5.3.15 · D3Advanced Microarchitecture

Worked examples — Spectre - Meltdown speculative side channels

3,693 words17 min readBack to topic

This page is the "make it concrete" companion to the parent topic. We already know the idea: the CPU guesses, runs ahead, and even when it throws the guess away it leaves crumbs in the cache. Here we grind through every kind of scenario that idea can produce — every sign of timing, every degenerate input, every quadrant of the attack space — until nothing can surprise you.

Before we start, three plain-word reminders (no symbol used before it is earned):

Figure — Spectre - Meltdown speculative side channels

Now the matrix.

The scenario matrix

Every worked example below hits one labelled cell. Together they cover the whole space: both timing signs, degenerate/zero cases, the boundary case, a real word problem, and an exam twist.

Cell Scenario class What makes it "this cell"
A Positive result — a byte was leaked One read is fast (); recover the value
B Zero / null result — nothing leaked All 256 reads are slow; secret unrecovered this round
C Degenerate secret = 0 The secret byte is literally ; does the attack still work?
D Boundary timing (right on ) A read lands near the threshold — hit or miss?
E Noise / false positive Two reads look fast; which is real? Statistics over rounds
F Meltdown arithmetic Compute leak rate; is a full page dumpable in reasonable time?
G Spectre v1 window sizing How wide is the speculation window; does the leak fit?
H Spectre v2 BTB aliasing Do attacker and victim branch addresses collide?
I Real-world / exam twist KPTI overhead trade-off with real numbers

Prerequisites you may want open: Cache Memory Hierarchy, Out-of-Order Execution, Branch Prediction, Privilege Levels and Protection Rings, Virtual Memory and Paging, TLB (Translation Lookaside Buffer), Hyperthreading and SMT.

We fix these numbers for the whole page so answers are checkable:


Cell A — A byte was leaked (positive result)

Forecast: Guess before reading on — one mailbox is fast, so the secret is that mailbox's index. Which one, and what is 65 in hex?

  1. Compare each read to the threshold. Mailbox 65: , so it is a hit (cached). Every other mailbox: , a miss. Why this step? The threshold is the whole point — it converts a continuous time into a yes/no "was this cached?" decision. Without it we'd just have 256 numbers.
  2. The hit index is the secret. During speculation the victim did probe[secret * 4096] = ..., so the only warmed mailbox is number secret. Here that is . Why this step? Recall the geometry in the opening figure: one secret → one warm mailbox. Reading the index reads the secret.
  3. Convert to hex (bytes are conventionally written in hex): . Why this step? is ASCII 'A', so a leaked text buffer would read out as letters — this is why demos print recognizable strings.

Verify: Reverse it — . Only one read is below ns (the ns one), so exactly one byte is claimed. ✓


Cell B — Nothing leaked (zero result)

Forecast: No read beats the threshold — so what byte did we recover? (Trick: maybe none.)

  1. Count reads below threshold. Number of with ns is zero. Why this step? Our recovery rule only fires on a hit. No hit ⇒ no claim.
  2. Interpret the zero. The speculative load may not have happened this round: the branch predictor guessed correctly (didn't enter the gadget), or the speculation was too short, or the probe line got evicted by other activity before we timed it. Why this step? A null round is a normal, expected outcome — the attack is probabilistic. You retry, you don't panic.
  3. Action: re-run the round. Real attacks average tens to thousands of rounds per byte.

Verify: With zero reads under threshold, the recovered-byte set is empty; sanity: claimed bytes, no contradiction. This is the "denominator" case that Cell E's statistics build on. ✓


Cell C — Degenerate secret = 0x00

Forecast: , so mailbox 0 gets warmed. Sounds fine — but is there a catch with mailbox 0 specifically?

  1. Compute the touched mailbox. offset , so mailbox 0 is warmed. Why this step? Confirms the multiply-by-4096 mapping is well-defined even at the zero end.
  2. The catch: mailbox 0 sits at the start of probe_array, which is often prefetched or touched by unrelated code (array base, loop setup). It can appear "hot" even when the secret is NOT zero — a false positive on byte 0. Why this step? Covers the degenerate corner the multiply hides: the value 0 is special not mathematically but microarchitecturally, because address 0-offset is high-traffic.
  3. The fix real exploits use — a fixed offset . Here (Greek letter "delta") is just a constant shift the attacker picks once, say : instead of touching mailbox , the victim gadget touches mailbox (the "" wraps back to 0 after 255, so we never run off the end of the 256 mailboxes). The reader subtracts back after recovery. Choosing moves the whole scheme up by one mailbox, so the real value 0 now lands on mailbox 1 — away from the noisy mailbox 0. Why this step? Full-coverage means the zero input must not silently corrupt results — you handle it explicitly by relabelling the mailboxes so the high-traffic slot 0 no longer corresponds to any real byte value.

Verify: With : secret maps to mailbox ; secret maps to . Every one of the 256 values still lands on a distinct mailbox (the shift is a bijection), so no information is lost — we just re-read mailbox 0 as "value 255." ✓ Also, offsets for are all distinct (0 up to ), confirming the base mapping is injective before any shift. ✓


Cell D — Boundary timing (right on the threshold)

Forecast: 98 is below 100... but only by a hair. Do we trust it?

  1. Apply the rule literally. ⇒ classified as a hit. Why this step? The rule is a strict inequality ; 98 satisfies it, so mechanically it's a hit.
  2. The danger. A value this close to the line is where misclassification lives. Cache-hit times cluster near ns and misses near ns; a ns reading is neither — likely a partially-cached line (L2/L3 hit, not L1) or a slow-but-uncached fluke. Why this step? Covers the limiting case where the two timing populations nearly touch — the "grey zone."
  3. What about exactly 100 ns? With strict "", is a miss (not below the line). This is a definition boundary — you must state whether your threshold is or ; flip it and the answer flips. Why this step? Boundary cases expose hidden assumptions. State the inequality direction explicitly.
  4. Robust practice: don't use a single hard line — repeat the read many times and take the minimum time (min filters out interrupts/contention). Min of a true hit stays near 4 ns; min of a miss stays near 200 ns; the 98 ns grey case vanishes.

Verify: Under strict : ⇒ hit; is false ⇒ miss. Two different classifications from a 2 ns difference confirms this is a genuine boundary. ✓


Cell E — Noise and false positives (statistics over rounds)

Before this example we need one new symbol, since a page must never use notation it has not earned.

Forecast: 65 wins by a mile — but 200's 6 hits aren't zero. How do we justify picking 65?

  1. Build a histogram of hit-counts. Mailbox 65 → 83; mailbox 200 → 6; noise floor . Why this step? One round is noisy (Cell B showed rounds can be blank). Aggregating turns a coin-flip into a landslide.
  2. Pick the argmax. The secret is the byte whose mailbox is fast most often: — that is, the index 65, not the number 83. Why this step? Random cache activity (prefetch, neighbours) sprinkles a few false hits everywhere; the true signal towers above them. We want the winning label, which is exactly what returns.
  3. Confidence check. Signal-to-noise: true byte 83 hits vs. next-highest 6 hits. Ratio . A ratio well above ~3–5 is a confident recovery. Why this step? Quantifies "by a mile" so an exam answer isn't hand-wavy.

Verify: ; ratio . Confident. ✓


Cell F — Meltdown leak-rate arithmetic

Forecast: Rough it in your head first — thousands of bytes, tens of rounds each, tens of µs per round... seconds? Let's see.

  1. Rounds for the whole page. rounds. Why this step? Total work = bytes × rounds-per-byte. Linear, because each byte is independent.
  2. Total time. seconds. Why this step? Convert µs to seconds () to get a human-scale number.
  3. Rate. KB/s (using B). Why this step? Rate = data / time; it's the number papers quote (original Meltdown reported hundreds of KB/s with optimizations — ours is deliberately pessimistic).

Verify: ; B/s; KB/s. Feasible — a page in ~2 s is very practical. ✓


Cell G — Spectre v1 speculation-window sizing

Forecast: Window is 200 ns; we need 4 ns + 20 ns of useful work before the branch resolves. Comfortable or tight?

  1. Window length = time until branch resolves. The compare resolves when array1_size arrives from memory: ns after the branch is fetched. Why this step? Speculation lasts exactly until the CPU knows the true branch direction — that's when the flushed operand lands. This is the Branch Prediction mispredict-penalty gap, and it is the entire budget we get to work in.
  2. Justify the 20 ns issue latency. After t = array1[x] produces its value, the CPU cannot fire probe[t*4096] instantly: it must (a) compute the address t*4096 (a shift/multiply, ~1 cycle), (b) find a free load port and allocate a load-buffer entry, and (c) translate the address through the TLB (Translation Lookaside Buffer). On a 3 GHz CPU (1 cycle ns) these ~60 cycles of address-gen + port-allocation + TLB lookup come to ns. Why this step? The window budget is meaningless unless we know the cost of the work we're trying to fit — the 20 ns is derived from real pipeline stages, not asserted.
  3. Add the work that must fit. Load array1[x] (4 ns, cached) → then issue the probe load (20 ns): needed ns. We only need to start the probe access; its cache footprint persists even after the rollback (that is the whole trick). Why this step? Encoding the secret only requires the probe load to touch its cache line; completion isn't needed, so 24 ns is the true requirement.
  4. Compare and conclude. , with slack ns. The leak fits comfortably. And this is why attackers flush array1_size: without the flush the compare is itself a cached ~4 ns operation, giving a window of only ns — smaller than the ns of work — so the leak would not fit. Flushing widens the window from ~4 ns to 200 ns, turning "impossible" into "easy." From Out-of-Order Execution: a longer window means more speculative instructions can retire their microarchitectural effects.

Verify: ; ⇒ fits; slack ns . Counter-case: without the flush, window ns and ⇒ leak does not fit — confirming the flush is essential. ✓


Cell H — Spectre v2 BTB aliasing

Forecast: Same slot ⇔ same low 12 bits ⇔ same address mod 4096. Look at the last three hex digits of each address...

  1. Compute each BTB index . Since , taking an address mod 4096 keeps exactly the low 3 hex digits.
    • Victim: 0x4FF230 → low 3 digits 0x230 .
    • Attacker: 0x120230 → low 3 digits 0x230 . Why this step? The BTB is a small hardware table; to index it cheaply the CPU uses only the bottom bits of the branch address. This is the same partial-address aliasing idea as cache indexing in Cache Memory Hierarchy.
  2. Compare the two indices. same slot. The high bits (0x4FF vs 0x120) are not stored/checked, so the hardware cannot tell the two branches apart. Why this step? The BTB stores a predicted target, not the full source address — that missing tag is exactly the vulnerability.
  3. State the consequence. The attacker trains their own branch to jump to a chosen "gadget" address; the victim's call, sharing the slot, inherits that target and speculatively executes the gadget in the victim's privilege context. (Retpoline defeats this by routing indirect jumps through the return-stack buffer instead of the BTB.) Why this step? Collision alone is harmless; the payoff is the victim running attacker-chosen code speculatively.

Verify: Is ? Both equal ⇒ collision confirmed. Counter-case: 0x4FF231 gives no poison, showing the test is discriminating. ✓


Cell I — Real-world / exam twist: KPTI overhead

Forecast: 200k syscalls × 150 cycles is 30 million cycles/s out of 3 billion — roughly 1%? Let's confirm and interpret.

  1. Extra cycles per second. cycles/s spent purely on the KPTI TLB flushes. Why this step? Overhead = (event frequency) × (per-event cost) — the same shape as Cell F's leak-rate arithmetic.
  2. As a fraction of the 3 GHz budget. . Why this step? Fraction of time lost = extra cycles ÷ total cycles available per second; the "per second" cancels, leaving a pure ratio.
  3. Interpret against the range. sits below the 5–30% band. That band assumes syscall-heavy workloads (databases, network servers doing millions of syscalls/s); our 200k/s server is comparatively light on syscalls, so it pays less. Why this step? Exam twist: the "5–30%" is workload-dependent, not a fixed constant. The correct answer names the assumption that drives it.

Verify: ; , which is ⇒ below the quoted band, consistent with a light syscall load. ✓


Recall Self-test — cover the answers

What does a probe read faster than the threshold mean? ::: That mailbox's cache line is hot ⇒ its index equals the leaked secret byte. Why space probe mailboxes by 4096 bytes? ::: One page apart ⇒ each byte value maps to a distinct cache line, so no two values alias (avoids false sharing). Why does an attacker flush array1_size in Spectre v1? ::: To make the bounds compare miss cache and stall ~200 ns, widening the speculation window so the leak fits (Cell G). Two indirect branches poison each other in the BTB when…? ::: their addresses are equal modulo (same low index bits), regardless of high bits (Cell H). A single fast read near the threshold — trust it? ::: No; take the minimum over many reads and aggregate over rounds (Cells D, E).