Skip to main content

Guessing Game 2 picoCTF 2020 Mini-Competition Solution

Exploit a binary protected by a stack canary using a two-stage memory leak and buffer overflow attack.

Published: April 2, 2026Updated: August 13, 2026

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.

Remote

Download the binary and Makefile from the challenge page.

Install pwntools: pip install pwntools

Install ROPgadget: pip install ROPgadget

bash
pip install pwntools ROPgadget

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Read the Makefile and understand get_random
    Observation
    The 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).
    bash
    objdump -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 offset 0x30fe0 in libc, then the last 12 bits are 0xfe0. 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).

  2. Step 2Find your input's position on the stack with the format string bug
    Observation
    The 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.
    python
    python3 -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 %p specifier reads the next argument off the stack and prints it as a pointer. The %n$p syntax 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.

  3. Step 3Leak the stack canary at parameter 135
    Observation
    The 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.
    python
    python3 -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.

  4. Step 4Steal the server's libc to get accurate offsets
    Observation
    Full 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.
    bash
    nc -l -p 63921 > libc-2.27.so
    bash
    nc jupiter.challenges.picoctf.com 63921 < /lib/i386-linux-gnu/libc-2.27.so
    bash
    ROPgadget --binary libc-2.27.so --rop
    What 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 with p32() in your pwntools script, then add the computed libc base address to each offset.

  5. Step 5Send the exploit: guess -31, leak canary and libc, overflow with ROP chain
    Observation
    All 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.
    python
    python3 exploit.py
    What 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.

Key takeaway

Format string bugs and stack buffer overflows often show up together, and when a canary is present the format string leak is what makes the overflow exploitable at all. Reading the canary off the stack with a %n$p probe and replaying it in the overflow payload satisfies the canary check, so execution still reaches the attacker-controlled return address. Leaking a libc return address in the same read anchors ASLR, turning two separate information disclosures into a complete chain against Full RELRO, NX, canary, and ASLR at once.

Related reading

Useful tools for Binary Exploitation

Where to go next