Echo Escape 2 picoCTF 2026 Solution

Published: March 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 1
    Find the fgets() bug
    Observation
    I noticed the challenge description said fgets() was used but 'not correctly', which suggested inspecting the size argument against the actual buffer declaration to find a mismatch that still allows a stack overflow.
    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

    08049276 <win>:
    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 actual buffer allocation. Here the developer passed 128 while the buffer is 32 bytes, so fgets still reads 96 bytes past the end of the buffer. The switch to fgets fixes nothing when the size literal is wrong.

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

    checksec reads the actual binary ELF headers for SSP, NX, PIE, and RELRO flags - the source code does not tell you the compiler flags used. If a canary were present, a naive overflow would crash with a stack smashing error rather than redirecting execution, and you would need a leak step 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. Static analysis tools like -D_FORTIFY_SOURCE=2 and AddressSanitizer catch this at runtime; clang-tidy can catch 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 2
    Find the offset to the return address
    Observation
    I noticed the binary is 32-bit (confirmed by checksec and file), which meant I needed to find the exact number of padding bytes by reading the disassembly for the buffer's offset from EBP and adding 4 bytes 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 inspect after a crash is $eip (not $rip). Using cyclic_find() on the $rip value from a 64-bit GDB session will return a wrong offset or no match at all because GDB reports a zero-extended 64-bit register. Run the binary under a 32-bit-aware GDB or read the disassembly directly: the buffer is at EBP - 0x28, giving 44 bytes of padding.

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

    The buffer size alone does not equal the offset to the return address. The stack frame also contains the saved EBP (4 bytes on a 32-bit binary) between the end of the buffer and the return address. Using 32 bytes of padding overwrites only the end of the buffer and leaves the saved EBP and return address untouched, so the program returns normally without hijacking control flow.

    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 3
    Redirect execution to win()
    Observation
    I noticed objdump showed a non-stripped binary with a win() function at a fixed address, and checksec confirmed no PIE and no stack canary, which meant a straightforward ret2win using 44 bytes of padding followed by p32(win_addr) would redirect execution.
    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 the address as 8 bytes, but this is a 32-bit binary where the saved return address slot is only 4 bytes wide. Sending 8 bytes causes the extra 4 bytes to overwrite the next word on the stack, so execution jumps to garbage rather than win(). Always match the pack function to the binary architecture: p32 for 32-bit, p64 for 64-bit.

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

    The 16-byte SSE alignment requirement enforced by 64-bit System V ABI does not apply to 32-bit binaries. Inserting a ret gadget between the padding and win_addr shifts the return address by 4 bytes, so the function receives a corrupted frame and likely segfaults or misreads its local variables. On 32-bit x86 the payload is simply 44 bytes of padding followed immediately by p32(win_addr).

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

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 static analysis tools like AddressSanitizer and -D_FORTIFY_SOURCE verify.

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

Want more picoCTF 2026 writeups?

Tools used in this challenge

What to try next