Description
Despite the name, this is not a heap challenge. It is a plain stack buffer overflow guarded by a seccomp filter. main reads 0x80 bytes into a 32-byte buffer, so you control the return address, but execve is blocked, so you cannot pop a shell. The solve is an open/read/write ROP chain that prints the flag file, built with a stack pivot because the inline overflow space is too small.
Setup
Download the binary. Run checksec (PIE is off, so program addresses are fixed) and dump the seccomp policy with seccomp-tools.
Inspect the input-reading function and compare the read size against the buffer size.
checksec --file=lockdown-horsesseccomp-tools dump ./lockdown-horsesnc <HOST> <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Find the overflow and read the seccomp allowlistObservationI noticed the binary reads 0x80 bytes into a 32-byte buffer and the description mentioned a seccomp filter, which suggested I first map the exact overflow offset and confirm which syscalls are permitted before planning the ROP strategy.main declares a 32-byte buffer (local_28[32]) and calls read(0, local_28, 0x80), giving 96 bytes of overflow. The offset from the buffer to the saved return address is 40 (32 buffer + 8 saved RBP). The seccomp filter allows open, read (fd 0), write (fd 1), mmap, getdents64, and exit/exit_group, but NOT execve, so a one_gadget or system shell is impossible. You must read the flag with file syscalls.bashseccomp-tools dump ./lockdown-horses # allowlist: open/read/write/mmap/getdents64/exit, no execvebash# Overflow offset to saved RIP = 40 (0x20 buffer + 8 saved RBP).What didn't work first
Tried: Try a one_gadget or ret2system finish after confirming the overflow offset.
The execve syscall is blocked by the seccomp filter, so one_gadget and any system('/bin/sh') chain both trigger a SIGSYS and the process dies. The filter is enforced by the kernel, not the binary, so bypassing ASLR or PIE does not help. The only path forward is an ORW chain restricted to the permitted syscalls.
Tried: Calculate the overflow offset as 32 (just the buffer size, skipping saved RBP).
Writing 32 bytes fills the buffer exactly but lands on the saved RBP, not the saved RIP. The return address sits 8 bytes further at offset 40. Using offset 32 corrupts RBP instead of RIP, producing a crash on the frame's epilogue rather than a controlled redirect.
Learn more
Why seccomp changes everything. A seccomp filter is a kernel-enforced syscall allowlist. With
execveforbidden, the usual "return into system("/bin/sh")" finish is dead. The remaining syscalls (open,read,write) are exactly enough to open the flag file, read it, and print it, which is the intended ORW (open-read-write) chain.Step 2
Pivot the stack to fit a longer chainObservationI noticed that 96 bytes of overflow provides only about 11 ROP slots, which is far too few for a full libc-leak plus ORW sequence, which suggested re-entering the read call to stage a larger payload at a fixed .bss address and then pivoting RSP there via leave; ret.The overflow is too small for the whole chain. Use a stack pivot: set RBP and return into main's read so it stages a larger second payload at a fixed writable address (PIE is off, so the .bss/data page around 0x602000 is usable), then pivot RSP onto it with leave; ret (or a pop rsp; ret gadget). Now you have a roomy controlled chain.bash# Stage 1 (within 40+slots): set RBP to a fixed writable page, return into the read call (~0x400b6f)bash# to write stage 2 at 0x602908, then leave; ret pivots RSP onto stage 2.bashROPgadget --binary lockdown-horses | grep -E 'pop rsp|leave|pop rdi|pop rsi'What didn't work first
Tried: Cram the full ORW chain inline into the 96-byte overflow without pivoting.
96 bytes is only about 11 eight-byte ROP slots after the 40-byte pad. A libc leak plus a re-read plus the three-call ORW sequence needs roughly 30+ slots. The chain is silently truncated by the read(0x80) limit, so execution returns to garbage and the process segfaults before reaching the open call. The pivot stage is necessary to extend available chain length.
Tried: Use a stack pivot targeting the stack itself (e.g. pivot RSP to a location still on the current stack frame).
With PIE off, the stack address is randomized by ASLR while the .bss page is at a fixed, known address. Pivoting to a stack-relative address requires a leak first, creating a circular dependency. Targeting the fixed .bss/data region around 0x602000 avoids this by relying only on the PIE-off property that all program-segment addresses are static.
Learn more
Why pivot instead of inline. A full chain needs a libc leak (write a GOT entry to stdout), a re-read of input, and then the ORW sequence; that is far more than 11 slots. Re-entering
readto deposit a large stage at a fixed writable address, then movingRSPthere, gives unlimited chain length. PIE being disabled is what makes the fixed staging address reliable.Step 3
Leak libc with ret2csu, then ORW the flagObservationI noticed the binary lacked a standalone pop rsi; pop rdx gadget but contained the standard __libc_csu_init epilogue, which suggested using ret2csu to control rdi/rsi/rdx for a write@plt GOT leak and then chaining open/read/write calls permitted by the seccomp filter.Use the ret2csu gadgets (the two gadgets in __libc_csu_init) to control rdi, rsi, rdx and call write@plt against a GOT entry, leaking libc. With libc resolved, build open('flag'...) -> read(fd, buf, n) -> write(1, buf, n). If the flag filename is randomized, first use getdents64 on the directory to recover it.pythonpython3 - <<'PY' from pwn import * elf = ELF("./lockdown-horses") io = remote("<HOST>", <PORT_FROM_INSTANCE>) # stage 1: pivot to a larger chain at a fixed writable page # stage 2: ret2csu -> write(1, got['read'], 8) to leak libc, re-read stage 3 # stage 3: open(flag) ; read(fd, buf, 0x100) ; write(1, buf, 0x100) io.interactive() PYExpected output
picoCTF{n0_sh3ll_v3ry_flag_xdd}The exact gadget addresses (csu gadgets around 0x400be0/0x400bfa, the read call site, the writable staging page) come from this specific binary and the provided libc; pull them with ROPgadget and the leaked base.
What didn't work first
Tried: Search for a pop rsi; pop rdx gadget to set up the write call instead of using ret2csu.
Most non-PIE x86-64 binaries compiled without extra gadget injection do not contain a standalone pop rsi; pop rdx sequence. ROPgadget will find none or only unreliable partial matches. The __libc_csu_init epilogue is specifically designed to load multiple registers before a controlled call and is universally present in this class of binary, making it the reliable alternative when a direct pop rdx gadget is missing.
Tried: Hard-code libc offsets from a local libc instead of computing them from the leaked GOT value.
The remote instance runs a different libc version than a typical local install. Hard-coded offsets will point to the wrong addresses, causing the open or write call to jump into garbage and crash. The correct approach is to leak a GOT entry (e.g. read@got) at runtime, subtract the known symbol offset for that libc version, and derive the base before computing any other symbol addresses.
Learn more
Why ret2csu. Statically the binary may lack a clean
pop rsi; pop rdxgadget. The__libc_csu_initepilogue is a universal gadget present in most non-PIE binaries that lets you loadrbx, rbp, r12, r13, r14, r15and then move them intordi/rsi/rdxbefore a call, which is exactly what you need to set up the three-argumentwriteand lateropen/read. See Pwntools for CTF.
Interactive tools
- Pwntools ForgeGenerate a complete pwntools exploit script from a template: ret2win, shellcode, ret2libc, ROP chain, format string, or blank scaffold. Fill the form, copy or download the .py file. Fully editable before saving.
Flag
Reveal flag
picoCTF{n0_sh3ll_v3ry_flag_xdd}
Stack BOF (32-byte buffer, read 0x80, return-address offset 40) under a seccomp filter that blocks execve. Pivot the stack to a fixed writable page for a longer chain, leak libc with ret2csu + write, then ROP open/read/write to print the flag file. No shell is possible by design.