DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Non-Constant-Time Comparison Turns Every API Token into a Character-by-Character Oracle

A 32-byte hex token has 256^32 possible values. With a comparison function that exits at the first mismatched byte, an attacker needs at most 256×32 guesses. The endpoint hands them a timing signal for every correct prefix character.

Non-constant-time comparison turns authentication into 32 independent single-byte guessing problems. The effective keyspace collapses from 1.16×10^77 to 8,192 candidates. The standard defense that network jitter makes remote exploitation impractical was invalidated in 2020. The fix is a single function call that ships in every runtime. The same pattern continued appearing in CVEs through 2025 because it is invisible to static analyzers and produces no test failures.

The Comparison That Leaks One Byte at a Time

Python's == operator on str and bytes exits at the first mismatch, returning in approximately 8 nanoseconds per additional matching byte. This behavior is documented and intentional: short-circuit evaluation is a performance optimization Python never promised to remove.

An attacker sends 256 tokens differing only in byte 0. They measure the mean response time for each candidate. The candidate with the highest mean is the correct byte at position 0. With that value confirmed, the attacker advances to byte 1 and repeats.

That is the oracle loop: 256 measurements per position, 32 positions, each round confirming one byte. The keyspace drops from 1.16×10^77 to 8,192 candidates. The httpsig-rs library showed the pattern appears in Rust too: developers called Hmac::finalize().into_bytes(), discarding the constant-time CtOutput wrapper, then compared the resulting Vec<u8> with ==. Advisory GHSA-q7pg-9pr4-mrp2 assigned CVSS 5.9 to that two-line pattern, rated AV:N/AC:H: exploitable remotely with high attack complexity.

Three Production Patterns That Ship This Vulnerability

Webhook signature verification, API key lookup middleware, and one-time token verification each independently reproduce the non-constant-time comparison pattern. Four distinct CVEs from 2024 to 2025 confirm each variant.

CVE-2024-52307 affected authentik: the /-/metrics/ route compared SECRET_KEY using Python == in HTTP Basic Auth. Sufficient timing samples allow brute-forcing the key. With the key recovered, an attacker can sign arbitrary session cookies. The fix shipped in versions 2024.8.5 and 2024.10.3. The attack vector is the network accessibility of the metrics endpoint, frequently exposed on internal networks without mTLS authentication.

CVE-2024-41828 hit JetBrains TeamCity before version 2024.07. Authorization token comparison in CI/CD pipelines ran in non-constant time. Pipeline tokens with write access to repositories are high-value targets: compromising a TeamCity token generally means arbitrary code execution on build agents.

GHSA-mjgf-xj26-9qf9 affected pay-rails/pay through version 11.6.1. The Paddle Billing webhook verifier used Ruby String#== instead of ActiveSupport::SecurityUtils.secure_compare. CVSS 7.4 High. The timing signal leaks on the HMAC output, not on business data, but the effect is identical: a byte-by-byte oracle against the shared webhook secret.

Why "Network Jitter Makes This Impractical" Stopped Being True in 2020

The standard argument was that nanosecond differences drown in milliseconds of network jitter. Crosby and Wallach (ACM CCS 2009) established that absolute-timing attacks over the internet require 10,000 or more samples to detect nanosecond differences. That made remote exploitation marginally viable at best.

Van Goethem et al. published "Timeless Timing Attacks: Exploiting Concurrency to Leak Secrets over Remote Connections" at USENIX Security 2020 and dismantled that premise. The technique sends 2 requests in a single HTTP/2 packet. Both requests arrive at the server simultaneously and are processed concurrently. Response ordering reveals which request took longer, without any absolute timing measurement.

Network jitter cancels because both requests travel the same path in the same packet; upstream and downstream variations affect both equally. PortSwigger's Turbo Intruder runs this attack via HTTP/2 as a Burp Suite extension, available to any pentester. Endpoints served over HTTP/2, the current default in nginx and Caddy, are measurably vulnerable with approximately 1,000 request pairs.

Detecting Vulnerable Endpoints Before Exploitation

An endpoint with non-constant-time comparison is detectable via automated timing probes that measure response time distributions across token prefixes. No authentication is required and the probe touches no business logic.

Methodology: send N pairs of requests with tokens differing only in byte 0. Measure the mean and variance of response time per candidate. Welch's t-test compares two distributions; p < 0.05 with 500 samples per candidate is achievable when the underlying difference exceeds 200 nanoseconds.

For a 32-byte hex token, full oracle enumeration via timeless timing over HTTP/2 requires 256×32×500 = approximately 4 million requests. That volume is feasible in an automated scan running for hours. Webhook validation endpoints, API key checkers, and magic-link validators are priority targets because each one compares user-supplied input against a stored secret.

The Fix Is One Function — The Pattern Failure Is Invisible

Every major runtime ships a constant-time comparison function. Python: hmac.compare_digest(a, b), not a == b. Node.js: crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)), requiring both buffers to be the same length or the function throws. Go: subtle.ConstantTimeCompare(a, b) from crypto/subtle. PHP: hash_equals($known, $user_input) since version 5.6. Ruby: ActiveSupport::SecurityUtils.secure_compare, what pay-rails should have used.

Django ticket #14445, filed in October 2010, was the first systematic audit of constant-time comparison in a major framework. Developers noted "no known practical attacks at the time," written before HTTP/2 and timeless timing research. The same pattern appeared in httpsig-rs in 2025, 15 years later.

Static analyzers including Bandit for Python and semgrep rules exist for this pattern, but they are not enabled by default in most CI pipelines. The vulnerable code and the correct code look identical in intent: both check whether two values match. That invisibility is why the pattern persists; it compiles, passes tests, and never emits a warning.

What MAGO Intel Detects in Automated Recon

The MAGO Intel tool (intel.mago.team) identifies endpoints that accept user-supplied tokens and show statistically significant timing variance across first-byte candidates. Detection happens in the reconnaissance phase, before any authentication bypass attempt.

The process:

  1. Identify endpoints that compare user-supplied tokens against stored values.
  2. Send 256 token variants via HTTP/2 single-packet attack.
  3. Rank response times.
  4. Apply z-score outlier detection to identify statistically longer responses.
  5. Confirm with Welch's t-test between the top candidate and a random candidate.

Endpoints with p < 0.05 receive a non-constant-time comparison flag.

The probe is non-destructive and has no side effects. The primary output is identifying which endpoints warrant deeper token analysis, not exploiting the secret directly.


Constant-time comparison is not a performance choice. It is a contract: this function reveals nothing about how close your input was to the correct value. Every codebase that validates a token with == breaks that contract silently. The fix is a function rename. The audit is a grep. The gap between those two facts has stayed open since 2010 because the vulnerable code compiles, passes tests, and never emits a warning.

Top comments (0)