Skip to main content

vr-school picoMini by redpwn Solution

Freeing a student leaves a dangling pointer, so overlap it with a name buffer, leak libc and the stack, then ROP.

Published: April 2, 2026Updated: August 25, 2026

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.

Remote + binary + libc

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.

bash
checksec --file=vr-school
bash
pwninit --bin vr-school --libc libc.so.6
bash
nc <HOST> <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Overlap a freed student with a name buffer (UAF + type confusion)
    Observation
    The student struct and the name buffer are both 0x18 bytes, and freeing a student leaves the global pointer dangling rather than clearing it. Allocate a name right after the free and it recycles that chunk, letting you forge the struct's 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 filling the tcache first, freeing the student drops it there and the next same-size allocation pops it straight back. The overlap technically happens, and the chunk vanishes into a fresh allocation before you can build anything on it. Saturate the tcache so the student free goes to the fastbin instead, then drain the tcache with seven allocations; the eighth pulls the student chunk back, which is the window you need.

    Tried: Write more than 24 bytes into the name buffer to control additional fields.

    The allocation is 0x18 bytes on a 0x20 chunk boundary, so writing past 24 bytes lands in the next chunk's metadata. That corrupts a size field and aborts the very next allocation, killing the process before the forged student is any use. Stay inside the limit.

    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 name pointer 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.

  2. Step 2Leak libc and then a stack address
    Observation
    The forged student's name pointer can hold any address, and the program dereferences it to print. Point it at a GOT entry for a libc address, then at the environ symbol for a live stack pointer.
    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.
    python
    python3 - <<'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
    PY
    What 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.

    That offset measures from the start of the shipped libc to an internal symbol. Any other version, your system's included, lays out differently, so the computed base is far off and every later lookup, environ and gadgets alike, points at unmapped memory. Patch the binary to the provided libc and confirm the mapping in GDB.

    Tried: Use __environ to get a stack address and then subtract a fixed offset without confirming it inside GDB against the actual binary.

    That distance below environ holds only for this binary at the exact call depth where the menu loop returns. Guess it and your chunk lands in a frame that is not about to return, so the chain never fires. Break on the return in GDB, read environ, and subtract to get the real offset.

    Learn more

    Why environ. __environ is a libc global that holds a pointer into the process stack. Once you have a libc base, reading __environ hands you a concrete stack address, which is what you need to redirect control flow on a stack with a known saved-return-address slot.

  3. Step 3Fastbin-poison onto the stack and ROP to the flag
    Observation
    Those two leaks pin the saved return address, and modern glibc has removed the malloc and free hooks. Poison the fastbin to allocate a chunk over the stack and write an open-read-write chain there, with no shell required.
    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.
    python
    python3 - <<'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)
    PY

    The finish is a syscall ROP (open/read/write), not a __free_hook or system overwrite. 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 removed both allocator hooks; the symbols are gone. The provided libc is newer, so aiming the poisoned chunk at those addresses writes into unmapped or unrelated memory and either crashes or does nothing. Redirect the saved return address into a chain that makes the syscalls itself.

    Tried: Poison the tcache fd directly without first exhausting the 7-entry tcache bin to reach the fastbin path.

    Each tcache bin holds up to seven entries, and while there is room every free lands there and follows tcache dequeue rules, including the pointer mangling added in glibc 2.32. Without saturating the bin first, your free is a tcache entry and its forward pointer is read under those rules rather than fastbin ones. Poison the wrong field and the allocation goes somewhere else and takes the heap with it. Get the tcache into the right state before any of this works.

    Learn more

    Why ROP and not a hook. Modern glibc removed the malloc/free hooks, and the challenge does not hand you a clean system path, 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.

Key takeaway

A use-after-free plus type confusion is the foundation of modern heap exploitation. When two object types share a size class and a freed pointer is never cleared, allocating the other type recycles the same memory, and you write one struct's fields through the other's interface. That overlap, plus a controlled read for an ASLR leak and tcache poisoning for arbitrary allocation, appears in nearly every real glibc heap exploit and browser sandbox escape of the last decade.

Related reading

Tools used in this challenge

Where to go next