Spectre: reading memory the CPU never really read
Modern CPUs do not wait. When they hit a conditional branch — an
if — they do not stop to compute the condition: the branch predictor
guesses the outcome and the CPU starts executing the predicted path
speculatively, ahead of time. If it guessed right, it saved time.
If it guessed wrong, it throws the results away and restarts on the correct branch.
Architecturally it is as if nothing happened.
The problem, disclosed in 2018, is that almost. Instructions that were speculated and then discarded leave a side effect the rollback does not clean up: the state of the cache. Spectre reads that state and recovers the data the speculation touched.
The vulnerable gadget
The canonical case, Spectre variant 1 (bounds check bypass):
if (x < array1_size) {
y = array2[array1[x] * 4096];
}
The check x < array1_size looks like it guards the access. But if
array1_size is not in cache, the CPU takes hundreds of cycles to load
it. While waiting, the predictor — trained by us with many valid x —
assumes the branch is taken, and speculates. With a malicious,
out-of-bounds x, during the speculation window the CPU
actually reads array1[x], one byte of arbitrary memory, and uses it as
an index into array2.
The secret enters the cache
The trick is the multiply by 4096: the size of a page. The secret byte
b = array1[x] determines which page of array2
gets pulled into cache by the speculative read. Every possible value of
b (0–255) touches a different, distant page, so no prefetcher muddies
the water.
Then the CPU realises x was out of bounds, rolls everything back,
and y is never architecturally written. But
array2[b*4096] stayed in cache.
Flush + Reload: reading the trace
Before the attack you evict all 256 candidate pages from the cache
(clflush). After the speculation, you time how long it takes to read
the first word of each:
for (i = 0; i < 256; i++) {
addr = &array2[i * 4096];
t0 = rdtscp();
tmp = *addr; // read
dt = rdtscp() - t0; // timing measurement
if (dt < CACHE_HIT_THRESHOLD)
results[i]++; // this page was cached -> b == i
}
The page that reads in ~50 cycles instead of ~300 is the one the speculation
pulled into cache: its index is the secret byte. Repeat the
measurement to beat the noise, advance x by one byte, and read memory
at will — one byte at a time, typically at a few KB/s.
Why it is so serious
Spectre does not exploit a software bug: it exploits the correct behaviour of the microarchitecture. The gadget code is flawless by any static analysis. And crucially it crosses boundaries we assumed were solid:
- Browser sandbox. The original version read the browser
process's memory from JavaScript, training the predictor with JS code. The answer
was to take high-resolution timers away from sites (
performance.now()coarsened,SharedArrayBufferdisabled for years) and to introduce site isolation into separate processes. - Kernel from user space and guest → host in virtualization, when the right gadget exists in privileged code.
The mitigations and their price
There is no single software patch because the root is in the silicon. It is fought on several fronts, each of which costs cycles:
- Speculation barriers. On x86 an
lfenceafter the bounds check stops the CPU speculating past it. The compiler can insert them, but they serialize execution: slow if overused. - Index clamping. Instead of trusting the branch, you mask the
index so it stays in bounds even under speculation
(
array_index_nospecin the Linux kernel). - Retpoline for variant 2 (branch target injection): it replaces indirect calls with a construct that traps speculation in a harmless loop instead of letting it jump to a poisoned target.
The total bill, across Spectre, Meltdown and their descendants (MDS, L1TF, Retbleed), was a measurable performance loss on real workloads — in some cases in the double-digit percentages — paid by every datacenter on the planet. It is the definitive case study that the CPU abstraction is a useful lie: underneath, timing and the cache tell everything.