Description
A glibc heap challenge built around a 0x18-byte 'student' record. Freeing a student leaves a dangling global pointer, and reallocating a same-size 'name' buffer lands on top of the freed student. That overlap lets you forge a student with attacker-controlled pointers: an arbitrary read for the libc leak, then tcache poisoning onto the stack for a syscall ROP that reads the flag.
Setup
Download the binary and the provided libc; patch the binary to use it for local debugging.
Map the menu: how a student is created and freed, and how the name buffer is allocated. Note the allocation sizes involved.
checksec --file=vr-schoolpwninit --bin vr-school --libc libc.so.6nc <HOST> <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Overlap a freed student with a name buffer (UAF + type confusion)ObservationI noticed that both the student struct and the name buffer are exactly 0x18 bytes, and that freeing a student leaves the global pointer dangling without zeroing it, which suggested that a same-size name allocation immediately after the free would recycle the freed chunk and let me forge the student's internal fields.A student is a 0x18-byte struct of three 8-byte fields (a function pointer, a name pointer, and a name length). Free a student and the global pointer to it is left dangling. Fill the 0x18 tcache so your next 0x18 allocation reuses that exact chunk, then allocate a 'name' buffer over it: now the 24 bytes you write as a name overlay the freed student's fields. You can forge a student whose name pointer is any address you choose.bash# Fill the 0x18 tcache, free a student, reallocate a 0x18 name onto the freed student chunk.bash# The name's 24 bytes overwrite {func_ptr, name_ptr, name_len}.Expected output
picoCTF{0nl1ne_d3bat3_sux}What didn't work first
Tried: Free the student immediately and allocate the name right away without first filling the 0x18 tcache bin.
Without first filling the tcache (7 prior frees of 0x18 chunks), freeing the student puts it straight into the tcache and the very next 0x18 allocation pops it right back out - which might sound like the overlap works, but it also means the forged-student chunk immediately vanishes into a fresh malloc context before you can set up the rest of the exploit. The standard approach is to pre-fill the tcache to saturation so the student free goes to the fastbin instead; you then drain the tcache with 7 allocations and the 8th pulls the student chunk from the fastbin, giving you a controlled window to forge its fields before triggering the print.
Tried: Write more than 24 bytes into the name buffer to control additional fields.
The allocation is exactly 0x18 bytes; glibc will service it as a 0x20-byte chunk boundary, so a write longer than 24 bytes overflows into the next chunk's metadata. This corrupts the heap's size field and causes a malloc abort on the very next allocation, crashing the process before the forged student can be used. Staying within the 24-byte name limit is required to keep the heap consistent.
Learn more
Why the overlap is the whole game. Two different object types sharing one size class plus a use-after-free means a write meant for a name lands on a live (dangling) student. Controlling the student's
namepointer turns the program's "print this student's name" feature into an arbitrary read, and controlling its fields lets you stage the rest of the attack.Step 2
Leak libc and then a stack addressObservationI noticed that the forged student's name pointer can be set to any address and the program will dereference it to print 'the name', which suggested pointing it first at a GOT entry to recover a libc address and then at the libc environ symbol to obtain a live stack pointer for targeting the saved return address.Set the forged student's name pointer to a GOT entry and print it to leak a libc address; in the challenge libc, libc_base = leaked_malloc - 0x97140. Then point the name pointer at libc_base + 0x3ee098 (the environ symbol) to leak a live stack address, and subtract 0x130 to reach a saved return address on the stack. These offsets are specific to the shipped libc; recompute them against the provided one.pythonpython3 - <<'PY' # after forging a student whose name ptr -> GOT entry: libc_base = leaked_malloc - 0x97140 # libc-specific environ = libc_base + 0x3ee098 # __environ symbol # read *environ to get a stack address, then: saved_ret = stack_leak - 0x130 # a saved return address slot PYWhat didn't work first
Tried: Apply the same libc_base offset (leak - 0x97140) against a different libc build or the system libc instead of the provided one.
The offset 0x97140 is the distance from the start of libc to the internal malloc symbol in the exact shipped libc. Any other libc version - including the one installed on your system - will have a different internal layout, making libc_base wrong by a large amount. All subsequent symbol lookups (environ, gadgets) will point at unmapped memory and the next dereference segfaults. Always patch the binary with pwninit to use the provided libc and verify with vmmap inside GDB.
Tried: Use __environ to get a stack address and then subtract a fixed offset without confirming it inside GDB against the actual binary.
The saved return address sits at environ_value - 0x130 only for this specific binary at the specific call depth when the menu loop returns. If the offset is guessed rather than measured, the 'stack chunk' lands in a stack frame that is not the one about to return, so the ROP chain is never triggered. The correct method is to break on the return instruction in GDB, read __environ, and compute saved_ret = return_addr_in_GDB subtracted from environ to get the exact offset.
Learn more
Why environ.
__environis a libc global that holds a pointer into the process stack. Once you have a libc base, reading__environhands you a concrete stack address, which is what you need to redirect control flow on a stack with a known saved-return-address slot.Step 3
Fastbin-poison onto the stack and ROP to the flagObservationI noticed that the libc leak and stack address gave me a precise target for the saved return address, and that modern glibc had removed the malloc/free hooks, which suggested using fastbin poisoning to allocate a writable chunk over the stack and writing an open/read/write ROP chain there to read flag.txt without needing a shell.Poison the 0x18 fastbin fd (after exhausting tcache with 7 frees) so a subsequent allocation returns a chunk over the saved return address you computed. Write a ROP chain there (or pivot with a pop rsp; ret gadget at libc+0x3960 onto a larger heap-resident chain) that performs open('flag.txt') -> read -> write(1, ...) using syscall gadgets, then triggers the return. The flag is printed back to you.pythonpython3 - <<'PY' from pwn import * # 1) poison tcache fd -> saved_ret # 2) alloc to land a writable chunk over the stack # 3) write ROP: open("flag.txt",0) ; read(fd, buf, n) ; write(1, buf, n) # (pivot via pop rsp; ret at libc_base + 0x3960 if the inline space is too small) PYThe finish is a syscall ROP (open/read/write), not a
__free_hookorsystemoverwrite. That detail matters: the target is set up so a shell is not the path; you read the flag file directly.What didn't work first
Tried: Overwrite __free_hook or __malloc_hook with system to get a shell instead of building a ROP chain.
Glibc 2.34 and later removed __free_hook and __malloc_hook entirely - the symbols no longer exist. The provided libc is a newer build where these hooks are gone, so pointing the poison chunk at those addresses writes into unmapped or unrelated memory and either segfaults or has no effect. The only reliable path is to redirect the saved return address into a ROP chain that calls open/read/write syscalls directly.
Tried: Poison the tcache fd directly without first exhausting the 7-entry tcache bin to reach the fastbin path.
Tcache has per-size bins holding up to 7 entries; as long as there is room, frees go into tcache and its dequeue logic applies (including the mangled-pointer scheme in glibc 2.32+). If the bin is not first saturated, a subsequent free is treated as a tcache entry and its fd is interpreted under tcache rules, not fastbin rules. Poisoning the wrong field sends the allocation to the wrong address and crashes the heap. The exploit must drain or fill the tcache to the correct state before fastbin poisoning becomes operative.
Learn more
Why ROP and not a hook. Modern glibc removed the malloc/free hooks, and the challenge does not hand you a clean
systempath, so the reliable finish is to redirect the saved return into a ROP chain that issues the file-read syscalls itself. The tcache poison provides the write-what-where to plant that chain on the stack. See Pwntools for CTF for the ROP plumbing.
Interactive tools
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{0nl1ne_d3bat3_sux}
0x18 student struct UAF + type confusion: overlap a freed student with a name buffer to forge controlled pointers, leak libc via a GOT read (libc = leak - 0x97140), leak a stack address via __environ, then fastbin-poison onto the saved return address and ROP open/read/write of flag.txt. Offsets are libc-specific.