Revenge Of Womp Womp

Eh4x CTFby smothy

The Revenge of Womp Womp - EH4X CTF Writeup

Category: Binary Exploitation Points: 473 (dynamic) Solves: 38 Flag: EH4X{w0mp_g0t_w0mpp3d_4g41n}

We solved this challenge after the CTF ended since we were participating in two CTFs at the same time and couldn't get to it during the event.


Challenge Overview

We're given a stripped ELF binary (pwn) and a libc (libc.so.6 - glibc 2.34). The binary implements a custom virtual machine that reads opcodes from stdin and processes them in a main loop:

Main loop: puts("Pls input the opcode") buf = malloc(0x2000) memset(buf, 0, 0x2000) read(0, buf, 0x500) VM_executor(buf) free(buf) [repeat]

VM Opcodes

OpcodeFunctionDescription
0x01alloc(idx, size)calloc(1, size), size range 0x410-0x500, idx 0-16
0x02free(idx)Frees chunks[idx] without nulling the pointer (UAF!)
0x03show(idx)Prints chunk data (UAF read)
0x04edit(idx, data)Writes data to chunk (UAF write)
0x05exitReturns from VM executor
0x06diagCalls printf("diag:%#lx\n", val)

Protections

$ checksec --file=pwn Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x3ff000)

Plus a strict seccomp filter allowing only:

SyscallRestrictions
read (0)fd must be 0
write (1)fd must be 1 or 2
open (2)unrestricted
openat (257)unrestricted
close (3)unrestricted
fstat (5)unrestricted
mprotect, mmap, brkunrestricted
exit, exit_groupunrestricted
Everything elseKILL_PROCESS

This blocks execve, all signal syscalls (rt_sigprocmask, rt_sigaction), and critically futex - which matters for glibc's internal locking.

Key Constraints

  • Full RELRO: Can't overwrite GOT
  • glibc 2.34: No __malloc_hook/__free_hook (removed)
  • calloc: Skips tcache, zeroes memory
  • seccomp blocks signals: abort() internally calls rt_sigprocmask which triggers SIGSYS, so any malloc corruption that hits malloc_printerr is fatal
  • _exit() instead of exit(): No exit handler FSOP trigger

Exploitation Strategy

The exploit chains four advanced heap techniques together:

UAF Leak --> Large Bin Attack (x3) --> House of Husk --> House of Apple 2 (FSOP) --> ORW ROP

Technique Overview

1. Large Bin Attack (Arbitrary Write)

We use the unchecked "smallest chunk" insertion path in glibc 2.34's large bin to write heap chunk addresses to three global pointers.

The large bin maintains a skip-list via fd_nextsize/bk_nextsize pointers. When a smaller chunk B is inserted into a bin that already contains a larger chunk A, the "smallest" bypass path executes four pointer writes without any integrity checks:

Large bin structure showing fd_nextsize and bk_nextsize circular linked list Large bin skip-list structure (source: 0x434b.dev)

By corrupting A->bk_nextsize to target - 0x20, the insertion writes B_chunk to *target:

c
// glibc _int_malloc "smallest" bypass path (no integrity checks!)
victim->bk_nextsize = fwd->bk_nextsize;        // B->bk_nextsize = target-0x20
fwd->bk_nextsize = victim;                     // A->bk_nextsize = B
victim->bk_nextsize->fd_nextsize = victim;      // *(target-0x20+0x20) = B → *target = B_chunk

We fire three attacks simultaneously in a single unsorted bin scan:

AttackTargetWritten Value
1_IO_list_allB1_chunk (fake FILE)
2__printf_function_tableB2_chunk (non-NULL)
3__printf_arginfo_tableB3_chunk (controlled table)

2. House of Husk (Hijack printf)

When __printf_function_table is non-NULL, vfprintf enters its positional argument path which calls __printf_arginfo_table[specifier] for every format specifier in the string.

Printf function table check - the if statement vfprintf checks if __printf_function_table is non-NULL (source: Maxwell Dulin)

Printf arginfo table function pointer execution Then indexes __printf_arginfo_table and calls the function pointer (source: Maxwell Dulin)

The diag opcode calls printf("diag:%#lx\n", val). The format specifier %x has ASCII value 0x78. We set arginfo_table[0x78] = _IO_flush_all, so when the diag opcode fires, printf calls _IO_flush_all for us.

3. House of Apple 2 (FSOP to Code Execution)

_IO_flush_all iterates _IO_list_all (which now points to our fake FILE at B1_chunk) and calls _IO_OVERFLOW(fp, EOF) through the vtable.

Our fake FILE has:

  • vtable = _IO_wfile_jumps (passes IO_validate_vtable check)
  • _mode = 1 (triggers wide code path)
  • _wide_data pointing to controlled memory

The chain follows:

_IO_flush_all_lockp └─→ _IO_OVERFLOW(fp, EOF) └─→ _IO_wfile_overflow(fp) [via _IO_wfile_jumps vtable] └─→ _IO_wdoallocbuf(fp) [since _IO_write_base == 0] └─→ _IO_WDOALLOCATE(fp) [since _IO_buf_base == 0] └─→ _wide_vtable->__doallocate(fp) = setcontext+0x2d ← NOT validated!

The critical insight is that _wide_vtable (inside _IO_wide_data) is NOT checked by IO_validate_vtable, unlike the main vtable. This gives us arbitrary function pointer control.

4. setcontext+0x2d (Stack Pivot to ROP)

_IO_wfile_overflow conveniently sets rdx = fp->_wide_data before calling through the wide vtable. The setcontext+0x2d gadget reads register values from rdx:

asm
setcontext+0x2d:
  mov rcx, [rdx+0xe0]     ; FPU env pointer
  fldenv [rcx]             ; load FPU environment
  ldmxcsr [rdx+0x1c0]     ; load SSE control
  mov rsp, [rdx+0xa0]     ; STACK PIVOT!
  mov rbx, [rdx+0x80]     ; load callee-saved regs
  ...
  mov rcx, [rdx+0xa8]     ; return address
  push rcx
  mov rdi, [rdx+0x68]     ; rdi from ucontext
  mov rsi, [rdx+0x70]     ; rsi from ucontext
  mov rdx, [rdx+0x88]     ; rdx (overwrites self)
  xor eax, eax
  ret                      ; → ROP chain

This pivots RSP to our ORW ROP chain:

close(0) ; free fd 0 openat(AT_FDCWD, "flag", 0) ; re-opens as fd 0 read(0, buf, 0x80) ; seccomp allows read(fd=0) write(1, buf, 0x80) ; seccomp allows write(fd=1)

Heap Layout

We allocate 17 chunks in a carefully designed layout. A chunks are LARGER than B chunks in the same large bin to ensure B hits the unchecked "smallest" insertion path:

Heap Layout

Barrier chunks (0x420 each) prevent freed chunks from consolidating with each other.


Exploit Walkthrough

Round 1: Allocate + Leak

Allocate all 17 chunks, then free leak1 and leak2 into the unsorted bin. Read their fd pointers via UAF show:

  • leak2->fd = leak1_chunk (heap leak)
  • leak1->fd = unsorted bin head (libc leak)

Round 2: Batched Large Bin Attacks

All three attacks fire in a single unsorted bin scan to minimize trigger allocations:

Step 1: free(A1), free(A2), free(A3) → all go to unsorted bin Step 2: alloc(0x410) → sorts A1→bin68, A2→bin67, A3→bin66 Step 3: edit A1, A2, A3 → corrupt bk_nextsize to targets Step 4: free(B1), free(B2), free(B3) → all go to unsorted bin Step 5: alloc(0x410) → sorts B1→bin68, B2→bin67, B3→bin66 ALL THREE ATTACKS FIRE!

Round 3: Setup arginfo table

Edit B3 via UAF to set arginfo_table[0x78] = _IO_flush_all (the entry for %x format specifier).

Round 4: Setup fake FILE + Trigger

Edit B1 via UAF to construct the complete attack FILE structure, then fire the diag opcode:

B1 Memory Layout


Debugging Adventures

Bug 1: Wrong Large Bin Insertion Path

Initially B was LARGER than A, hitting the checked middle insertion path. The integrity check fwd->bk_nextsize->fd_nextsize != fwd fails because *_IO_list_all (= _IO_2_1_stdout_) != A_chunk.

Fix: Make B SMALLER than A to hit the unchecked "smallest" bypass path.

Bug 2: VM Buffer Forward Consolidation

After Round 2, free(VM_buffer) tries to consolidate forward with A1 (which is in a large bin). unlink(A1) checks nextsize integrity and fails → abort()rt_sigprocmask → SIGSYS.

Fix: Add a guard chunk (idx 16) between the VM buffer and A1.

Bug 3: LOCK_ADDR Was chunks[12]!

The most subtle bug. We set LOCK_ADDR = 0x4041e0 thinking it was "unused BSS." It was actually chunks[12] in the binary's global array! After allocating and freeing leak1 at index 12, the UAF left a non-zero heap pointer there.

When _IO_flockfile(fp) tried the atomic CAS on our "lock," the value was a heap address (not 0), so the CAS failed, __lll_lock_wait called futex() → blocked by seccomp → SIGSYS.

$ strace ./pwn (trimmed) ... futex(0x4041e0, FUTEX_WAIT_PRIVATE, 2, NULL) +++ killed by SIGSYS (core dumped) +++

Fix: Use a location inside a calloc'd barrier chunk on the heap (guaranteed zeros, never written to).

python
# Old (broken): LOCK_ADDR = 0x4041e0  # Actually chunks[12]!
# New (fixed):
LOCK_ADDR = A1_chunk + 0x0520 + 0x200  # barrier1's user data

Full Exploit Chain Diagram

Exploit Chain


Flag

$ python3 solve.py remote [+] Opening connection to chall.ehax.in on port 11111: Done [*] === ROUND 1: Allocate + Leak === [*] Heap leak (leak1_chunk): 0x21d4fcc0 [*] Libc leak (unsorted_bin): 0x72be4bb09cc0 [*] Libc base: 0x72be4b8f0000 ... [*] === ROUND 2: Large bin attacks === [*] Round 2 complete — all three large bin attacks done [*] === ROUND 3: Setup arginfo table === [*] Round 3 complete — arginfo_table[0x78] = _IO_flush_all [*] === ROUND 4: Setup attack FILE + trigger === [*] Waiting for flag... [+] Flag: EH4X{w0mp_g0t_w0mpp3d_4g41n}

References