Timing side channels explained in fifty lines
By Elias Lankinen13 min read
The sixteen cycles that broke TLS
In early 2013, Adam Langley measured how long OpenSSL took to reject a corrupted TLS record. When the record claimed a large amount of padding, the server took a median of 18,020 CPU cycles to say no. When it claimed small padding, it took 18,004. The difference was sixteen cycles — about five billionths of a second on a 3 GHz processor, less than the time light takes to cross a desk. That gap was a decryption oracle. Nadhem AlFardan and Kenny Paterson turned it into the Lucky Thirteen attack, recovering plaintext from TLS and DTLS connections. Their basic variant needed roughly 2²³ — about eight million — sessions to pull out a full block of plaintext, which sounds absurd until you remember that a malicious ad running JavaScript in a victim's browser can open connections all day, and that the secret you want (a session cookie) sits in the same place in every one of them.
A timing side channel is what you get when a program's runtime depends on a secret it is supposed to be protecting. The program never prints the secret. It never leaks it into a log or a response body. It just finishes slightly sooner, or slightly later, and an attacker with a stopwatch reads the difference. MITRE catalogues this as CWE-208, "Observable Timing Discrepancy", which sits under the broader family of observable discrepancies — the same logic applies to error messages, power draw, and electromagnetic emissions. The cleanest way to understand it is to build one. It fits in about fifty lines.
Fifty lines: the leak and the exploit
Here is a server that verifies an HMAC — a keyed message authentication tag, the thing that proves a request came from someone holding a shared secret — using ordinary byte-by-byte comparison.
import hmac, hashlib, time
SECRET = b"server-side-hmac-key"
def bad_verify(msg: bytes, tag: bytes) -> bool:
expected = hmac.new(SECRET, msg, hashlib.sha256).digest()
if len(tag) != len(expected):
return False
for a, b in zip(expected, tag):
if a != b:
return False # returns early: leaks how far we got
time.sleep(0.00002) # stand-in for real per-byte work
return True
The bug is the return False. The loop stops at the first mismatched byte, so a tag whose first byte is right takes measurably longer to reject than one whose first byte is wrong. The verification is correct, constant in memory, and completely broken.
Now the attacker, who does not know SECRET and cannot compute a valid tag:
def time_guess(msg: bytes, tag: bytes, trials: int = 40) -> int:
samples = []
for _ in range(trials):
t0 = time.perf_counter_ns()
bad_verify(msg, tag)
samples.append(time.perf_counter_ns() - t0)
return min(samples) # the minimum filters noise, not signal
def recover_tag(msg: bytes, length: int = 32) -> bytes:
known = b""
while len(known) < length:
timings = {}
for guess in range(256):
candidate = known + bytes([guess]) + b"\x00" * (length - len(known) - 1)
timings[guess] = time_guess(msg, candidate)
best = max(timings, key=timings.get) # slowest guess matched one more byte
known += bytes([best])
return known
msg = b"transfer 1000 to attacker"
forged = recover_tag(msg)
assert forged == hmac.new(SECRET, msg, hashlib.sha256).digest()
The structure is a ladder. Guess all 256 values for byte zero; the one that takes longest is correct, because it is the only one that lets the loop reach byte one. Fix it, climb to the next rung. A 32-byte tag falls in 32 × 256 = 8,192 guesses — and with 40 timing trials each, about 330,000 requests. That converts a search of 2²⁵⁶ into a search of a few thousand. Brute force is exponential in the tag length; the timing attack is linear in it. The fix is three lines:
def good_verify(msg: bytes, tag: bytes) -> bool:
expected = hmac.new(SECRET, msg, hashlib.sha256).digest()
return hmac.compare_digest(expected, tag)
compare_digest was added to Python in version 3.3 and folds every byte into an accumulator before checking it once, so the runtime never depends on where a mismatch occurs. Written out by hand it is the pattern Nate Lawson proposed in 2009, when he found exactly this bug in Google's Keyczar library, whose Python verifier was a plain self.Sign(msg) == sig_bytes:
result = 0
for a, b in zip(expected, tag):
result |= a ^ b
return result == 0
Note the honest caveat in my toy: that time.sleep(0.00002) is doing a lot of work. A real 32-byte memcmp differs by nanoseconds per byte, not twenty microseconds, and Python's interpreter overhead would bury it. The amplification is fake. But the need for amplification is exactly what the rest of this story is about, because attackers have found several ways to get it for free.
The misconception: "network noise makes this theoretical"
The standard dismissal is that jitter swamps the signal. Scheduler preemption, interrupts, queueing at three switches and a load balancer — all of it adds milliseconds of variance to a nanosecond-scale difference. Surely that is the end of it. It is not, for two reasons. The first is that jitter is almost entirely additive. Delays make a request slower, essentially never faster. So the distribution of measured times has a hard floor at the true execution time and a long right tail of noise. If you take the minimum, or a low percentile, across many samples, you are reading the floor, not the average. Scott Crosby, Dan Wallach and Rudolf Riedi built filters on this principle and showed in 2009 that an attacker could resolve events to within 15–100 microseconds across the internet, and around 100 nanoseconds over a local network. The second is that noise that is random averages out. Standard error shrinks with the square root of the sample count, so a difference a hundred times smaller than the noise needs roughly ten thousand times more samples — expensive, not impossible. This is also why the instinctive defence of adding a random delay before responding fails: you have added noise, and the attacker removes noise by collecting more data. A random delay buys a constant factor in sample count and costs you latency forever. The frontier keeps moving in the attacker's favour. At NDSS 2026, Vik Vanderlinden, Tom Van Goethem and Mathy Vanhoef showed that you can sidestep network jitter almost entirely by reading TCP timestamps generated on the server itself — one when it acknowledges your request, another when it sends the response. The server times its own work for you. The authors report their methods are several times more efficient than round-trip-time measurement and can detect smaller differences.
Where the leak hides in real cryptography
Byte comparison is the beginner's version. The deep version is that the mathematics itself runs at data-dependent speed.
Paul Kocher opened the field at CRYPTO '96 with Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems. Textbook modular exponentiation walks the exponent bit by bit, squaring every time and multiplying only when the bit is 1. The multiply costs time. Measure enough operations and the exponent — the private key — falls out one bit at a time. Kocher also proposed the standard countermeasure: blinding, where you multiply the input by a random factor before the operation and divide it out afterwards, so the attacker never controls or knows the value being processed. For seven years this was widely treated as a smartcard problem. Then in 2003, David Brumley and Dan Boneh published Remote Timing Attacks are Practical, extracting a 1024-bit RSA private key from an OpenSSL web server over a local network in about two hours. Their oracle was subtler than Kocher's: OpenSSL's Montgomery reduction performs an "extra reduction" step only sometimes, and the library switches between Karatsuba and schoolbook multiplication depending on operand size. Both behaviours depend on how the guessed factor compares to the real one. Reported query counts run from a few hundred thousand to around 1.4 million depending on configuration. The direct consequence was that RSA blinding became the default in OpenSSL and elsewhere. The pattern recurs wherever a secret influences a loop bound. In the Minerva attacks, disclosed in October 2019, implementations leaked the bit length of the random nonce used in each ECDSA signature — a few bits of information per signature, useless alone, but enough to feed a lattice attack that recovers the full private key. The researchers needed roughly 1,200 signatures against a vulnerable crypto library and 2,100 against a FIPS-certified Athena IDProtect smartcard. Affected code included libgcrypt up to 1.8.4, wolfSSL up to 4.0.0, MatrixSSL, Crypto++ up to 8.2.0, and SunEC in OpenJDK through JDK 12. And the classics refuse to die. In September 2023, Red Hat's Hubert Kario published the Marvin attack, showing that Bleichenbacher's 1998 timing oracle against RSA PKCS#1 v1.5 padding still works against a long list of current implementations: OpenSSL (CVE-2022-4304), GnuTLS, Node.js, Java, BouncyCastle, libgcrypt, Mbed TLS. Against a vulnerable library API, hours of work on mid-range hardware. His recommendation is not to write better depadding code. It is to stop using PKCS#1 v1.5 encryption.
When the leak isn't in your code at all
You can write perfect branch-free code and still leak, because the machine underneath you is optimising on your behalf. Memory is the oldest case. In 2005, Daniel J. Bernstein demonstrated cache-timing attacks on AES, recovering a full key from an OpenSSL implementation on a Pentium III; Dag Arne Osvik, Adi Shamir and Eran Tromer published independent cache attacks the same year. The code had no secret-dependent branches. It had secret-dependent table lookups, and whether a lookup hits cache or misses is visible in time — sometimes measured by a separate process that never touches the AES computation at all. Then it got stranger.
Hertzbleed, disclosed on 14 June 2022, exploits dynamic voltage and frequency scaling. Modern CPUs adjust clock speed to stay inside a power budget, and power draw depends on the data being processed — broadly, on the number of 1 bits in a value and the number of bits that flip between successive values. So a workload processing "heavy" data runs at a slightly lower frequency than one processing "light" data, and frequency differences are wall-clock differences. A power side channel became a remote timing channel with no power meter required. The team demonstrated full key extraction against constant-time implementations of SIKE, a post-quantum candidate, in 36 hours against Cloudflare's CIRCL and 89 hours against Microsoft's PQCrypto-SIDH. Neither Intel nor AMD shipped microcode fixes (CVE-2022-24436 and CVE-2022-23823); the mitigation was library-side ciphertext validation, at roughly 5–11% overhead. In a footnote that says something about this whole field, SIKE itself was broken outright by Castryck and Decru less than two months later, by pure mathematics, in about an hour on one core.
GoFetch, presented at USENIX Security 2024, went after the constant-time doctrine itself. Apple M-series chips include a data memory-dependent prefetcher, which inspects values already in memory, guesses which ones look like pointers, and prefetches them. The researchers' phrasing is the important part: even if a victim correctly separates data from addresses by following the constant-time paradigm, the prefetcher will generate secret-dependent memory accesses on the victim's behalf. They extracted keys from OpenSSL Diffie-Hellman, Go's RSA, and the post-quantum schemes Kyber and Dilithium on M1 and M2. Apple was notified in December 2023. On M3 the data-independent timing bit disables the prefetcher; on M1 and M2 there is no clean user-space switch.
What "constant time" actually means
The name misleads almost everyone on first contact. Constant-time code does not take the same amount of time on every input. It takes an amount of time that is independent of secret data. Verifying a 1 MB message takes longer than a 1 KB one; that is fine, because message length is not the secret.
Operationally, the discipline is narrow. Thomas Pornin's BearSSL guidance is the standard short list: no conditional branches on secrets, no memory addresses derived from secrets, no integer division, no variable-count shifts on machines without a barrel shifter. Bitwise operations, addition, and fixed rotations are safe. Everything conditional becomes arithmetic — you compute both branches and select with a mask.
Two problems sit on top of that.
The compiler is not on your side. C is defined by an abstract machine under the "as-if" rule, and an optimiser that recognises your mask-select as a disguised conditional is free to emit a branch. Research groups have responded by moving verification below the source level: ct-verif checks constant-timeness after LLVM optimisation passes, a modified CompCert has been formally proven to preserve constant-time through compilation, and languages like Jasmin and F\/HACL\ let you prove the property and get C or assembly out the other end. HACL\* code now ships inside Firefox, Linux, and Python.
The hardware was never contractually on your side either. Until recently, the assumption that an add or an xor takes fixed time regardless of operands was folklore, not specification. Intel and Arm have since made it explicit and optional: Arm's Data Independent Timing (DIT) bit and Intel's Data Operand Independent Timing mode, the latter applying to Ice Lake and later cores and Gracemont and later Atoms. Intel explicitly does not recommend enabling DOIT globally, because the guarantee is bought by switching off optimisations like data-dependent prefetching. Which is the whole tension in one sentence: secret-independence and speculative performance are the same resource, spent differently.
The fix that isn't code
Lucky Thirteen's real lesson was not "write a constant-time MAC check." Langley's own account calls a backwards-compatible fix "really rather complex" — you must process a variable number of hash compression blocks in fixed time, rotate the MAC into place without branching, and resist every temptation to shortcut. People got that wrong repeatedly for years afterwards. The lesson was that TLS's MAC-then-encrypt construction created a place where padding validity had to influence control flow. TLS 1.3 removed CBC ciphersuites and RSA key transport entirely and mandated authenticated encryption, deleting the oracle rather than making it quiet. Kario's advice on Marvin is the same shape: don't perfect your PKCS#1 v1.5 depadding, retire it. Curve25519 was designed so that the natural implementation is already constant-time, which is why Ed25519 held up better than ECDSA against Minerva — it has no secret nonce whose bit length can leak. The through-line, thirty years from Kocher to GoFetch: every layer that promises you an abstraction is also a layer that can time you. Source code hides branches; compilers hide instruction selection; caches hide memory; prefetchers hide other people's memory; frequency governors hide power. Each new performance feature is a candidate side channel, shipped by default, discovered on average some years later. So the thing to watch is not the next named attack. It is whether DOIT and DIT become a real, tested, per-process ISA contract that a compiler can request and a verifier can check — or whether they stay an exotic MSR bit that almost no software sets, on a small number of chips, while every generation of silicon adds one more clever optimisation that quietly reads your secrets and adjusts the clock.
Sources
- ImperialViolet, Lucky Thirteen attack on TLS CBC, 2013
- Nadhem J. AlFardan and Kenneth G. Paterson, Lucky Thirteen: Breaking the TLS and DTLS Record Protocols, IEEE Symposium on Security and Privacy, 2013
- MITRE, CWE-208: Observable Timing Discrepancy, Common Weakness Enumeration
- Python Software Foundation,
hmac— Keyed-Hashing for Message Authentication, Python documentation - Nate Lawson, Timing attack in Google Keyczar library, rdist, 2009
- Scott A. Crosby, Dan S. Wallach and Rudolf H. Riedi, Opportunities and Limits of Remote Timing Attacks, ACM Transactions on Information and System Security, 2009
- Vik Vanderlinden, Tom Van Goethem and Mathy Vanhoef, Time and Time Again: Leveraging TCP Timestamps to Improve Remote Timing Attacks, NDSS Symposium, 2026
- Paul C. Kocher, Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems, CRYPTO '96, 1996
- David Brumley and Dan Boneh, Remote Timing Attacks are Practical, 12th USENIX Security Symposium, 2003
- Jan Jancar, Petr Svenda, Vladimir Sedlacek et al., Minerva: The curse of ECDSA nonces, CRoCS, Masaryk University, 2019
- Hubert Kario, The Marvin Attack, Red Hat, 2023
- Daniel J. Bernstein, Cache-timing attacks on AES, 2005
- Yingchen Wang, Riccardo Paccagnella, Elizabeth Tang He et al., Hertzbleed: Turning Power Side-Channel Attacks Into Remote Timing Attacks on x86, USENIX Security, 2022
- Cloudflare, Hertzbleed explained, 2022
- Wouter Castryck and Thomas Decru, An efficient key recovery attack on SIDH, IACR ePrint, 2022
- Boru Chen, Yingchen Wang, Pradyumna Shome et al., GoFetch: Breaking Constant-Time Cryptographic Implementations Using Data Memory-Dependent Prefetchers, USENIX Security, 2024
- Thomas Pornin, Why Constant-Time Crypto?, BearSSL
- José Bacelar Almeida, Manuel Barbosa, Gilles Barthe et al., Verifying Constant-Time Implementations, USENIX Security, 2016
- Gilles Barthe, Sandrine Blazy, Benjamin Grégoire et al., Formal verification of a constant-time preserving C compiler, POPL, 2020
- José Bacelar Almeida, Manuel Barbosa, Gilles Barthe et al., Jasmin: High-Assurance and High-Speed Cryptography, ACM CCS, 2017
- Intel, Data Operand Independent Timing ISA Guidance, Software Security Guidance
- Daniel J. Bernstein, Why EdDSA held up better than ECDSA against Minerva, 2019