algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

Spectre v1 — the CPU runs code it shouldn't

2026-03-123 min readside-channel research

The first two parts treated the cache as an oracle for what a victim did. Spectre turns it into an oracle for what a victim's CPU speculated — code that, by the architecture's rules, never officially ran. It's the attack that made "microarchitectural state is security state" impossible to ignore.

target
cve
CVE-2017-5753 (Spectre variant 1, bounds-check bypass)
mechanism
speculative execution past an unresolved branch
read-out
FLUSH+RELOAD on a cache footprint left by the speculation
impact
read memory across a bounds/sandbox boundary (JIT, eBPF, in-process)

speculation leaves a trace it can't take back#

When the CPU hits a conditional branch it hasn't resolved yet (because the condition's inputs aren't in cache), it predicts the outcome and speculatively executes ahead. If the prediction was wrong, it discards the architectural results — registers, memory writes, all rolled back. But the cache is not rolled back. A line pulled in during speculation stays pulled in. That gap is the whole attack.

the gadget#

The canonical victim is an ordinary, correct-looking bounds check:

the Spectre v1 gadgetc
1
2
3
if (x < array1_size) {                       // branch the CPU will mispredict
    y = array2[ array1[x] * 4096 ];          // speculatively runs with evil x
}

Run it many times with in-bounds x and the branch predictor learns "taken." Now:

one secret bytetxt
1
2
3
4
5
6
7
8
9
1. TRAIN  call with valid x (0..size-1) repeatedly  -> predictor: "branch taken"
2. FLUSH  evict array1_size (so the check resolves slowly) and all of array2
3. ATTACK call with x = (secret_addr - array1)        -> x is OUT of bounds
          CPU speculates the branch taken:
            reads array1[x]  = *secret_addr  = a secret byte `s`
            reads array2[s * 4096]           -> caches THAT line
          bounds check resolves false -> architectural rollback
            ...but array2[s*4096] is still cached
4. RELOAD time array2[k*4096] for k in 0..255; the fast k IS the secret byte s

The * 4096 spreads each of the 256 possible byte values onto its own page, so the hardware prefetcher can't blur which line the speculation touched — FLUSH+RELOAD then reads it cleanly:

recover the byte from array2's footprintpython
times = [reload_time(array2 + k*4096) for k in range(256)]
secret = min(range(256), key=lambda k: times[k])     # the cached (fast) index

Walk secret_addr one byte at a time and you read memory the bounds check was supposed to protect — out of a JS JIT sandbox, across an eBPF bounds check, or anywhere a trusted boundary is enforced by a branch.

reading past the check, one byte at a time
txt
1
2
3
addr 0x... : 0x73 's'   addr+1 : 0x65 'e'   addr+2 : 0x63 'c' ...
-> "secret_in_another_security_domain"
segfault{the_cpu_already_read_it_before_it_said_no}

stopping it (and the cost)#

  • Serialize the check. An lfence between the bounds compare and the array access stops speculation from running ahead — the targeted, low-overhead fix compilers insert at sensitive branches.
  • Mask the index. Clamp x to the array bounds with an arithmetic mask (x &= (size-1)) so even speculatively it can't go out of range.
  • Starve the read-out. Reduced timer resolution and removing precise timers (browsers did this to performance.now/SharedArrayBuffer) raise the noise on the FLUSH+RELOAD half.

Spectre v1's descendants — v2's branch-target injection (answered by retpolines), Meltdown's fault-suppressed loads — all share this spine: a transient, "rolled-back" execution that nonetheless stamps the cache, read back by the oracles from earlier in this series. The uncomfortable lesson is architectural: correctness guarantees cover architectural state, and security had been quietly assuming that was all the state there is.