Skip to main content

lockdown-horses picoMini by redpwn Solution

Exploit a binary running under a restricted syscall policy to retrieve the flag.

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

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.

Remote + binary

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.

bash
checksec --file=lockdown-horses
bash
seccomp-tools dump ./lockdown-horses
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 1Find the overflow and read the seccomp allowlist
    Observation
    The 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.
    bash
    seccomp-tools dump ./lockdown-horses   # allowlist: open/read/write/mmap/getdents64/exit, no execve
    bash
    # 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 execve forbidden, 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.

  2. Step 2Pivot the stack to fit a longer chain
    Observation
    96 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.
    bash
    ROPgadget --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 read to deposit a large stage at a fixed writable address, then moving RSP there, gives unlimited chain length. PIE being disabled is what makes the fixed staging address reliable.

  3. Step 3Leak libc with ret2csu, then ORW the flag
    Observation
    There 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.
    python
    python3 - <<'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()
    PY

    Expected 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 rdx gadget. The __libc_csu_init epilogue is a universal gadget present in most non-PIE binaries that lets you load rbx, rbp, r12, r13, r14, r15 and then move them into rdi/rsi/rdx before a call, which is exactly what you need to set up the three-argument write and later open/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.

Key takeaway

seccomp restricts which system calls a process may make, enforced at the kernel boundary whatever the binary contains. With execve blocked, the usual ROP-to-shell ending is gone, and an open-read-write chain still discloses the file using only permitted calls. Hardened CTF binaries and production sandboxes both work this way: the attacker still needs code execution, and the policy decides which primitives remain afterwards.

Related reading

Useful tools for Binary Exploitation

Where to go next