Hyper-Neighbor

REVERSE TAMUctf  ·  2026-03-22  ·  Flush+Reload cache side-channel with CRC-as-oracle decoding
gigem{you-really-need-to-calm-down-10aa082660957ffe}

Hyper-Neighbor drops you into a shared container (pwn.red/jail, Alpine/musl) next to a noisy neighbour: a sender process that quietly encodes the flag into cache eviction patterns. Nobody hands you the flag — you have to listen through the cache. The sender clflushes 64 shared cache lines in a pattern; your job is to build a receiver that times those lines with rdtsc and reconstructs the bits.

The twist that makes this a real challenge is noise. A raw Flush+Reload read of a 32-bit word carries 2–8 bit errors. You cannot simply read the channel — you have to decode it. This writeup is about the decoding, because that is where all the time goes: turning the protocol's own CRC field into an error-correcting oracle and voting per-bit across hundreds of noisy readings.

Table of Contents

  1. The Channel and the Receiver
  2. Reversing the Protocol
  3. The Noise Model — Know Your Weak Lines
  4. CRC as an Error-Correcting Oracle
  5. Per-Bit Consensus Voting
  6. Collecting Enough Data & Decoding
  7. Pitfalls
  8. Key Lessons

1 The Channel and the Receiver

Background: Flush+Reload Flush+Reload is a cache side-channel. Two processes share read-only memory (here the system libc). The receiver clflushes a cache line, waits, then times how long it takes to read it back with rdtsc: a fast read means the other process touched that line (it got re-cached), a slow read means it didn't. Do that over 64 lines and you have a covert channel leaking one bit per line — no shared API, just shared cache.

The sender and receiver live in the same container and share a memory-mapped file — the system libc. The sender transmits by selectively evicting cache lines of that shared mapping; the receiver reads those same lines back and measures access latency. A fast access means the line is cached (bit sense one way); a slow access means it was flushed (the other). That is classic Flush+Reload.

The receiver is a small static-glibc C program that:

sender clflush pattern → shared libc cache lines → rdtsc probe (64 lines) → noisy 64-bit word
The channel is lossy, not clean. Even with a stability filter, a recovered 64-bit word carries several bit errors. Errors are overwhelmingly 1→0 (a missed eviction reads as "still cached"), and rarely the reverse. That asymmetry is the single most useful fact for decoding — a bit that ever reads as 1 is strong evidence the true bit is 1.

2 Reversing the Protocol

The sender is a Rust binary. Ghidra (headless) plus radare2/objdump give the wire format: each 64-bit word is a chunk laid out as three fields.

# 64-bit bitmask per chunk
bits  0-31 : data    # 4 ASCII bytes as a little-endian u32
bits 32-47 : index   # u16 chunk index, sequential 0,1,2,...
bits 48-63 : crc     # CRC16 (folded CRC32C)

The CRC is computed with SSE4.2 hardware CRC32C and then folded to 16 bits:

crc = crc32c_u32(0xdeadbeef, data)   # seed is the flavour-text constant
crc = crc32c_u16(crc, index)
checksum = (crc >> 16) ^ (crc & 0xFFFF)   # fold 32 -> 16 bits

The stream is framed: a START marker (data = 1), then chunks 0..N, then an END marker (data = 2), and the whole thing repeats. Each 64-bit bitmask is pushed through the 64 shared cache lines and the pattern is repeated ~500K times to give the receiver many chances to sample it.

Verify the CRC against hardware before trusting it. I re-implemented CRC32C in Python, but a side-channel decode is only as good as its oracle. A tiny C program using _mm_crc32_u32 / _mm_crc32_u16 intrinsics confirmed the Python CRC matches the hardware bit-for-bit — otherwise every "match" downstream would be against a phantom.

3 The Noise Model — Know Your Weak Lines

Not all cache lines are equally reliable. Some lines miss their eviction most of the time, so the bits they carry are almost always wrong. The winning move is to build a per-cache-line error rate empirically, using chunks whose data you already trust (the ones that produce a direct CRC match). Those give you ground truth to measure every line against.

Field (bits)Weak cache linesObservation
data (0–31)7, 12, 15, 17, 19, 23, 25, 27, 31very weak (<15% reliable)
crc (48–63)line 52bit 4 of CRC flipped in ~14% of readings
The CRC field is corruptible too. My biggest wrong assumption was that the CRC was always correct and only the data/index drifted. But the CRC sits on cache lines 48–63, which also contain weak lines (52). When no data value matches an observed CRC, the honest explanation is often that the CRC itself is wrong, not that your data search is incomplete.

4 CRC as an Error-Correcting Oracle

The data field is too noisy to read directly, but the CRC gives us a cheap, local correctness test: for any candidate (data, index) we can recompute the checksum and compare it to what we observed. That turns decoding into a search guided by an oracle. Per chunk, in order of reliability:

crc_func(data, idx) == observed_crc
direct match
flip 1 of 32 data / 16 index bits
1-bit correction
all 4-byte printable candidates
brute force
consensus data + CRC within 1–2 bits
CRC-error recovery
  1. Direct match. crc_func(observed_data, observed_idx) == observed_crc → done.
  2. 1-bit data fix. Flip each of the 32 data bits in turn and re-check the CRC.
  3. 1-bit index fix. Flip each of the 16 index bits — this catches a chunk that was read at the wrong observed index.
  4. Full printable brute force. A C program tries all 4-byte printable-ASCII values (~81M candidates) against the target CRC at the target index, using Hamming distance to the observed data as a tiebreaker when several values share a checksum.
  5. CRC-error recovery. When nothing matches, assume the CRC is corrupted: reconstruct the data by bit-consensus (next section), then accept it if its computed CRC lands within 1–2 bits of the observed CRC, cross-validated against the known CRC-error patterns from other chunks.
for cand in candidates_in_reliability_order(chunk):
    if crc32c_fold(cand.data, cand.idx) == cand.crc:
        accept(cand)                 # oracle says yes
        break
# else: escalate to 1-bit fixes -> brute force -> CRC-error recovery

5 Per-Bit Consensus Voting

The same chunk is transmitted many times, so the obvious idea is majority-vote on the raw 32-bit value across all readings. That fails on the hardest chunks: when several weak lines each suppress their bit most of the time, the correct word may never be the single most common raw value — every reading is corrupted somewhere.

The fix follows straight from the noise asymmetry. Because errors are almost entirely 1→0, a true 1 only has to show up occasionally to betray itself. So vote on each bit independently, with a low threshold:

def consensus_bit(readings, j):
    seen_one = sum((r >> j) & 1 for r in readings)
    # a bit seen as 1 in >20% of readings is probably truly 1
    # (weak lines suppress it the rest of the time)
    return 1 if seen_one > 0.20 * len(readings) else 0

data = sum(consensus_bit(readings, j) << j for j in range(32))
Bit-level consensus beats majority-vote on raw values. A bit seen as 1 in more than ~20% of readings is almost certainly a true 1 being suppressed by a weak line. This recovered chunk 10, where raw-value majority voting had settled on a wrong word.

6 Collecting Enough Data & Decoding

Everything downstream is statistics, so the decode quality is bounded by how many readings you gather. A single run of 179 readings was not enough for the hardest chunks. Two runs of ~750 readings (1270 total) gave the per-bit statistics the consensus vote needs. The jail allows ~230s of collection per session (JAIL_TIME=300s), so spend all of it.

# connection + jail setup
io = remote("streams.tamuctf.com", 443, ssl=True, sni="hyper-neighbor")
solve_pow(io)                    # PoW solver provided as /tmp/pow
upload_static_binary(io)         # base64 + gzip receiver
run_for(io, seconds=230)          # collect as many readings as the TTL allows
[*] run 1: 179 readings -> hardest chunks still ambiguous [*] run 2: +~750 readings (1270 total) -> per-bit stats sufficient [*] chunk 9: direct CRC match [+] chunk 10: raw-majority WRONG -> per-bit consensus recovered it [*] CRC-error chunks: computed CRC within 1 bit of observed, cross-validated [+] flag decoded: gigem{you-really-need-to-calm-down-10aa082660957ffe}

7 Pitfalls

8 Key Lessons

A protocol's integrity check is the attacker's error-correcting oracle. Raw cache-channel data is unreadable (2–8 bit errors per word), but the sender's own CRC turns decoding into oracle-guided search: direct match, 1-bit correction, printable brute force, and finally CRC-error recovery when the checksum itself is corrupted. Always look for integrity checks in the protocol and weaponise them.

Tools: Ghidra (headless) + radare2 + objdump (reversing the Rust sender), gcc static-glibc (receiver + C brute force), pwntools (remote, PoW, upload), Python (CRC32C, statistics, per-bit consensus), C with _mm_crc32_u32/_mm_crc32_u16 (hardware-CRC verification).