VNX-CRYPTO-008 – Timing Attack via Direct Comparison of Secrets

Overview

This rule detects cases where HMAC digests, hash values, authentication tokens, or signatures are compared using standard equality operators (== in Python, === in JavaScript). Standard string comparison is not constant-time: it returns early as soon as it finds the first mismatching character. This timing difference — nanoseconds per character — is measurable over a network and allows an attacker to mount a timing side-channel attack, gradually discovering the expected value one character at a time. This maps to CWE-208 (Observable Timing Discrepancy).

Severity: Medium | CWE: CWE-208 – Observable Timing Discrepancy

Why This Matters

Timing attacks sound theoretical but are practical against web services. By sending thousands of requests where a token differs in one position and measuring average response times, an attacker can statistically determine whether each character of the submitted value matches the expected value. For webhook signature verification (where HMAC-SHA256 is compared to a submitted header), this could allow an attacker to forge signatures without the secret key.

GitHub, Stripe, Slack, and virtually every other webhook provider explicitly documents “use constant-time comparison” in their security recommendations precisely because this attack works. The attack is easier when the attacker controls part of the input (e.g., submitting a webhook with a known partial signature) and has high-resolution timing access (on-premises or low-latency cloud), but timing information also leaks through TCP retransmissions and other network artefacts at larger scale.

What Gets Flagged

# FLAGGED: direct == comparison of HMAC digest
if hmac_digest == request_signature:
    process_webhook()
# FLAGGED: token comparison with == in a function that checks it
def check_token(token, stored_token):
    if token == stored_token:
        authenticate_user()
// FLAGGED: an HMAC computed and compared with ===
if (crypto.createHmac('sha256', secret).update(body).digest('hex') === req.get('X-Signature')) {
  verifyWebhook();
}
// FLAGGED: Go string equality on a signature
return sig == expected
// FLAGGED: a byte loop that returns at the first difference, in HMAC code
for i := range expected {
	if expected[i] != supplied[i] {
		return false
	}
}
// FLAGGED: PHP CSRF token compared with !==
if ($user_token !== $session_token) { die('CSRF token is incorrect'); }

Only where a supplied value is checked

A timing attack needs one operand the attacker supplies and the other a value the code holds. An equality comparison is therefore reported only when something on or around the line says it verifies a supplied value:

  • one side is computed by an HMAC, hash or signing call (hmac.new, createHmac, hash_hmac, doFinal, mac.Sum, md5(...), hexdigest) on the same line or one of the five lines above;
  • the enclosing function is named for verifying: verify, validate, valid, check, authenticate;
  • an operand is read from a request (req, request, params, headers, cookies, body, $_POST, r.Header) or is named for what was supplied or what it is checked against (provided, supplied, received, given, incoming, submitted, candidate, user_, client_, header, input, got, expected, computed, calculated);
  • an operand was assigned from a request on one of the 30 lines above (tok := r.Header.Get(...), token = request.POST.get(...), $token = $_GET['token']);
  • an operand is a parameter of a request handler (@RequestParam, @RequestHeader, @PostMapping, HttpServletRequest, http.ResponseWriter).

These are not reported, because neither side is supplied by a caller:

// two documents' content digests
if from.Source.Digest == to.Source.Digest { ... }
// a cache dropping its own entry only if it is still the one it issued
if t := cache[key]; t != nil && t.Token == tok { ... }
// change detection on a refreshed credential
if tok.RefreshToken != login.RefreshToken { ... }
// a credential compared with a published constant
return creds.APIKey == CommunityAPIKey

The byte-loop form (expected[i] != supplied[i] with an early return) keeps its own context test: code whose file name, function, class or doc comment names a MAC, signature, digest or secret.

Not flagged: presence checks against an empty string, null, None, nil, a boolean or a number; comparisons with an ALL_CAPS constant or after typeof (lexer token kinds); .length, .type and size comparisons; names that only contain hash (geometryHash, location.hash); lines already using hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal, hash_equals, secure_compare or MessageDigest.isEqual; comments, strings and test files.

Remediation

  1. Python: use hmac.compare_digest() for all secret comparisons:

    # SAFE: constant-time comparison
    import hmac
    
    def verify_webhook(payload: bytes, received_sig: str, secret: str) -> bool:
        expected = hmac.new(secret.encode(), payload, "sha256").hexdigest()
        return hmac.compare_digest(expected, received_sig)
    
  2. Node.js: use crypto.timingSafeEqual() for all secret comparisons:

    // SAFE: constant-time comparison in Node.js
    const crypto = require('crypto');
    
    function verifyWebhook(payload, receivedSig, secret) {
      const expected = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest('hex');
      const expectedBuf = Buffer.from(expected);
      const receivedBuf = Buffer.from(receivedSig);
      if (expectedBuf.length !== receivedBuf.length) return false;
      return crypto.timingSafeEqual(expectedBuf, receivedBuf);
    }
    
  3. Java: use MessageDigest.isEqual() (Java 7+):

    // SAFE: constant-time comparison in Java
    import java.security.MessageDigest;
    boolean verified = MessageDigest.isEqual(expectedBytes, receivedBytes);
    
  4. Apply constant-time comparison to all secret material: webhook signatures, CSRF tokens, API keys, session tokens, and password reset tokens — any value where a timing oracle would reveal useful information.

References