Echo Escape 1 picoCTF 2026 Solution

Published: March 20, 2026

Description

The secure echo service welcomes you politely, but what if you don't stay polite? Can you make it reveal the hidden flag? Download the program file and source code.

Download vuln and its source code.

Read the source code to understand how input is handled.

bash
cat vuln.c
bash
chmod +x vuln

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Find the buffer overflow in the source
    Observation
    I noticed that the source code shows a 32-byte buffer but passes 128 to read(), and this mismatch between declared size and read limit is the textbook sign of a stack buffer overflow that lets us overwrite the return address.
    Read vuln.c. The buffer is declared as 32 bytes, but read() is called with a size of 128. This mismatch lets you write past the end of the buffer and overwrite the saved return address on the stack.
    bash
    cat vuln.c
    What didn't work first

    Tried: Run the binary and try sending a very long string manually via the terminal to see if it crashes.

    Typing input interactively won't let you embed null bytes or control exact byte counts, so you can't craft a precise payload. The overflow needs an exact number of bytes (40 padding + 8-byte address), and the address bytes are non-printable; you need a script to write the binary payload.

    Tried: Assume the padding is 32 bytes (the declared buffer size) and skip the saved RBP slot.

    32 bytes of padding only fills the buffer itself. The saved RBP sits in the 8 bytes immediately above the buffer before the return address, so using 32 bytes of padding overwrites part of the saved RBP and leaves the return address untouched. You need 32 + 8 = 40 bytes of padding to reach the return address.

    Learn more

    A stack buffer overflow occurs when more bytes are written into a stack-allocated buffer than it can hold. The extra bytes overwrite adjacent stack data, including the saved base pointer and the saved return address. When the function returns, it jumps to whatever address is now in the return address slot.

    In x86-64, when a function is called, the return address is pushed onto the stack first, then the old frame pointer, then local variables are allocated below that. So the layout from the buffer start is: buffer (32 bytes), then padding to the old saved RBP (8 bytes), then the return address (8 bytes). Writing 40 bytes of padding followed by the address of win overwrites the return address to redirect execution there.

  2. Step 2
    Find the address of win()
    Observation
    I noticed the source contains a win() function that opens and prints the flag but is never invoked in normal control flow, which meant I needed its exact binary address to use as the overwrite target in the payload.
    Locate the win() function in Ghidra or with objdump. It reads and prints the flag file.
    bash
    objdump -d vuln | grep '<win>'
    python
    python3 -c "from pwn import *; e=ELF('./vuln'); print(hex(e.sym['win']))"

    Expected output

    0000000000401256 <win>:
    0x401256
    What didn't work first

    Tried: Open the binary in a hex editor and search for the string 'win' to find the function address.

    A hex editor shows raw bytes and will find the ASCII bytes of 'win' inside the symbol table or string sections, but those offsets are not the executable address. The function's actual start address comes from the symbol table entry as interpreted by the ELF loader, not from a raw byte offset into the file. objdump and pwntools ELF both parse the symbol table correctly.

    Tried: Use nm vuln instead of objdump to get the win address.

    nm works on unstripped binaries and will print the win symbol address, so it is actually a valid alternative here. The common mistake is running nm on a stripped binary where the symbol table is removed, in which case nm returns nothing and objdump -d with grep on the disassembly listing is needed instead.

    Learn more

    The binary contains a win function that is never called in normal program flow. This function reads and prints the flag file. By overwriting the return address with the address of win, the program jumps to it when the vulnerable function returns.

    In Ghidra, open the binary, let it analyze, then look at the main program in the decompiler. You can see the buffer, the read call, and locate the win function address in the symbol tree.

  3. Step 3
    Build and send the exploit payload
    Observation
    I noticed the x86-64 stack layout places the 32-byte buffer below the 8-byte saved RBP and then the return address, so 40 bytes of padding followed by the little-endian win() address would land precisely on the return address slot when sent over the network connection.
    The buffer is 32 bytes below RBP. Above the buffer is 8 bytes of old RBP, then the return address. So 40 bytes of padding followed by the win address overwrites the return address. Send the payload via netcat.
    bash
    # Build the payload: 40 bytes of 'A' padding, then the 8-byte win address in little-endian.
    bash
    # Replace 0x401256 with the real win address from objdump. struct.pack('<Q', ...) emits 8 bytes for x86-64.
    python
    python3 -c "import sys, struct; sys.stdout.buffer.write(b'A'*40 + struct.pack('<Q', 0x401256))" | nc <HOST> <PORT_FROM_INSTANCE>
    bash
    # Or use pwntools:
    python
    python3 << 'EOF'
    from pwn import *
    
    e = ELF("./vuln")
    win_addr = e.sym["win"]
    
    payload = b"A" * 40
    payload += p64(win_addr)
    
    r = remote("<HOST>", <PORT_FROM_INSTANCE>)
    r.sendafter(b"Welcome", payload)
    print(r.recvall(timeout=3))
    EOF
    What didn't work first

    Tried: Pipe the payload directly with printf or echo instead of python3, for example: printf 'AAAA...\x56\x12\x40' | nc host port.

    Shell printf interprets escape sequences inconsistently across shells and truncates on null bytes (\x00), which appear in most 64-bit addresses padded to 8 bytes. The address 0x401256 packed as a 64-bit little-endian value is V@, which contains five null bytes that printf silently drops, sending a short payload that misses the return address slot. python3 with sys.stdout.buffer.write writes raw bytes without any null-stripping.

    Tried: Use the win address from the local binary on the remote server without checking whether ASLR is enabled.

    If the remote binary is PIE (Position Independent Executable), its base address is randomized each run and the win address in the local objdump output is a relative offset, not the real runtime address. For non-PIE binaries (which this challenge uses, as indicated by the fixed 0x401256 address), the address is the same on the server. Checking checksec ./vuln beforehand confirms whether PIE is on.

    Learn more

    The payload structure is: 40 bytes of padding (to fill the 32-byte buffer plus the 8-byte saved RBP), followed by the 8-byte little-endian address of win. When the function executes its ret instruction, it pops this address off the stack and jumps there.

    p64(addr) in pwntools packs a 64-bit integer into 8 bytes in little-endian order, which is the format x86-64 expects for return addresses. The Intel architecture is little-endian, meaning the least significant byte comes first in memory.

    See Buffer Overflow Binary Exploitation and Pwntools for CTF for the broader workflow.

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{3ch0_3sc4p3_1_...}

Stack buffer overflow: buffer is 32 bytes but read() accepts 128. Write 40 bytes of padding then the address of win() to redirect execution and read the flag.

Key takeaway

Stack buffer overflows exploit the fact that local variables, the saved frame pointer, and the saved return address all live in the same contiguous stack memory region. Writing past a buffer's declared size lets an attacker overwrite the return address and redirect execution to any code already loaded in the process, including hidden functions never called by the normal control flow. The same spatial memory confusion underlies heap overflows, off-by-one bugs, and format-string writes; mitigations like stack canaries, ASLR, and NX buy defense in depth but each addresses a different layer of the attack chain.

How to prevent this

The only root cause is reading more bytes than the buffer can hold. Bound the read to the buffer size.

  • Use bounded reads: read(0, buf, sizeof(buf)) or fgets(buf, sizeof(buf), stdin). Never pass a hardcoded size larger than the actual allocation.
  • Compile with -fstack-protector-strong. A stack canary placed between the buffer and the return address detects overflows before the function returns and aborts the process.
  • Don't ship a win() function that reads /flag in production binaries. CTF challenges include it for teaching; real code should never have an unreachable "open the vault" function.

Related reading

Want more picoCTF 2026 writeups?

Tools used in this challenge

What to try next