home / ctf / k17

make-a-wish

A signed modulo on the array index makes free() take a stack pointer, and tcache never checks that a chunk lives on the heap.

2026.09.19 K17 CTF 2026 500 pts Pwn
FLAG K17{my_f4v0ur1t3_fl4v0ur_15_k1w1_p1n34ppl3_btw}
nc chal.secso.cc 4004 · handout: chal + Dockerfile (debian 13.4-slim, glibc 2.41, redpwn jail)

If you’re stuck, start with the array index expression, not the menu functions. This challenge is presented as a heap challenge, but the bug that actually opens it is a signed modulo on an array index, and it turns the whole thing into a stack challenge before malloc is even relevant.

1. Recon

RELRO      Partial       <- GOT writable
Stack      No canary     <- return address is reachable by a linear write
NX         enabled
PIE        No (0x400000) <- no binary leak needed
SHSTK/IBT  marked in .note (not enforced on the target)

The program asks for a full name, splits it on the first space, then loops a 3-option menu. create does malloc(144) + fgets(chunk,144,stdin); delete does free() and NULLs the slot.

main’s frame is a single frame for the whole process lifetime — the menu loop is a jmp, never a recursive call, so rbp stays constant throughout, which matters for what follows:

addrvar
rbp-0x74choice
rbp-0x70strarr[-2]
rbp-0x68narr[-1]
rbp-0x60 … -0x40arr[0..4] — the wish array
rbp-0x3030-byte name buffer (filled by read, so NUL bytes are allowed)
rbp-0x08first — re-read from memory before every menu action, printed with %s

2. Bug 1 — signed modulo, no bounds check

idx = idx % 5;      /* C semantics: sign is preserved */
... arr[idx] ...    /* no range check */

idx = -2 gives arr[-2], which — per the offsets above — is rbp-0x70, the str variable itself. So delete(-2) calls free() on a stack pointer, one that points into the name buffer we already control the contents of.

3. Bug 2 — the chunk header is author-controlled, and split() finishes it

free(str) reads the chunk’s size field at str-8, inside our own 30 controllable bytes. Putting the separating space at index 15 makes str equal rbp-0x20 — 16-byte aligned, as free() requires. The size field then occupies buf[8..15], whose last byte is that space character, and split() NUL-terminates precisely there:

buf[0:8]    'A'*8       -> prev_size (ignored)
buf[8]      0xa1        -> size = 0xa0 | PREV_INUSE == malloc(144)'s chunk size
buf[9:15]   00 00 00 00 00 00
buf[15]     ' '         -> overwritten with NUL by split() => size == 0x00a1
buf[16:]    second name -> str == rbp-0x20

glibc’s tcache path validates alignment and size, but never checks that the chunk actually lives on the heap. The fake chunk is inserted into tcache bin 8, and the next malloc(144) returns it:

fgets(rbp-0x20, 144, stdin)   -> 143 bytes directly over saved rbp + return address

4. Leak — safe-linking gives away the page

tcache_put stores PROTECT_PTR(&e->next, NULL) == mem_addr >> 12 at mem_addr. delete prints printf("...Mrs. %s", str) after the free, using the pointer value it saved on entry — before the free. So the first delete(-2) prints mem_addr >> 12, the stack page, missing only its low 12 bits.

5. Arbitrary read — offset 24 of the write is first

rbp-0x20 + 24 == rbp-0x08. Every reclaimed-chunk fgets therefore also sets main’s first pointer as a side effect, and the next prompt’s printf("...Mr. %s", first) reads any address supplied there. This was used to brute-force the missing 8 bits — mem_addr is 16-byte aligned, so there are only 256 candidates, and every candidate sits inside the page already leaked, so a wrong guess prints garbage rather than crashing. The whole 256-way sweep can therefore be pipelined as one write (roughly 0.5 s against the remote, versus ~1500 individual round trips).

the trap that cost the most time

stdout is setvbuf(_IONBF), so glibc builds each printf output inside a stack work buffer, and those leftovers sit inside the swept page and get read back along with everything else. The reply stream ends up containing whole extra copies of earlier prompts (spurious …Mr. delimiters, meaning more records than iterations) and of earlier payloads (spurious marker hits). Indexing the replies positionally is therefore unsound — it will silently misattribute values to the wrong iteration.

The fix is to make each iteration self-identifying rather than relying on position: write TG%05d + padding + p64(cand). mem_addr is the only address that holds tag(i) during iteration i, so any reply carrying tag(i) proves its own trailing pointer is mem_addr, without needing any index arithmetic at all.

6. ROP

The binary ships a gadget hidden inside an immediate:

4012de:  b8 31 c0 c3 5f      mov eax, 0x5fc3c031
               ^^ ^^ ^^ ^^
4012e2:        5f c3          -> pop rdi ; pop rbp ; ret

and main itself calls system("clear") at startup, so system@plt.sec (0x401130) is already resolved. No libc leak is required.

Final 88-byte write at mem_addr (= rbp-0x20):

rbp-0x20   'A'*24            padding
rbp-0x08   0x402121          ("clear") first -- still %s-printed on the exit path
rbp+0x00   'B'*8             saved rbp
rbp+0x08   0x4012e2          return address -> pop rdi ; pop rbp ; ret
rbp+0x10   mem_addr+0x50     -> rdi
rbp+0x18   junk              -> rbp
rbp+0x20   0x401130          system
rbp+0x28   junk              system's own return slot
rbp+0x30   "/bin/sh\0"

the second trap

The first version placed "/bin/sh" at rbp-0x20. system() forked and exec’d an empty command — strace showed the child doing nothing but exit_group(0). leave; ret plus the two pops leave rsp == rbp+0x28 by the time system is entered, so everything below that offset is scratch space that system’s own frame overwrites before it reads the string. Moving the command to rbp+0x30 — above rsp — fixed it, and that offset also keeps rsp % 16 == 8 at system’s entry, satisfying the ABI.

Choosing 3 to exit triggers leave; ret, which runs the chain.

7. Chain summary

  1. name = forged 0xa1 chunk header, str == rbp-0x20
  2. delete(-2)free() the stack chunk → leak mem_addr >> 12
  3. 256-way tagged sweep → exact mem_addr (→ rbp)
  4. create(0) → malloc returns the stack chunk → fgets writes the ROP chain
  5. 3leave; retsystem("/bin/sh")

exploit.py — 5/5 reliable locally (same glibc 2.41 container) and on the first attempt against the remote.

Notes for next time

  • A negative % on an array index is worth checking for in any heap-menu challenge. Here it turns a “heap” challenge into a pure stack challenge before malloc is even relevant to the bug.
  • tcache performs no heap-bounds check. Any free() of a pointer whose -8 byte is attacker-controlled is a write primitive to that address, regardless of where the pointer actually lives.
  • When stdout is unbuffered, leaked stack strings will be interleaved with glibc’s own printf work buffers. Never index leak replies positionally — tag each one so it can identify itself.
  • Verify what system() actually ran, with strace, before concluding a chain failed — a chain that ran an empty command and one that crashed look identical from the outside.
#tcache#stack#safe-linking#rop