Timing Attacks and the constant-time Fix
· #security #cryptography
Here’s a comparison function nobody thinks twice about:
def check_token(provided: str, expected: str) -> bool:
return provided == expected
That’s correct. It’s also a side channel, if expected is a secret and
an attacker can measure how long the check takes. Python’s == on
strings compares byte by byte and bails out the instant it finds a
mismatch. That means a guess that gets the first byte right takes
measurably longer to reject than a guess that gets the first byte
wrong — not because anyone wrote code to leak that, but because “stop
early when you already know the answer” is the natural, efficient way
to write a comparison.
Why a nanosecond difference is exploitable
One comparison’s timing difference is noise — network jitter alone swamps it. The attack works because you don’t need one measurement, you need thousands, averaged. Send the same guess a few hundred times, take the median or trim the outliers, and the underlying signal survives the noise. This is a real, well-documented class of attack against network services, not just a theoretical worry for people running benchmarks on bare metal.
The attack loop looks roughly like this: try every possible byte in position 0, keep the one that’s slowest on average (because a correct first byte means the comparison proceeds to check byte 1, which takes slightly longer than immediately returning False). Lock that byte in, move to position 1, repeat.
import time
def timed_attempt(guess: str, check) -> float:
start = time.perf_counter()
check(guess)
return time.perf_counter() - start
def crack_byte(prefix: str, alphabet: str, check, samples: int = 200) -> str:
best_char, best_time = None, -1.0
for ch in alphabet:
guess = prefix + ch
total = sum(timed_attempt(guess, check) for _ in range(samples))
avg = total / samples
if avg > best_time:
best_char, best_time = ch, avg
return best_char
That turns an n-character secret with k possible characters per
position from a k^n brute force into roughly k * n timed guesses —
an exponential problem reduced to a linear one, purely because the
comparison function told you, one byte at a time, “you’re getting
warmer.”
What actually needs constant time
Not every comparison. The threat only exists when three things are all true: the value being compared is secret, an attacker controls one side of the comparison, and the attacker can measure timing precisely enough (directly, or statistically over many requests) to extract signal. Comparing two non-secret strings — say, checking if a URL path matches a route — gets nothing from constant time and just wastes cycles.
The cases that matter, concretely:
- API key / auth token validation
- HMAC signature verification (webhook signatures, signed URLs, CSRF tokens)
- Password hash comparison (though you should be comparing hashes, not plaintext — see below)
- Session cookie / API secret comparison of any kind
The fix, and why it’s not “just add a delay”
The instinct to fix this with time.sleep(random()) is understandable
and wrong — it adds noise, not a floor, and enough samples average
noise away just like it averages away network jitter. The actual fix is
a comparison that takes the same number of operations regardless of
where or whether a mismatch occurs.
import hmac
def check_token(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided, expected)
hmac.compare_digest (and equivalents in every serious standard
library — Go’s subtle.ConstantTimeCompare, Node’s
crypto.timingSafeEqual) walks the full length of both inputs no
matter what, XOR-ing and accumulating a difference flag instead of
returning the instant it sees a mismatch. Same instruction count for a
totally wrong guess and a guess that’s off by one byte at the end.
That’s the entire fix — not a smarter cipher, not more entropy, just
refusing to let the shape of the computation depend on secret data.
One thing it does not fix: length. Comparing strings of different lengths still short-circuits in most implementations, which leaks length, not content. That’s usually an acceptable leak (token lengths are rarely secret) but worth knowing rather than assuming away.
What I learned
The bug here was never in the cryptography — HMAC-SHA256 is fine, the
key is fine, the algorithm is fine. The bug is in the four characters
== e. Timing attacks are a good reminder that “secure algorithm,
insecure plumbing” is the default state of most systems: the interesting
crypto gets reviewed, the boring comparison right after it doesn’t. Grep
your codebase for == next to anything called token, secret,
signature, or password_hash, and replace what you find. It’s a
five-minute fix for a bug that otherwise takes months to notice and an
afternoon to exploit.