PIE TIME picoCTF 2025 Solution

Published: April 2, 2025

Description

A PIE-protected binary leaks the address of main each time you connect. Use that leak to compute the absolute address of win and jump there instead of returning to main.

Fetch both the binary and its source so you can inspect the control flow (win simply prints the flag).

Confirm with checksec that PIE is on and a relevant function (win) is present in the symbol table.

Connect to nc rescued-float.picoctf.net <PORT_FROM_INSTANCE> and note the leaked address of main.

Use objdump or radare2 locally to record the offsets of main and win inside the binary.

bash
wget https://challenge-files.picoctf.net/c_rescued_float/2736a730340dbe9969fe3104da0cca0c60eddaf1fedb0e220b5df5a3f3cf015f/vuln.c
bash
wget https://challenge-files.picoctf.net/c_rescued_float/2736a730340dbe9969fe3104da0cca0c60eddaf1fedb0e220b5df5a3f3cf015f/vuln
bash
chmod +x vuln
bash
checksec --file=./vuln
bash
objdump -D vuln | grep -E "<win>|<main>"
bash
nm vuln | grep -E ' T (main|win)$'

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
Leak then redirect is the canonical PIE-defeat shape. The ASLR / PIE Bypass guide covers the math, and Pwntools for CTF shows the ELF + remote() idiom that automates the offset arithmetic.
  1. Step 1
    Derive the PIE base
    Observation
    I noticed the binary prints the runtime address of main before asking for any input, and objdump shows main sits at static offset 0x133d, which suggested subtracting that fixed offset from the leaked value to recover the randomized PIE base for this run.
    The binary literally hands you the leak: main itself contains a printf("main is at %p\n", main) call (visible in the source / decompilation), so connecting prints the runtime address before you send any input. Subtract the static main offset (0x133d) to recover the PIE base for that run. Because ASLR randomizes per-process, the leak and your subsequent input run in the same process, so the base stays valid for the whole connection.
    bash
    # Verify the static offsets in the binary first:
    objdump -d vuln | grep -E '<win>:|<main>:'
    
    # Sample output:
    #   00000000000012a7 <win>:
    #   000000000000133d <main>:
    
    pie_base = leaked_main - 0x133d
    What didn't work first

    Tried: Subtract the leaked address from the win offset directly (win_address = leaked_main - 0x12a7) instead of going through the PIE base.

    This produces a garbage address because it conflates two different steps. The leaked value is a runtime address, not a base. The correct path is leaked_main - main_offset to isolate the base, then base + win_offset to reach win. Skipping the intermediate base means the offset subtracted is wrong and the result lands outside the loaded binary.

    Tried: Run the binary locally and hardcode the addresses printed during that run into the exploit sent to the remote server.

    Local and remote processes each get their own ASLR-randomized base on every exec, so addresses from a local run are never valid for the remote. The correct approach is to parse the leak the remote server prints on each fresh connection, compute the base from that run's leaked main address, and send the exploit within the same connection.

    Learn more

    Position Independent Executables (PIE) are binaries compiled with -fPIE -pie so that every instruction and data reference uses relative addressing. At load time, the OS kernel maps the binary to a random base address chosen by ASLR (Address Space Layout Randomization). Every subsequent address inside the binary is that base plus the static offset from the compiled binary, so main's runtime address equals pie_base + 0x133d.

    ASLR is per-process: each fresh exec gets a fresh random base. The leak you receive on connect is valid for that connection's exploit because the program prints it inside the same process that will read your input. Reconnecting (or hitting a forking server's sibling worker) will give you a different base, so do not mix leaks across runs.

    objdump -D disassembles all sections of the binary and prints symbol addresses as offsets from zero (since the binary isn't loaded yet). readelf -s and nm provide cleaner symbol table output. The key insight is that these offsets are fixed at compile time - ASLR only randomizes the base, not the relative layout.

  2. Step 2
    Compute the win address
    Observation
    I noticed objdump confirmed win sits at static offset 0x12a7 inside the binary, and since the PIE base is now known, adding that fixed offset to the base produces the exact runtime address where win will be loaded for this process.
    Add the static win offset (0x12a7) to the PIE base. Inline pwntools helper below: parse the leak with int(line, 16) and emit the target.
    python
    # Minimal helper - drop into a pwntools script:
    from pwn import *
    p = remote('rescued-float.picoctf.net', PORT)
    leak_line = p.recvline(timeout=2).decode()           # 'main is at 0x...'
    leaked_main = int(leak_line.strip().split()[-1], 16)
    win_address = leaked_main - 0x133d + 0x12a7
    log.info(f'leaked main = {hex(leaked_main)}')
    log.info(f'win address = {hex(win_address)}')
    What didn't work first

    Tried: Use elf.symbols['win'] as the win_address directly without adding the PIE base, assuming pwntools already resolves it to a runtime address.

    elf.symbols['win'] returns the static offset from the ELF file (0x12a7), not the runtime address. Pwntools does not automatically know the PIE base until you set it with elf.address = pie_base. Without that assignment the computed address is just the raw offset, which is far below the binary's actual load location and causes a segfault.

    Tried: Use p64(win_address) to pack the address before parsing the leak line, placing the send before receiving the prompt.

    Calling p.recvline() after already sending data races against the server's output. The server prints the leak before asking for input, so you must receive the leak line first, parse it, compute win_address, then send. Sending early means the packed bytes are treated as the name input and the actual address prompt is never reached.

    Learn more

    The address arithmetic win_address = leaked_main - main_offset + win_offset is the fundamental formula for all PIE-defeat exploits. It generalizes to any two symbols in the binary: knowing one runtime address and both static offsets lets you compute any other runtime address. CTF players often write a small Python script using pwntools (from pwn import *) which automates connecting to the service, parsing the leaked address, computing the target address, and sending the exploit.

    Pwntools' ELF class can parse the binary automatically: elf = ELF('./vuln'); main_offset = elf.symbols['main']; win_offset = elf.symbols['win']. This avoids manual offset extraction from objdump and keeps the exploit script portable across different binary versions. The context.arch and context.log_level settings further simplify address packing and debugging output.

    Real-world exploitation frequently involves leaking a libc address (via a puts call or format string) rather than a binary address, computing the libc base, then jumping to system('/bin/sh') or using a ROP gadget chain. The arithmetic is identical - only the target binary (libc vs. the vuln binary) changes.

  3. Step 3
    Send the target address
    Observation
    I noticed the program prompts for an address to jump to after printing the main leak, so supplying the computed win address here causes the binary to call that address directly, redirecting execution into the flag-printing function.
    Reconnect (or keep the connection open), paste the computed 0x... value when prompted, and the binary jumps straight into win, printing the flag.
    Learn more

    The mechanism that lets you "send an address" and have the binary jump there is almost always a stack buffer overflow. The vulnerable program reads more input than the buffer can hold, overwriting the saved return address on the stack. When the current function executes its ret instruction, it pops your supplied address into the instruction pointer and execution continues from there.

    The win function pattern - a function that prints the flag but is never called by normal program flow - is a classic CTF teaching device. In real-world binary exploitation, there is no win function; attackers typically chain ROP gadgets to call execve('/bin/sh', NULL, NULL) or use a one-gadget (a single libc address that directly spawns a shell under the right register conditions).

    Keeping the connection alive (rather than reconnecting) is important because ASLR generates a new random base on each process execution. If the service forks (a common CTF server pattern), the child inherits the parent's address space layout, so the leak from one connection is valid for subsequent requests to the same child process. New connections that spawn new processes get fresh ASLR randomization.

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{b4s1c_p051t10n_1nd3p3nd3nc3_31cc...}

Keep one terminal open with nc to grab the current leak and a second to run the helper script so the values stay in sync.

Key takeaway

ASLR randomizes a binary's load address each run, but the relative distance between any two functions inside the binary is a compile-time constant that never changes. A single leaked runtime address from any symbol is therefore enough to compute every other symbol's address for that process. This 'leak then redirect' two-step is the foundation of real-world exploitation against ASLR-protected targets, whether the leak comes from a debug print, a format string, or a read-beyond-bounds in a network response.

How to prevent this

PIE only works if the binary does not hand out leaked addresses for free, and only if user input cannot redirect control flow. Both sides need fixing.

  • Don't print pointer values, return addresses, or anything from %p. Debug logs that leak addresses in error messages are a primary ASLR-bypass vector.
  • Bounds-check every input that lands on the stack. The challenge here only works because the program reads more bytes than the local buffer holds; fgets(buf, sizeof(buf), stdin) instead of gets(buf) closes the door even if a leak exists.
  • Don't accept arbitrary jump targets from untrusted input. If the program needs to dispatch to one of N functions, validate against an allowlist or a small enum before doing the indirect call - never feed user-supplied addresses into a void(*)().
  • Combine PIE with stack canaries (-fstack-protector-strong) and full RELRO (-Wl,-z,relro,-z,now). Even with a leak, the canary blocks the overflow and RELRO blocks GOT overwrites. For high-value services add -D_FORTIFY_SOURCE=2 and AddressSanitizer in early-deploy windows.

Related reading

Want more picoCTF 2025 writeups?

Tools used in this challenge

Do these first

What to try next