Forenser Canary
Forenser Canary
Platform: spbctf | Category: Forensics | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: frequency_analysis, glibc_canary_structure, qword_alignment_scan, stack_memory_analysis
Summary
Task: given a raw stack memory dump of a Linux x86_64 process (0x21000 bytes matching a VA range), recover the glibc stack canary. Solution: scan the dump for 8-byte aligned qwords with low byte 0x00 and non-zero upper 7 bytes (terminator canary pattern) and pick the most frequent clustered value.
Forenser Canary — spbctf
Description
Дан сырой дамп стека работающего процесса (
pid-4018-7ffc1efb8000-7ffc1efd9000, размер0x21000байт, совпадающий с диапазоном виртуальных адресов). Найдите stack canary.
English summary: we are given a raw memory dump corresponding to the stack region of a running Linux x86_64 process. The filename encodes the VA range (0x7ffc1efb8000–0x7ffc1efd9000), and the file size is exactly 0x21000 bytes — confirming it is a straight copy of that VMA. The goal is to extract the stack canary value used by glibc's SSP (Stack Smashing Protector).
Analysis
Glibc terminator canary on x86_64
On x86_64 Linux, glibc uses a terminator canary stored in the TLS at fs:0x28. Its structure:
- 8 bytes wide, always placed at an 8-byte aligned position on the stack.
- Low byte = 0x00 — the "terminator". This byte defeats string-based leaks (
strcpy,gets,readuntil newline) because a copy that stops on NUL will not leak or clobber the rest of the canary. - Upper 7 bytes = random, all non-zero — generated once per process at startup (from
AT_RANDOM//dev/urandom).
The same canary value is saved once in each stack frame of a function compiled with -fstack-protector, right above the saved RBP. In a real program there are typically several such frames alive at once, so the exact same qword appears multiple times on the stack, all at 8-byte aligned offsets, and all clustered near the currently active (high-address) part of the stack.
Strategy
Scan every 8-byte aligned qword in the dump and keep only values that look like a terminator canary:
value & 0xff == 0x00(low byte is the terminator).- Every other byte of the upper 7 is non-zero (a random 7-byte value has a very small chance of containing an extra 0x00).
- Exclude the obvious garbage: all zeros (
0x0000000000000000), pure ASCII runs that happen to end in 0x00 (environment strings).
Then rank the survivors by how many times they appear. A real canary wins with a clear margin and its hits cluster in a small address range (the live frames).
Solution
Step 1 — download the dump
bash read-only • copy allowedcurl -O https://pwn.spbctf.ru/files/canary/pid-4018-7ffc1efb8000-7ffc1efd9000 mv pid-4018-7ffc1efb8000-7ffc1efd9000 stack.dump # Sanity check: size must match the VA range. python3 -c "print(hex(0x7ffc1efd9000 - 0x7ffc1efb8000))" # 0x21000 ls -la stack.dump # 135168 = 0x21000
Step 2 — scan for terminator-canary candidates
python read-only • copy allowed#!/usr/bin/env python3 """Recover the glibc stack canary from a raw x86_64 stack dump.""" import struct from collections import Counter BASE = 0x7ffc1efb8000 with open("stack.dump", "rb") as f: data = f.read() assert len(data) == 0x21000, f"unexpected size: {hex(len(data))}" def looks_like_canary(q: int) -> bool: # Low byte must be 0x00 (terminator). if q & 0xff != 0x00: return False # Upper 7 bytes: all non-zero (random, no extra NULs). for i in range(1, 8): if (q >> (8 * i)) & 0xff == 0: return False # Reject obvious ASCII strings: every non-low byte printable ASCII. ascii_bytes = sum(1 for i in range(1, 8) if 0x20 <= ((q >> (8*i)) & 0xff) <= 0x7e) if ascii_bytes == 7: return False return True hits = [] # (offset, value) for off in range(0, len(data), 8): (q,) = struct.unpack_from("<Q", data, off) if looks_like_canary(q): hits.append((off, q)) cnt = Counter(q for _, q in hits) print("Top candidates (value : count, first/last VA):") for val, n in cnt.most_common(10): offs = [BASE + o for o, q in hits if q == val] print(f" {val:#018x} count={n} first={hex(min(offs))} last={hex(max(offs))}")
Step 3 — interpret the output
Running the scan produces something like:
Top candidates (value : count, first/last VA):
REDACTED count=7 first=0x7ffc1efd6e88 last=0x7ffc1efd8a10
0x...4c4c4548___00 count=1 ... ... # "SHELL=" tail
0x...4e4f495300 ... ... ... # "SESSION" tail
0x...53454d4447 ... ... ... # "GDMSESS" tail
...
The clear winner:
REDACTEDappears 7 times, more than any other candidate.- All 7 hits are 8-byte aligned.
- All 7 cluster in the high-address region
0x7ffc1efd6e88..0x7ffc1efd8a10— i.e. near the top of the stack, exactly where the currently live frames live. - Low byte
0x00, upper 7 bytes7a 46 f2 ea f0 4f 87— all non-zero, no obvious structure, high entropy.
All other survivors are single-shot remnants of ASCII environment strings on the stack (SHELL=..., XDG_SESSION_*, GDMSESSION=...) whose last byte happens to be the \x00 string terminator — they fail either the ASCII filter or the frequency test.