Skip to content

CWE-385: Covert Timing Channel

Overview

A timing channel leaks information through observable differences in how long an operation takes, rather than through its result. Password comparisons, token validation, database lookups, and cryptographic verification can all leak secrets a byte at a time to an attacker who measures response time across many requests, even when the operation's direct output (success/failure) reveals nothing.

Relationship to Other CWEs

CWE-385 is a Base, ChildOf CWE-514 (Covert Channel), the general case of a program leaking information through an unintended side channel - CWE-385 is specifically the timing variant. MITRE's mapping guidance is Allowed, so a finding can carry this number.

Its relationship to CWE-208 (Observable Timing Discrepancy) is directional rather than an overlap: MITRE records CWE-385 as CanFollow CWE-208. CWE-208 is the discrepancy itself - two code paths that take measurably different times. CWE-385 is what an attacker builds out of it - a channel that carries information across many measurements. In practice a scanner reports the discrepancy, so CWE-208 is the number most findings arrive with, and CWE-385 is the right page when the concern is the channel rather than one comparison: repeated sampling, statistical averaging, and the branches that stay unequal after the comparison has been fixed.

OWASP Classification

A04:2025 - Cryptographic Failures

Risk

Medium-High: Timing channels enable user and token enumeration, private key recovery, cache-timing attacks (Spectre/Meltdown-class), and extraction of secrets a byte at a time through statistical timing analysis. A network path does not rule any of it out, given enough samples to average out jitter.

Remediation Steps

Core Principle: Secret comparisons and secret-dependent code paths must take the same amount of time regardless of the input, so timing cannot reveal anything about the secret.

Trace the Data Path

  • Source: Any comparison or lookup involving a secret - password hashes, tokens, API keys, HMACs, signatures
  • Sink: The response time an attacker can observe (HTTP response latency, RPC round-trip)
  • Missing control: A comparison or code path whose duration depends on how much of the secret the attacker guessed correctly

Use Constant-Time Comparison for Secrets (Primary Defense)

// VULNERABLE - pseudo-code
function check_token(provided, expected):
    if length(provided) != length(expected):
        return false                    // fast rejection - reveals length mismatch
    for i in range(length(provided)):
        if provided[i] != expected[i]:
            return false                 // exits sooner the more characters mismatch first
    return true

// SECURE - pseudo-code
function check_token(provided, expected):
    if length(provided) != length(expected):
        return false                                  // decided here, deliberately
    return constant_time_equals(provided, expected)   // no early exit on content

Most mainstream languages provide a constant-time comparison primitive - use it for any comparison involving a secret, never a plain == or a hand-rolled loop with an early return.

The guarantee these primitives make is narrower than "constant time" suggests, and the length check above is written out rather than left to the library for that reason. What they remove is the dependence on how much of the secret matched, which is the part that leaks the secret a byte at a time. None of them hides a length mismatch: the table below shows six returning a falsy value immediately and one throwing. So decide the length case yourself, where you can see it. Where the secret's length is itself sensitive, compare fixed-size digests of both values rather than the values.

Equalize Timing Across Secret-Dependent Branches

Sometimes the comparison is not where the difference lives. In a login flow, "user doesn't exist" and "user exists, wrong password" take different amounts of work. The usual fix is to run the expensive verification step, normally the password hash, against a dummy value when the fast path would otherwise skip it entirely, so both outcomes take the same time.

Add Random Delay as Defense in Depth

Two different techniques travel under this name and they are not equally good. A random delay added to each response raises the number of samples an attacker needs and does nothing else: random noise averages out, so a patient attacker recovers the underlying difference anyway and the delay has only changed the price. Padding every response to a fixed total duration - record when the handler started, sleep until a constant deadline, then respond - removes the signal instead of burying it, provided the deadline is comfortably longer than the slowest legitimate path and a request that overruns it is failed rather than allowed to run long. Padding costs that latency on every request and turns any overrunning path into a visible outlier, which is why it suits a handful of authentication endpoints rather than an entire API. Neither replaces a constant-time comparison or equalised branches; both are for the residue those leave behind.

Test the Fix

  • Send the same request with correct vs. incorrect secrets many times and compare mean/variance of response time - a real timing leak shows up as a statistically significant difference
  • Check what happens after the comparison as well as during it. A constant-time compare leaks nothing, but writing an audit log entry, incrementing a failure counter, or sending a notification on only one branch reintroduces the difference the compare removed. Both outcomes should perform the same side effects, or defer them until after the response timing is no longer observable
  • Test the specific scenario in the finding: does an existing username respond measurably slower/faster than a non-existent one?
  • Re-scan with the security scanner to confirm the finding is resolved

Common Vulnerable Patterns

// VULNERABLE - pseudo-code
function login(username, password):
    user = db.find_by_username(username)
    if user is null:
        return false                              // fast path: no password check at all
    return verify_password(password, user.hash)   // slow path: full password hash verification

// Attack: measure response time across many usernames
// Result: requests for existing usernames take measurably longer, revealing which
// usernames are valid without any explicit "user not found" message

Why this is vulnerable: the difference is not in the comparison, it is in the control flow. The two branches do genuinely different amounts of work - one returns immediately, the other runs a deliberately slow password hash - so the response time reports which branch was taken, and the branch was chosen by whether the username exists. The endpoint answers a question it was never asked and has no message to suppress.

That is why a constant-time comparison does not help here, and why this is worth separating from the comparison-level case in CWE-208. The comparison in the slow path may already be constant-time and correct; the leak happens before it, in the decision to run it at all. The fix has to equalise the work rather than the comparison: hash the submitted password against a stored dummy hash when no user is found, so both paths pay the same cost, and be aware that the dummy has to use the same algorithm and parameters as the real records or the difference reappears at the next parameter upgrade.

A username list is not the end of the attack, but it changes the economics. Credential stuffing against confirmed accounts is orders of magnitude cheaper than against a guessed list, which is why this endpoint is worth an attacker's time.

Secure Patterns

// SECURE - pseudo-code
DUMMY_HASH = precomputed_hash_of_dummy_value()  // computed once at startup

function login(username, password):
    user = db.find_by_username(username)
    if user is not null:
        return verify_password(password, user.hash)
    else:
        verify_password(password, DUMMY_HASH)   // do the same expensive work either way
        return false

Why this works: Both the "user exists" and "user doesn't exist" paths now run the same password hash verification before returning, so an attacker measuring response time sees no statistically meaningful difference between a valid and an invalid username and the enumeration channel is closed. The same principle - make every branch that touches a secret do equivalent work - applies beyond login, to API key checks, token validation, and signature verification.

Language-Specific Constant-Time Comparison Functions

Language Function On a length mismatch
Python hmac.compare_digest(a, b) returns False
Java MessageDigest.isEqual(a, b) returns false
C# / .NET CryptographicOperations.FixedTimeEquals(a, b) returns false
PHP hash_equals($known, $user) returns false
Go crypto/subtle.ConstantTimeCompare(a, b) returns 0
Node.js crypto.timingSafeEqual(a, b) throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
Ruby Rack::Utils.secure_compare(a, b) (or OpenSSL.secure_compare) returns false
C No portable standard function - implement with an accumulated XOR over the full length, checked once at the end, never with an early-exit loop -

The third column is the part that causes outages and bypasses in roughly equal measure. Node's crypto.timingSafeEqual raises where every other language returns false, measured on Node 24 - so a handler written as if (timingSafeEqual(sig, expected)) throws on any attacker-supplied signature of the wrong length. Whatever catches that exception decides what happens next: a 500 per request, or an accepted request if the catch was written to be forgiving. This is why the secure pattern above compares lengths itself before calling the primitive: written that way the handler behaves the same in all seven languages, and the one that throws never sees an input that would make it.

Additional Resources