Skip to content

CWE-514: Covert Timing Channel

Overview

Timing channels leak information through observable timing differences in operations (password comparison, hash lookup, database queries), enabling attackers to deduce secrets bit-by-bit through timing analysis, even when direct access is prevented.

Risk

Medium-High: Timing channels enable password/token enumeration, private key recovery, user enumeration, cache-timing attacks (Spectre/Meltdown), and gradual extraction of secrets through statistical timing analysis.

Remediation Steps

Core principle: Mitigate timing channels by reducing observability and using constant-time operations for secrets where feasible.

Locate Timing Channel Vulnerabilities

When reviewing security scan results:

  • Identify sensitive comparisons: Password checks, token validation, API key verification
  • Find database timing leaks: User enumeration queries, permission checks
  • Check cryptographic operations: Signature verification, MAC checking
  • Review authentication logic: Login flows with different code paths
  • Locate cache operations: Cache hits vs misses with observable timing

Vulnerable operations:

  • String comparison with early exit (strcmp, ==)
  • Database queries revealing existence (user lookup)
  • Different error handling for valid vs invalid users
  • Cache lookups with different timing

Use Constant-Time Comparisons (Primary Defense)

# VULNERABLE - early exit reveals information
def check_token_vulnerable(provided, expected):
    if len(provided) != len(expected):
        return False  # Fast rejection

    for i in range(len(provided)):
        if provided[i] != expected[i]:
            return False  # Returns faster if mismatch earlier!
    return True

# Character match analysis:
# "abc" vs "xyz" - fails on first char (fast)
# "abc" vs "abz" - fails on third char (slower)
# Attacker can deduce correct characters by timing!

# SECURE - constant time comparison
import hmac

def check_token_secure(provided, expected):
    # Always takes same time regardless of where differences are
    return hmac.compare_digest(provided, expected)

Why this works: hmac.compare_digest() compares all bytes even after finding a mismatch, ensuring timing doesn't leak information about which characters match. It always takes the same time whether strings differ at the first byte or last byte.

Language-specific constant-time functions:

// Java
import java.security.MessageDigest;
boolean equal = MessageDigest.isEqual(a.getBytes(), b.getBytes());
# Ruby
require 'rack/utils'
Rack::Utils.secure_compare(token1, token2)
// Go
import "crypto/subtle"
if subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 {
    // Equal
}
// C - manual constant-time comparison
int secure_compare(const char *a, const char *b, size_t len) {
    volatile unsigned char result = 0;
    for (size_t i = 0; i < len; i++) {
        result |= a[i] ^ b[i];  // XOR, accumulate differences
    }
    return result == 0;  // Check at end, not during loop
}

Prevent Database Timing Leaks

# VULNERABLE - timing reveals if user exists
def login_vulnerable(username, password):
    user = db.query(User).filter_by(username=username).first()

    if not user:
        return False  # FAST PATH - user doesn't exist

    # Slow password verification
    return verify_password(password, user.password_hash)  # SLOW PATH

# Timing attack:
# Non-existent user: ~1ms (database query only)
# Existing user: ~100ms (query + bcrypt verification)
# Attacker can enumerate valid usernames!

# SECURE - consistent timing
import time

# Pre-compute a dummy hash for non-existent users
DUMMY_HASH = bcrypt.hashpw(b'dummy_password', bcrypt.gensalt())

def login_secure(username, password):
    user = db.query(User).filter_by(username=username).first()

    if user:
        # User exists - verify real password
        valid = verify_password(password, user.password_hash)
    else:
        # User doesn't exist - perform dummy expensive operation
        # Takes same time as real password verification
        verify_password(password, DUMMY_HASH)
        valid = False

    return valid

# Now both paths take ~100ms (query + bcrypt)
# Attacker cannot distinguish valid from invalid usernames

Key principle: Make success and failure paths take the same amount of time by performing equivalent work in both cases.

Add Random Delays for Additional Defense

import random
import time

def check_credentials_with_delay(username, password):
    start = time.time()

    # Perform actual check
    result = perform_authentication(username, password)

    # Calculate elapsed time
    elapsed = time.time() - start

    # Define baseline time (e.g., 500ms)
    baseline = 0.5

    # Add delay to reach baseline + small random jitter
    if elapsed < baseline:
        delay = baseline - elapsed + random.uniform(0, 0.1)
        time.sleep(delay)

    return result

Random delay benefits:

  • Masks any remaining timing differences
  • Makes statistical timing analysis harder
  • Small random jitter prevents precise measurements
  • Should be used as defense-in-depth, not primary defense

Warning: Don't rely solely on random delays - use constant-time operations first!

Eliminate Different Code Paths Based on Secrets

# VULNERABLE - different code paths
def verify_api_key(provided_key):
    valid_key = get_valid_key()

    # Early return creates timing difference
    if len(provided_key) != len(valid_key):
        log_invalid_attempt()  # Extra operation for invalid!
        return False

    return hmac.compare_digest(provided_key, valid_key)

# SECURE - same code path
def verify_api_key_secure(provided_key):
    valid_key = get_valid_key()

    # Pad to consistent length
    if len(provided_key) < len(valid_key):
        provided_key = provided_key.ljust(len(valid_key), '\0')
    elif len(provided_key) > len(valid_key):
        provided_key = provided_key[:len(valid_key)]

    # Always perform comparison
    result = hmac.compare_digest(provided_key, valid_key)

    # Log after comparison (same for both paths)
    if not result:
        log_invalid_attempt()

    return result

Test for Timing Channel Vulnerabilities

Timing attack simulation:

import time
import statistics
import requests

def timing_attack_test(url):
    # Test with invalid username
    invalid_times = []
    for _ in range(100):
        start = time.perf_counter()
        requests.post(url, json={'username': 'invalid', 'password': 'test'})
        invalid_times.append(time.perf_counter() - start)

    # Test with valid username
    valid_times = []
    for _ in range(100):
        start = time.perf_counter()
        requests.post(url, json={'username': 'admin', 'password': 'test'})
        valid_times.append(time.perf_counter() - start)

    # Compare timing distributions
    print(f"Invalid user - Mean: {statistics.mean(invalid_times):.4f}s, "
          f"StdDev: {statistics.stdev(invalid_times):.4f}s")
    print(f"Valid user - Mean: {statistics.mean(valid_times):.4f}s, "
          f"StdDev: {statistics.stdev(valid_times):.4f}s")

    # If means differ significantly, timing leak exists
    diff = abs(statistics.mean(valid_times) - statistics.mean(invalid_times))
    if diff > 0.01:  # 10ms difference
        print(f"WARNING: Timing leak detected! Difference: {diff:.4f}s")
        return False
    else:
        print("OK: No significant timing difference")
        return True

Token guessing attack:

def guess_token_by_timing(url, known_prefix=""):
    """Attempt to guess token character by character using timing"""
    chars = 'abcdefghijklmnopqrstuvwxyz0123456789'

    for c in chars:
        guess = known_prefix + c
        times = []

        for _ in range(50):
            start = time.perf_counter()
            requests.post(url, json={'token': guess})
            times.append(time.perf_counter() - start)

        avg_time = statistics.mean(times)
        print(f"Testing '{guess}': {avg_time:.4f}s")

        # If this character is correct, timing might be slightly longer
        # (vulnerable implementations take longer for more matching chars)

Automated testing:

# Use timing attack tools
python timing_attack.py --url http://localhost:5000/login \
    --param username --values admin,user,invalid

# Statistical analysis
for i in {1..1000}; do
    curl -w "%{time_total}\n" -o /dev/null -s \
        -X POST http://localhost:5000/verify \
        -d '{"token":"test"}'
done | awk '{sum+=$1; sumsq+=$1*$1} END {print "Mean:",sum/NR,"StdDev:",sqrt(sumsq/NR - (sum/NR)^2)}'

Common Vulnerable Patterns

Early-Exit String Comparison

# VULNERABLE - early exit reveals information
def check_token_vulnerable(provided, expected):
    if len(provided) != len(expected):
        return False  # Fast rejection

    for i in range(len(provided)):
        if provided[i] != expected[i]:
            return False  # Returns faster if mismatch earlier!
    return True

# Character match analysis:
# "abc" vs "xyz" - fails on first char (fast)
# "abc" vs "abz" - fails on third char (slower)
# Attacker can deduce correct characters by timing!

Why is this vulnerable: The comparison exits immediately upon finding the first mismatched character, creating measurable timing differences based on how many characters match. If the provided token is "abc" and the expected is "xyz", the function returns almost instantly (first character fails). If provided is "abc" and expected is "abz", it takes longer (processes three characters before failing). An attacker can exploit this by guessing one character at a time: try "a_" vs "b_" and measure which is slower - the slower one has the correct first character. Repeating this for each position allows byte-by-byte extraction of secrets through thousands of timed requests, even over network with statistical analysis to overcome network jitter.

Database Timing Leaks for User Enumeration

# VULNERABLE - timing reveals if user exists
def login_vulnerable(username, password):
    user = db.query(User).filter_by(username=username).first()

    if not user:
        return False  # FAST PATH - user doesn't exist

    # Slow password verification
    return verify_password(password, user.password_hash)  # SLOW PATH

# Timing attack:
# Non-existent user: ~1ms (database query only)
# Existing user: ~100ms (query + bcrypt verification)
# Attacker can enumerate valid usernames!

Why is this vulnerable: When a username doesn't exist, the function returns immediately after a quick database lookup (~1-5ms). For valid usernames, it performs expensive bcrypt password verification (~100-500ms). This 100x timing difference is easily observable even over network, allowing attackers to enumerate valid usernames by measuring response times. Once attackers know valid usernames, they can focus brute-force attacks on those accounts. The vulnerability occurs because the two code paths (user exists vs. doesn't exist) perform dramatically different amounts of computational work, creating an observable timing side channel.

Different Code Paths Based on Secret Data

# VULNERABLE - different code paths
def verify_api_key(provided_key):
    valid_key = get_valid_key()

    # Early return creates timing difference
    if len(provided_key) != len(valid_key):
        log_invalid_attempt()  # Extra operation for invalid!
        return False

    return hmac.compare_digest(provided_key, valid_key)

Why is this vulnerable: The length check creates an early exit path that includes an extra logging operation before returning. Invalid-length keys execute the logging function and return, while correct-length keys skip logging and proceed to comparison. Even though hmac.compare_digest() is constant-time, the different code paths before it create a timing leak. Additionally, the logging operation (writing to disk/network) takes variable time depending on I/O performance, adding noise that can still reveal information through statistical analysis. Attackers can first determine the correct key length by timing, then focus on guessing the actual key bytes.

Secure Patterns

Constant-Time Comparison Functions

# SECURE - constant time comparison
import hmac

def check_token_secure(provided, expected):
    # Always takes same time regardless of where differences are
    return hmac.compare_digest(provided, expected)

Why this works: hmac.compare_digest() is implemented in C to compare all bytes without early exit, ensuring execution time doesn't depend on the position of mismatched characters. It uses bitwise operations that execute in constant time on modern CPUs, avoiding conditional branches that could leak timing information through branch prediction. The function always processes the entire string length, whether strings match completely or differ at the first byte. This makes timing attacks infeasible because there's no correlation between correct characters and response time - all guesses take identical time, providing no signal for attackers to exploit.

Dummy Operations for Consistent Timing

# SECURE - consistent timing
import time

# Pre-compute a dummy hash for non-existent users
DUMMY_HASH = bcrypt.hashpw(b'dummy_password', bcrypt.gensalt())

def login_secure(username, password):
    user = db.query(User).filter_by(username=username).first()

    if user:
        # User exists - verify real password
        valid = verify_password(password, user.password_hash)
    else:
        # User doesn't exist - perform dummy expensive operation
        # Takes same time as real password verification
        verify_password(password, DUMMY_HASH)
        valid = False

    return valid

# Now both paths take ~100ms (query + bcrypt)
# Attacker cannot distinguish valid from invalid usernames

Why this works: By performing bcrypt verification against a dummy hash when the user doesn't exist, both code paths (valid user / invalid user) execute the same expensive operation, taking equivalent time (~100-500ms). The dummy hash is pre-computed at startup to ensure the dummy verification takes the same time as real verification. This eliminates the timing difference that allowed user enumeration - attackers measuring response times see no statistical difference between valid and invalid usernames. The approach ensures computational work is identical in both paths, not just similar, making timing analysis reveal no useful information.

Random Delays for Defense-in-Depth

import random
import time

def check_credentials_with_delay(username, password):
    start = time.time()

    # Perform actual check
    result = perform_authentication(username, password)

    # Calculate elapsed time
    elapsed = time.time() - start

    # Define baseline time (e.g., 500ms)
    baseline = 0.5

    # Add delay to reach baseline + small random jitter
    if elapsed < baseline:
        delay = baseline - elapsed + random.uniform(0, 0.1)
        time.sleep(delay)

    return result

Why this works: Adding random delays masks any residual timing differences that might remain from implementation quirks or CPU-level variations. The baseline ensures all operations take at least 500ms, while random jitter (0-100ms) prevents attackers from taking precise measurements - they can't average out the randomness without millions of samples. However, this is defense-in-depth only - it makes attacks harder but doesn't eliminate the channel. The primary defense must still be constant-time operations; random delays add noise to make statistical analysis require more samples, increasing attack cost. Never rely solely on random delays as they can be defeated with enough measurements.

Uniform Code Paths

# SECURE - same code path
def verify_api_key_secure(provided_key):
    valid_key = get_valid_key()

    # Pad to consistent length
    if len(provided_key) < len(valid_key):
        provided_key = provided_key.ljust(len(valid_key), '\0')
    elif len(provided_key) > len(valid_key):
        provided_key = provided_key[:len(valid_key)]

    # Always perform comparison
    result = hmac.compare_digest(provided_key, valid_key)

    # Log after comparison (same for both paths)
    if not result:
        log_invalid_attempt()

    return result

Why this works: Normalizing input to a consistent length before comparison ensures the constant-time comparison always processes the same number of bytes. Padding short inputs with null bytes and truncating long inputs creates uniform-length data, so there's no length-dependent timing. Moving the logging operation after the comparison (not before) ensures both success and failure paths execute identical code up to the final return. This eliminates branching that could create timing differences - whether the key is valid or invalid, the same operations execute in the same order, providing no timing signal about key validity or which characters match.

Security Checklist

  • All secret comparisons use constant-time functions
  • Database queries take consistent time
  • No early returns based on secret data
  • Error messages don't reveal timing info
  • Cache operations have consistent timing
  • Timing attack tests pass (no statistical difference)

Additional Resources