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.
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:
mmap(MAP_SHARED)s the same libc file the sender uses, so both see one physical copy.rdtsc timing, deriving an adaptive threshold from a
calibration phase (cache timing drifts between hosts, so a hard-coded cut-off is fragile).N can disturb line N+1).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.
_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.
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 lines | Observation |
|---|---|---|
| data (0–31) | 7, 12, 15, 17, 19, 23, 25, 27, 31 | very weak (<15% reliable) |
| crc (48–63) | line 52 | bit 4 of CRC flipped in ~14% of readings |
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(observed_data, observed_idx) == observed_crc → done.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
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))
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
"52ab" matching CRC=0x084e in 1/48 readings)
wasted real time. Require multiple independent confirmations for any value that
appears only once or twice.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).