Skip to main content
Be Bitwise

What Spectre changed about how we write code

Spectre exposed a CPU design assumption rather than a single fixable bug. The response is now baked into compilers, kernels, browsers, and language runtimes — eight years of slow change under one name.

#spectre#side-channel#speculative-execution#cpu

Spectre landed in January 2018. Kernel page table isolation rolled out in a hurry, microcode updates appeared, the cloud providers patched their hypervisors, and most developers moved on. The interesting story is what happened in the years after the press cycle ended, because Spectre is the rare class of bug that quietly rewrote the floor of the stack everyone builds on.

The bug, briefly

Modern CPUs don't wait around. When the pipeline hits a branch, it guesses where the branch is going, starts executing the predicted path speculatively, and then either commits or rolls back the work depending on whether the guess was right. From the architectural state (the registers, the visible memory) the rollback is perfect. Speculation that doesn't commit might as well not have happened.

The cache, however, doesn't roll back. If the speculative path read an address into the cache, that line stays warm even after the rollback. An attacker who can choose what gets speculatively executed, and who can measure cache timing, can leak the values that were touched during the discarded path. That's the whole primitive.

Spectre v1 is the bounds check bypass. You write code that looks safe (a length check followed by an array index) and the CPU mispredicts the bounds check, executes the indexed read against an attacker-supplied offset, observes the cache state, and rolls back the architectural side. The data wasn't supposed to be readable. The cache says it was.

Why it isn't fixable in the way bugs normally are

Branch prediction, speculation, and out-of-order execution are not bugs. They are the architectural decisions that have given us roughly twenty years of single-thread performance gains. Removing them gives you a CPU that runs maybe a third as fast on the same silicon. The hardware vendors couldn't simply turn speculation off and ship a patched processor.

Instead, the industry settled on a strategy of cordoning off the dangerous patterns. Make sure the branch predictor cannot be trained across security domains. Add fences that wait for speculation to resolve before reading sensitive data. Make the timer too coarse to detect a single cache miss. None of these are clean fixes. They're a series of paper cuts to the optimiser, distributed across every layer of the stack that touches user data.

What changed in compilers

The most visible compiler change was retpoline. Indirect branches (call rax, jmp through a function pointer) are exactly the kind of branch the predictor handles speculatively, and they're exactly the kind of branch attackers want to mistrain. Retpoline replaces them with a thunk that uses the return predictor instead, which can be flushed cheaply on context switch.

The cost is real. An indirect call goes from a single dependent-on-load instruction to a small dance of pushes, calls, and returns. For most code the overhead is nothing. For code with a hot dispatch loop (interpreters, virtual function calls in object-oriented hot paths) it can be five to ten percent. The Linux kernel turned retpoline on by default. Userland mostly didn't, but the compiler flag is there if you want it: -mretpoline on Clang, -mindirect-branch=thunk on GCC.

A more recent and subtler intervention is __builtin_load_no_speculate and the family of speculation barriers around it. These let you tell the compiler "treat this memory access as architecturally visible only, don't speculate past it." You won't see them in normal code. You will see them in cryptographic libraries that have decided constant-time is no longer enough and they need constant-speculation as well.

What changed in kernels

KPTI (kernel page table isolation) was the immediate response, and it's the one with the visible cost. Pre-Spectre, the kernel was mapped into every userspace process's page table with the supervisor bit set, so that syscalls could transition into the kernel without a TLB flush. KPTI splits this into two page tables and switches between them on entry. Every syscall costs an extra TLB flush. On chips without PCID support, this hurts: workloads with high syscall rates lost ten to twenty percent.

The Linux kernel grew a small ecosystem of inline assembly fences (array_index_nospec, barrier_nospec) that get sprinkled into hot paths where attacker-supplied indices are followed by sensitive reads. These have to be added by hand. Static analysers help find candidate sites, but the false positive rate is high enough that most additions still get reviewed by someone who has built the mental model.

There's also the death of certain optimisations. The kernel used to keep task_struct pointers visible from userspace through /proc/self/maps. It doesn't anymore, because the address itself is sensitive once you can speculatively dereference it. ASLR entropy got a similar review pass. A few syscalls grew capability checks they hadn't needed before.

What changed in browsers

The browser response is where most developers will have noticed something, even if they didn't connect it to Spectre. The first move was to lower the resolution of performance.now() from microseconds to milliseconds, and to add jitter so that timing differences smaller than the jitter window are invisible. SharedArrayBuffer was disabled overnight, because it gave attackers a way to build a high-resolution timer out of a worker thread incrementing a counter.

Eventually SharedArrayBuffer came back, but only behind cross-origin isolation. To use it, your page has to send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which together prevent any other origin from loading your page in a window or embedding your resources without explicit opt-in. COEP/COOP then became the gating mechanism for a whole tier of web platform features (WebAssembly threads, atomic operations, certain WebGPU paths), and a lot of sites that wanted any of those had to do the work to qualify.

V8, SpiderMonkey, and JavaScriptCore all picked up sandboxing strategies inspired by Spectre. Site isolation in Chrome (one process per origin) was already on the roadmap, but Spectre is what got it shipped on schedule. The renderer process model now treats one origin's data as physically not co-resident with another's, on the assumption that a single-process JavaScript sandbox can't be made airtight against speculative attacks.

The optimisations that quietly died

A short list of things that were normal before Spectre and are no longer assumed safe:

  • High-resolution shared timers in any sandboxed environment.
  • Lazy bounds checking on indexed reads in security-sensitive contexts.
  • Storing secrets in the same address space as untrusted code, without any speculation barriers between them.
  • Speculative execution of indirect branches across protection domains, which is the whole reason hardware indirect branch predictors are now flushed at every privilege transition on modern chips.

Newer ISAs have started to bake speculation control into the instruction set itself. Arm's CSDB, SSBB, and friends are explicit speculation barriers. Intel's LFENCE has been redefined to have stronger speculation-blocking semantics than it used to. RISC-V is still arguing about what the right primitives are.

The longer arc

Spectre was the moment the security community noticed that side channels were not a niche cryptographic concern but a general-purpose primitive that could lift data across any privilege boundary the CPU implemented. The follow-on bugs (Meltdown, Foreshadow, MDS, RIDL, ZombieLoad, the LVI family, Downfall) have continued at a steady pace ever since. Each one closes some portion of the attack surface and exposes another. The industry is now somewhat used to this rhythm.

The lasting change is cultural. Cryptography libraries that used to think about constant-time now also think about constant-speculation. Compiler authors think about whether their optimisations preserve security boundaries that did not exist as concepts ten years ago. CPU designers ship microcode updates with security advisories attached, which they did not used to. Kernel developers treat any attacker-supplied index followed by an attacker-observable side effect as a candidate for nospec annotation.

It's the kind of bug that doesn't get fixed. It gets absorbed.

Where this fits in

The mechanics of cache timing, branch prediction, transient execution, and the various ways to turn a microarchitectural side effect into a bit of leaked data are the meat of Module 87 (Side-Channel Attacks). If you've patched Spectre by accepting a kernel update and never thought about it again, that module is where the gap closes.