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 1Find the overflow and read the seccomp allowlist
ObservationThe binary reads 0x80 bytes into a 32-byte buffer, and the description mentions a seccomp filter. Establish the exact overflow offset and which syscalls survive the filter before planning any chain.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 filter blocks execve, so one_gadget and any system call to a shell trigger a signal and kill the process. The kernel enforces this, not the binary, so defeating ASLR or PIE changes nothing. The way through is an open-read-write chain using only the permitted syscalls.
Tried: Calculate the overflow offset as 32 (just the buffer size, skipping saved RBP).
32 bytes fills the buffer exactly and stops on the saved frame pointer, not the return address, which sits eight bytes further along. Stop short and you corrupt the frame pointer and crash in the epilogue rather than redirecting anything.
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 2Pivot the stack to fit a longer chain
Observation96 bytes of overflow is about eleven ROP slots, nowhere near enough for a leak plus an open-read-write sequence. Re-enter the read call to stage a larger payload at a fixed .bss address, then pivot the stack pointer there.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.
After the 40-byte pad, 96 bytes leaves about eleven slots. A leak, a re-read, and the three-call sequence need thirty or more. The read limit truncates the chain silently, so execution returns into garbage and segfaults before reaching the open. The pivot is what buys the 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, ASLR still randomizes the stack while the .bss page sits at a fixed address. Pivoting somewhere stack-relative needs a leak first, which is circular. Targeting the fixed data region relies only on program-segment addresses being static, which PIE-off already guarantees.
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 3Leak libc with ret2csu, then ORW the flag
ObservationThere is no standalone gadget to load rsi and rdx, but the standard __libc_csu_init epilogue is present. Use ret2csu to control the argument registers for a GOT leak through write, then chain the open, read, and write calls the filter permits.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 have no standalone sequence popping both those registers, so ROPgadget finds nothing or only unreliable partial matches. The __libc_csu_init epilogue loads several registers before a controlled call and is present in nearly every binary of this shape, which makes it the fallback when the direct 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 from your local install, so hardcoded offsets point at the wrong addresses and the open or write call jumps into garbage. Leak a GOT entry at runtime, subtract that libc's known symbol offset, and derive the base before computing anything else.
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
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
- 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.