Every pwn challenge trains you to look at the server. You get a binary, you find the parser, you smash the thing that reads your input. This one flipped the table: the binary was a client, and I was the server. It took me an embarrassingly long time to accept that, and the moment I did, the whole challenge collapsed in about twenty minutes.
Hello world! This one is a bit different from my usual posts, no CVE, no bug bounty, just a CTF challenge from SEKAICTF 2026 that lived rent-free in my head for a weekend. It's a heap challenge, but the actual difficulty wasn't the heap at all (glibc 2.31 tcache is basically a tutorial at this point). The difficulty was the mental flip: realizing which side of the connection I was standing on.
The challenge hands you afc_list, a client for AFC (Apple File Conduit), the protocol libimobiledevice speaks to talk to iPhones. Normally this binary connects to a real Apple device, asks it to list a directory, and prints the result. But in the challenge, the "device" on the other end is... you. You connect to the service, and the service's client talks AFC at you, and it will believe absolutely anything you say back to it. :D
That's the whole idea, and honestly it's a genuinely nice challenge design, because it models something real. Real clients parse untrusted data from real devices all the time, and almost nobody audits the client side of a device protocol. Let's dig in.
What we're actually attacking
Before touching any heap, here's the stuff that shipped with the challenge and what each piece does:
src/afc_list.c— an interactive wrapper around the reallibimobiledeviceAFC API. This is just the driver, the bug is not here.afc_list— the main binary, compiled without PIE (remember this).libc.so.6— glibc 2.31, provided. (Also remember this.)Dockerfile— Ubuntu 20.04 environment, installsreadflag.hook.sh— patches the nsjail config to allow SUID and disable ASLR. (Definitely remember this one.)xpl1.py— my final exploit.
First thing I always do, checksec:
Arch: amd64
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Two lines here are the entire exploit strategy, and everything else is noise:
Partial RELRO—.got.pltis writable. If I can write one pointer anywhere, I want to write it there.No PIE—free@gotlives at a fixed address. No leak needed to find my target.
And then the jail hook, which is the part that made me smile:
persona_addr_no_randomize: true
disable_no_new_privs: true
nosuid:false
persona_addr_no_randomize: true means ASLR is off. So libc is at a fixed address too. So I don't need a leak for the value either. The challenge is basically telling me "you don't need infoleaks, just go get a write primitive", which is a very generous hint if you actually read the config files instead of skipping straight to the binary (I skipped straight to the binary, obviously, and read the hook an hour later hehehe).
The protocol, in the five minutes it takes to care
Every AFC packet starts with a 0x28-byte header, and it's about as simple as protocols get:
typedef struct {
char magic[8]; // "CFA6LPAA"
uint64_t entire_length;
uint64_t this_length;
uint64_t packet_num;
uint64_t operation;
} AFCPacket;
Two length fields. Two. Sitting right next to each other, both fully attacker-controlled, both describing the same body.
If you've done this long enough, that struct alone should already be making a noise in your head. Any time a format carries two independent descriptions of one buffer, somebody somewhere is going to allocate using one of them and read using the other. That's not a guess, that's just how it always goes.
On the service side, the text interface is dead simple:
ls <path>
read <path>
write <path> <data>
...
and those commands map onto the real library calls:
afc_read_directory(afc, path, &list);
afc_file_open(afc, path, AFC_FOPEN_RDONLY, &h);
afc_file_read(afc, h, data, sizeof(data), &got);
Every one of those sends an AFC request out to "the device". And since I am the device, every one of those is an invitation for me to reply with whatever I want. The command interface isn't the attack surface, it's just the trigger. The attack surface is the response parser.
The bug (yeah, it's the two length fields)
The AFC receive path in libimobiledevice boils down to this:
body_size_alloc = header.entire_length - 0x28;
body_size_read = header.this_length - 0x28;
buf = malloc(body_size_alloc);
service_receive(conn, buf, body_size_read);
There it is. Allocate with entire_length, read with this_length. Nobody ever checks that the second one is <= the first, because on a real Apple device those two fields are always consistent, so the invariant "holds" in every test the developer ever ran.
This is the exact thing I keep saying in my other posts: every codebase has assumptions the developer never expected to be broken. Here the assumption is "the device is honest". Break that one assumption and the code is trivially exploitable, with no clever trick needed anywhere.
So I reply with:
entire_length = 0x28 + 8
this_length = 0x28 + 0x28
which makes the library malloc(8) and then read 0x28 bytes into it. On glibc, malloc(8) gives back a 0x20-sized chunk, so writing 0x28 bytes from the user pointer walks straight off the end of it, over the next chunk's size field, and into the next chunk's fd. Which is exactly as much as I need:
poison = b"P" * 0x18
poison += p64(0x21) # keep the next chunk a valid 0x20 chunk
poison += p64(FREE_GOT) # poisoned fd
A controlled heap overflow, handed to me by a length field. Beautiful.
Why tcache poisoning just works here
Now, in 2026, "tcache poisoning" usually comes with a caveat: safe-linking. Since glibc 2.32, tcache fd pointers are XOR-mangled with the chunk address (ptr ^ (addr >> 12)), so overwriting fd with a raw address gets you nothing unless you already have a heap leak to compute the encoding.
But this challenge ships glibc 2.31. And 2.31 is the last version before safe-linking landed. tcache stores raw forward pointers. So the attack is the old, clean, no-nonsense one: if a bin looks like
A -> B -> C
and I overwrite B->fd with free@got, the bin now reads
A -> B -> free@got
Pop enough small allocations and malloc() (or strdup()) eventually hands me a pointer that is free@got. And the second that happens, any copy into that pointer is a GOT write. That's the whole plan: turn a strdup() into an arbitrary write to a fixed, writable, executable-on-call address.
Grooming the heap with a directory listing
To make the tcache line up, I first need to know what shape the 0x20 bin is in. The nice thing about controlling the device side is that I control the heap layout too, just by answering an ls / with the data I choose:
groom = b"A" * 8 + b"\x00" + b"B" * 8 + b"\x00" + b"C" * 8
self.ls_response(groom)
On screen this looks boring, two directory entries:
AAAAAAAA
BBBBBBBB
But the interesting part is what it does to the heap while nobody's watching:
- the raw AFC data buffer is small, lands in a 0x30 chunk;
- the parsed pointer vector lands in a 0x20 chunk;
- each
strdup()of the two entries also lands in a 0x20 chunk; - then
free_list()frees all of it, in reverse order.
After that one command, the 0x20 tcache bin holds 3 chunks in a completely predictable order. And 3 is exactly the number I need, because the final trigger is going to consume, in order: one chunk for the list pointer vector, one for strdup("/readflag sekai ppp"), and then the third allocation is the poisoned one that comes back as free@got.
I love this part of heap work. You spend all your time arranging furniture so that one specific allocation lands on one specific address, and to the program it just looks like it printed a directory listing.
Firing the overflow
To reach the vulnerable read path I use the read /x command. The path doesn't exist and doesn't matter, I'm the filesystem, so I get to say it does.
First the client calls afc_file_open(), and I hand it a completely made-up file handle:
self.respond(packet, p64(1), op=OP_FILE_OPEN_RES)
Then it calls afc_file_read(), and that's where the malformed packet goes:
poison = b"P" * 0x18 + p64(0x21) + p64(FREE_GOT)
self.respond(packet, poison, entire=0x28 + 8, this=0x28 + len(poison))
Step by step, what the library does to itself:
malloc(8)pops the first 0x20 chunk out of tcache;- the 0x28-byte read overflows it into the next free chunk;
- that chunk's
fdis now0x404070, which isfree@got; - when the read buffer gets freed, it goes back on the head of the bin.
Leaving me with:
tcache[0x20] = A -> B -> free@got
Writing system() into free@got
Target address, fixed thanks to No PIE:
free@got = 0x404070
Value to write, fixed thanks to that persona_addr_no_randomize line in the hook:
libc base = 0x7ffff7d65000
system = libc base + 0x52290
system = 0x7ffff7db7290
And the delivery mechanism is, once again, just an ls / response. I send two entries: the command I want executed, and the pointer bytes I want written.
payload = b"/readflag sekai ppp\x00"
payload += p64(SYSTEM)[:6] + b"\x00"
That [:6] is the one detail worth pausing on. Why only 6 bytes of an 8-byte pointer? Because in x86-64 userspace this address is canonical and its top two bytes are zero anyway. If I wrote the full 8 bytes I'd be feeding two extra NUL bytes into a parser that splits entries on NUL, which would create junk list entries and wreck my carefully groomed allocation count. So I write 6 bytes, let strdup()'s own NUL terminator supply byte 7, and the remaining high byte is already zero in the GOT. Clean.
Then the final parse runs and the tcache pays out exactly as planned:
- the pointer vector consumes
A; strdup("/readflag sekai ppp")consumesB;strdup(system_bytes)returnsfree@got;strdup()copies thesystem()bytes intofree@got.
And from this instant on, in this process:
free(ptr) == system(ptr)
The best part: the program pwns itself
Here's my favorite thing about this challenge. I don't have to trigger execution. I don't need a ROP chain, I don't need to hijack a return address, I don't need to do anything at all. The binary does it to itself during normal cleanup.
After printing a listing, afc_list tidies up like a well-behaved program:
static void free_list(char **list, int n)
{
for (int i = n - 1; i >= 0; i--) free(list[i]);
free(list);
}
Perfectly good code. Frees every string, frees the vector, no leaks, nothing wrong with it. Except free is now system, so what actually runs is:
system(list[i]);
And one of those list[i] entries is a string I chose. The reverse iteration order means the garbage entry gets executed too and the shell complains about it, before and after the one that matters, but who cares. The call I came for is:
system("/readflag sekai ppp")
The output is gloriously messy:
/readflag sekai ppp
<system pointer bytes>
sh: 1: <garbage>: not found
SEKAI{REDACTED}
sh: 1: <garbage>: not found
afc>
The flag just sitting there in the middle of two shell errors. I scrape it out with:
rb"SEKAI\{[^}\n]*\}"
The whole thing, end to end
Full flow, in order:
- Connect to the service.
- Wait for the
afc>prompt. - Send
ls /to groom three 0x20 tcache chunks. - Send
read /x. - Answer
afc_file_open()with a fake handle. - Answer
afc_file_read()withentire_length < this_lengthto overflow. - Overwrite the next free chunk's
fdwithfree@got. - Finish the read command with an empty AFC response.
- Send the final
ls /. - Let
strdup()writesystem()intofree@got. - Let
free_list()run/readflag sekai pppfor me. - Regex the flag out of stdout.
Running it:
./xpl1.py local
./xpl1.py remote HOST PORT
Local mode just points at 127.0.0.1:5000.
Conclusion
The exploit stands on three environment properties, and it's worth being honest about how much each one is carrying:
- glibc 2.31, no safe-linking — I can write a raw pointer into a tcache
fd. On 2.32+ I'd need a heap leak first to compute the mangled value. - ASLR disabled by the nsjail hook —
system()is at a known address. Otherwise I'd need a libc leak before I could write anything useful, and I'd have to build a second primitive just to get it. - Partial RELRO —
.got.pltis writable. With Full RELRO the entire GOT-overwrite route is dead and I'd be hunting for hooks orexithandlers instead.
Take away any one of those and this becomes a much longer post. But the bug itself doesn't care about any of that, and that's the part I want to leave you with. The bug is just two length fields that were never checked against each other, because the code assumed the thing on the other end of the wire was a real iPhone being honest with it.
That's the lesson I actually took from this challenge, and it generalizes way past CTFs: we spend enormous effort hardening servers against clients, and comparatively nobody looks at clients being fed by "trusted" hardware. Phone clients, printer drivers, USB stacks, sync daemons... all of them parse attacker-reachable data while assuming the peer is well-behaved. If you ever want a productive place to go hunting, go read a client-side protocol parser and ask "what if the device lies?" hehehe.
See you in the next one. Bye! :D
References
- libimobiledevice — the AFC client implementation
- src/afc.c — the AFC receive path with the two length fields
- glibc malloc.c — tcache internals
- Safe-Linking (Check Point) — the mitigation glibc 2.31 doesn't have yet
- nsjail — where
persona_addr_no_randomizecomes from