← Back to Writeups
CUSTOMEasyForensics

Forenser Canary

XESXOR9/2/20264 min read
#forensics#custom#easy#frequency_analysis#glibc_canary_structure#qword_alignment_scan#stack_memory_analysis#linux

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, read until 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:

  1. value & 0xff == 0x00 (low byte is the terminator).
  2. 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).
  3. 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
curl -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
#!/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:

  • REDACTED appears 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 bytes 7a 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍