lockdown-horses picoMini by redpwn Solution

Published: April 2, 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 1
    Find the overflow and read the seccomp allowlist
    Observation
    I 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.
    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 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 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 2
    Pivot the stack to fit a longer chain
    Observation
    I 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.
    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.

    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 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 3
    Leak libc with ret2csu, then ORW the flag
    Observation
    I 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.
    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 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 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
  • 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 (secure computing mode) is a Linux kernel mechanism that restricts which system calls a process may invoke, enforced at the kernel boundary regardless of what the binary contains. When execve is blocked, the conventional ROP-to-shell finish is unavailable, but an open/read/write (ORW) chain achieves file disclosure using only the permitted syscalls. This pattern is common in hardened CTF binaries and real production sandboxes: the attacker still needs code execution, but the seccomp policy shapes exactly which post-exploitation primitives remain available.

Related reading

Want more picoMini by redpwn writeups?

Useful tools for Binary Exploitation

What to try next