Skip to content

Session 4: Microarchitecture Security — Cache Side Channel Attacks

What is a cache side channel?

Modern processors keep recently used data in small, fast caches (L1, L2, L3) so they do not have to go all the way out to main memory (DRAM) every time. This speed difference is normally invisible to a program — but if you measure carefully enough, you can tell whether a piece of data was in the cache or not. That observation becomes a side channel: a way to learn information about another program’s behaviour without that program ever telling you anything directly.

In this lab, two things were measured and demonstrated:

  1. The actual timing difference between a cache hit and a DRAM access on a real machine.
  2. A complete cache-based attack — Flush+Reload — that steals a secret value from a running program.

Environment: Ubuntu 24.04 VM (ff-cs-sedat-yalcin, 192.168.80.104), x86-64, accessed over WireGuard VPN via SSH.


Part 1: Measuring Cache vs. DRAM Latency

How the measurement works

Two helper functions were provided in utility.h:

  • measure_one_block_access_time(addr) — reads the data at addr and returns how many CPU cycles the read took. It uses the rdtscp instruction (a hardware cycle counter) with mfence and lfence barriers to prevent the CPU from reordering the measurement around the actual memory access.
  • clflush(addr) — evicts the given address from the cache all the way back to DRAM. After calling this, the next read of that address will be a cache miss and will be slow.

Code added to main.c

The L1 (cache) loop was already provided. The DRAM loop was completed:

for (int i = 0; i < SAMPLES; i++) {
    // Flush the target line out of the cache to DRAM.
    clflush((void *)target_buffer);

    // Measure the read -- must go all the way to DRAM (slow).
    dram_latency[i] = measure_one_block_access_time((uint64_t)target_buffer);
}

The key insight: access once to warm the cache (L1 measurement), then clflush to evict before timing (DRAM measurement). Print nothing inside the loop — printf itself touches the cache and would corrupt the measurements.

Results

             : Cache(L1)  DRAM
Minimum      :        38   233
Bottom decile:        38   233
Median       :        39   235
Top decile   :        40   354
Maximum      :        40   354

Discussion 1-1: How big is the difference?

Cycles
L1 cache (median) 39 cycles
DRAM (median) 235 cycles
Ratio ~6× slower

A cache hit takes about 39 CPU cycles. Going out to DRAM takes about 235 cycles — roughly six times longer. This gap is consistent and clearly visible even in noisy conditions.

Discussion 1-2: Choosing a threshold

A threshold of 150 cycles sits cleanly between the two distributions (39 median vs. 235 median) with plenty of margin on both sides. Any read that completes in under 150 cycles was served from the cache; anything over 150 cycles came from DRAM.


Part 2: Flush+Reload — Stealing a Secret

How the victim works

The victim program picks a secret integer flag in the range [0, 1024) and then loops forever, reading from one specific location in a shared memory buffer:

value = load(buf + flag * 128)

Which location it reads depends entirely on flag. The victim never prints the flag or sends it anywhere — but every iteration, it pulls that one cache line into the cache.

Why the attack is possible

The attacker and victim share the same physical memory via a memory-mapped file (shared_file). Because the memory is physically shared, the cache state is shared too. When the victim loads buf + flag * 128, that cache line becomes hot — and the attacker can detect this.

The Flush+Reload attack

The attacker repeats three steps in a tight loop:

1. Flush — evict every candidate location from the cache:

for (int i = 0; i < SEC_RANGE; i++) {
    clflush((ADDR_PTR)(buf + i * ALIGN));
}

After this step, nothing is cached — all reads will be slow.

2. Wait — spin briefly to give the victim time to do its secret-dependent read:

for (volatile int spin = 0; spin < 300; spin++);

We spin rather than calling sleep() because sleep() makes a system call that disturbs the cache.

3. Reload — read every candidate location in a randomised order and time each one:

// Shuffle the access order to defeat the hardware prefetcher.
for (int i = SEC_RANGE - 1; i > 0; i--) {
    int j = rand() % (i + 1);
    int tmp = order[i]; order[i] = order[j]; order[j] = tmp;
}

for (int i = 0; i < SEC_RANGE; i++) {
    int idx = order[i];
    CYCLES t = measure_one_block_access_time((ADDR_PTR)(buf + idx * ALIGN));
    if (t < THRESHOLD) {
        counts[idx]++;   // this location was in the cache -- victim touched it
    }
}

After thousands of rounds, whichever index accumulated the most cache hits is the secret flag.

The access order is randomised because modern CPUs prefetch memory when they detect a simple sequential pattern. Randomising prevents the prefetcher from loading the “wrong” lines into the cache and producing false hits.

Results

Running the victim in one terminal:

Flag: 576
(victim loops silently)

Running the attacker in a second terminal simultaneously:

Flag: 576

The attacker correctly recovered the secret value 576 without the victim ever communicating it.

Discussion 2-2: How could the victim be fixed?

The leak happens because the victim’s memory access address depends directly on the secret. To fix this, the victim must access memory in a way that does not reveal the secret through the cache:

  • Access the same location every time — read a fixed address regardless of the flag value, doing the secret-dependent computation without touching a secret-dependent address.
  • Oblivious RAM (ORAM) — a technique that accesses memory in a pattern that is statistically independent of the secret, using cryptographic shuffling.
  • Constant-time programming — ensure every possible input follows exactly the same sequence of memory accesses, so no timing or cache pattern leaks information.

The simplest fix: compute the result using arithmetic (e.g. a lookup table accessed at every index, combining results with masks) rather than jumping directly to the secret-indexed address. This is the principle behind constant-time cryptographic implementations.


Key Takeaways

  • The cache and DRAM are not equally fast — a cache hit takes ~39 cycles; a DRAM access takes ~235 cycles on this machine. This is a physical property of the hardware.
  • This timing difference is observable from software. Any program that can measure time can, in principle, distinguish a cache hit from a miss.
  • Shared memory means shared cache state. Two processes sharing a physical page also share the cache lines that back it — neither process has to know about the other.
  • Flush+Reload turns this into an attack: flush everything, wait for the victim to do its secret-dependent read, then check which line came back fast. The fast line reveals the secret.
  • The fix is not to patch the cache — it is to write code that does not allow the secret to control which addresses are accessed.

AI Assistance

I used Claude AI (claude-sonnet-4-6) as a learning assistant throughout this session. I ran all commands myself on my Ubuntu VM (ff-cs-sedat-yalcin, 192.168.80.104) over SSH via WireGuard VPN. Key prompts I wrote: provided the homework instructions and starter code, ran the timing measurements, compiled and executed both the victim and attacker programs, and observed the results.

Claude helped me complete the DRAM timing loop in main.c, write the Flush+Reload attacker in attacker.c, understand why randomising the reload order defeats the hardware prefetcher, and explain the fix for the victim (constant-time memory access). All outputs shown are real results from my VM.