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). Get the files from the challenge page on CyLab Security Academy (formerly play.picoctf.org).
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.
chmod +x vulnchecksec --file=./vulnobjdump -D vuln | grep -E "<win>|<main>"nm vuln | grep -E ' T (main|win)$'Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
ELF + remote() idiom that automates the offset arithmetic.Step 1Derive the PIE base
ObservationThe binary prints main's runtime address before asking for anything, and objdump puts main at static offset 0x133d. Subtract one from the other and you have this run's PIE base.The binary literally hands you the leak:mainitself contains aprintf("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 staticmainoffset (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 - 0x133dWhat 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.
That collapses two steps into one and produces garbage. The leak is a runtime address, not a base. Subtract main's offset to isolate the base, then add win's offset. Skip the base and you subtract the wrong number and land 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.
Every exec gets its own randomized base, local and remote alike, so a local address is never valid remotely. Parse the leak the server prints on each fresh connection, compute that run's base, and send the exploit on the same connection.
Learn more
Position Independent Executables (PIE) are binaries compiled with
-fPIE -pieso 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, somain's runtime address equalspie_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 -Ddisassembles all sections of the binary and prints symbol addresses as offsets from zero (since the binary isn't loaded yet).readelf -sandnmprovide 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.Step 2Compute the win address
Observationobjdump puts win at static offset 0x12a7. With the base known, adding that offset gives win's runtime address for this process.Add the staticwinoffset (0x12a7) to the PIE base. Inline pwntools helper below: parse the leak withint(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 returns the static offset from the file, not a runtime address. Pwntools does not know the PIE base until you assign elf.address. Without that, the value you compute is the bare offset, far below where the binary actually loaded, and the jump segfaults.
Tried: Use p64(win_address) to pack the address before parsing the leak line, placing the send before receiving the prompt.
Reading the line after you have already sent data races the server. The leak comes before the prompt, so receive it, parse it, compute the address, then send. Send early and your bytes are consumed by the address prompt before you have a leak to compute from.
Learn more
The address arithmetic
win_address = leaked_main - main_offset + win_offsetis 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'
ELFclass can parse the binary automatically:elf = ELF('./vuln'); main_offset = elf.symbols['main']; win_offset = elf.symbols['win']. This avoids manual offset extraction fromobjdumpand keeps the exploit script portable across different binary versions. Thecontext.archandcontext.log_levelsettings further simplify address packing and debugging output.Real-world exploitation frequently involves leaking a libc address (via a
putscall or format string) rather than a binary address, computing the libc base, then jumping tosystem('/bin/sh')or using a ROP gadget chain. The arithmetic is identical - only the target binary (libc vs. the vuln binary) changes.Step 3Send the target address
ObservationAfter the leak, the program asks for an address to jump to. Give it the computed one and it calls straight into the flag-printing function.Stay on the same connection that printed the leak, paste the computed0x...value when prompted, and the binary jumps straight intowin, printing the flag.Learn more
This binary hands over control the direct way: it reads a hex value with
scanf("%lx", ...), casts it to a function pointer, and calls it, so no memory corruption is involved at all. The more common mechanism for "send an address and the binary jumps there" is a stack buffer overflow, where the program reads more input than the buffer holds and overwrites the saved return address; when the function executes itsretinstruction it pops your supplied address into the instruction pointer. Either way, the address arithmetic above is the same.The
winfunction 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 nowinfunction; attackers typically chain ROP gadgets to callexecve('/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...}
Grab the leak and send the computed address on the same nc session; a second terminal is only for the arithmetic, because a reconnect re-randomizes the base and invalidates the old leak.
Key takeaway
How to prevent this
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. This binary is not overflowed, but the same leak feeds a classic overflow just as well, and
fgets(buf, sizeof(buf), stdin)instead ofgets(buf)closes that door even when 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=2and AddressSanitizer in early-deploy windows.