CWE-208: Observable Timing Discrepancy - C
Overview
In .NET, timing discrepancies typically come from comparing secrets - password hashes, HMAC digests, API keys, session tokens - with ==, string.Equals(), or Enumerable.SequenceEqual(). None of these is constant-time: each compares lengths first and then exits at the first difference it finds. == needs one qualification, because it does not always compare contents at all: it is value equality only when the static type is string. On a byte[] it is reference equality, so new byte[] {1,2,3} == new byte[] {1,2,3} is false on .NET 10 - as is a comparison of two equal strings held in object variables. Where a digest is compared with == the bug is correctness rather than timing, and SequenceEqual() is the wrong repair for it. Use System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ReadOnlySpan<byte>, ReadOnlySpan<byte>), available since .NET Core 2.1, for any comparison involving a secret. ASP.NET Core Identity's password hashers already perform constant-time comparison internally, so the risk is concentrated in hand-written HMAC/signature verification, webhook signature checks, and custom API key or token validation.
The largest timing discrepancy on a typical ASP.NET Core application is not in any of those comparisons, though - it is a login that resolves the username before hashing anything. See ASP.NET Core Identity below, and check for it first when triaging a finding on an authentication endpoint.
Common Vulnerable Patterns
Manual API Token Comparison
// VULNERABLE - string equality is not constant-time: it compares lengths,
// then stops at the first difference
public bool VerifyApiKey(string providedKey, string storedKey)
{
return providedKey == storedKey;
}
// Attack: submit many candidate keys of the right length, measure response
// latency per attempt
// Result: a candidate that matches further into the key takes marginally longer
// to reject, and one of the wrong length is rejected faster still
Why this is vulnerable: string.Equals compares the two lengths and then hands the characters to a vectorized comparison that stops at the first difference, so both the length and the position of the first difference are reflected in how long the call takes. How much they leak is worth measuring rather than assuming: on .NET 10 with a 64-character key compared in-process, a mismatch in the first character took 2.73 ns and one in the last 7.23 ns, while positions 0 and 3 were indistinguishable from each other because they sit in the same vector. The per-character recovery this is usually described as does not follow from those numbers, but the length signal and the coarse position signal are real, the block width is an implementation detail that changes between runtime versions, and the replacement below costs nothing.
Webhook Signature Verification with SequenceEqual
// VULNERABLE - SequenceEqual is not guaranteed constant-time
using System.Security.Cryptography;
using System.Text;
public bool VerifyWebhookSignature(byte[] payload, string signatureHeader, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] computed = hmac.ComputeHash(payload);
byte[] provided = Convert.FromHexString(signatureHeader);
return computed.SequenceEqual(provided); // DANGEROUS!
}
Why this is vulnerable: SequenceEqual() compares counts first and then returns on the first mismatched element, so an attacker who can resend a payload with a guessed signature learns something about where the guess went wrong. On a 32-byte digest the something is small - measured on .NET 10, a first-byte mismatch took 2.54 ns and a last-byte mismatch 2.93 ns, because LINQ takes a vectorized path for byte[] that covers the whole digest in two registers. Treat that as a reason to fix it cheaply rather than as a reason to dismiss it: the vectorized path is not a documented guarantee, and the same code compiled for a runtime without it degrades to a per-element loop with no visible change.
This example has a second problem that has nothing to do with timing, and it is the one that will page you: Convert.FromHexString throws FormatException on a signature header of odd length or containing a non-hex character. That input is entirely under the attacker's control, so a forged signature returns 500 where a well-formed forgery returns 401 - a difference visible without a stopwatch. The secure version below decodes without throwing.
Secure Patterns
CryptographicOperations.FixedTimeEquals
// SECURE - constant-time comparison regardless of where a mismatch occurs
using System.Buffers;
using System.Security.Cryptography;
using System.Text;
public bool VerifyApiKey(string providedKey, string storedKey)
{
// Hash both sides so the comparison is always 32 bytes against 32 bytes.
// FixedTimeEquals returns false immediately when the lengths differ, so
// comparing the raw keys would publish the length of the stored one.
byte[] provided = SHA256.HashData(Encoding.UTF8.GetBytes(providedKey));
byte[] expected = SHA256.HashData(Encoding.UTF8.GetBytes(storedKey));
return CryptographicOperations.FixedTimeEquals(provided, expected);
}
public bool VerifyWebhookSignature(byte[] payload, string? signatureHeader, string secret)
{
if (signatureHeader is null)
{
return false;
}
// Decode without throwing: the header is attacker-controlled, and
// Convert.FromHexString(string) raises FormatException on an odd length
// or a non-hex character. This OperationStatus overload is .NET 9+; before
// that, catch FormatException around the string overload and return false.
byte[] provided = new byte[SHA256.HashSizeInBytes];
OperationStatus status = Convert.FromHexString(
signatureHeader, provided, out _, out int bytesWritten);
if (status != OperationStatus.Done || bytesWritten != provided.Length)
{
return false;
}
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] computed = hmac.ComputeHash(payload);
return CryptographicOperations.FixedTimeEquals(computed, provided);
}
Why this works: For two spans of the same length FixedTimeEquals() compares them to the end whatever they contain, so its running time depends on that length and never on how much of the content matched, and there is no early exit for an attacker's timing measurement to key off. It does return false immediately if the two spans differ in length without comparing content, which is why the API key is hashed first: a SHA-256 digest is 32 bytes whatever the key was, so the length check can no longer be reached with two different lengths and the key's length stops being observable. Hashing is not standing in for the constant-time comparison here - it is what makes the comparison fixed-width.
Decoding the signature with the OperationStatus overload rather than Convert.FromHexString(string) is the other half. Verified against a real HMAC on .NET 10: a genuine signature returns true in upper or lower case, a signature with one hex digit changed returns false, and "zzzz", "abc", "", "de ad", null and a genuine signature with two extra characters appended all return false rather than throwing. Every rejection now takes the same path to the same 401.
Framework-Specific Guidance
ASP.NET Core Identity
// SECURE - hash a decoy when the username does not resolve, so both
// outcomes cost the same
using Microsoft.AspNetCore.Identity;
public class LoginService
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly IPasswordHasher<IdentityUser> _hasher;
private readonly string _decoyHash;
public LoginService(SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IPasswordHasher<IdentityUser> hasher)
{
_signInManager = signInManager;
_userManager = userManager;
_hasher = hasher;
// Minted once from the configured hasher, so it tracks
// PasswordHasherOptions.IterationCount instead of freezing at whatever
// a pasted-in literal was generated with.
_decoyHash = hasher.HashPassword(new IdentityUser(), Guid.NewGuid().ToString());
}
public async Task<SignInResult> SignInAsync(string userName, string password)
{
var user = await _userManager.FindByNameAsync(userName);
if (user is null)
{
// Same work as the real path; the result is discarded.
_hasher.VerifyHashedPassword(new IdentityUser(), _decoyHash, password);
return SignInResult.Failed;
}
// PasswordSignInAsync runs PreSignInCheck first and returns without
// hashing when it fails, so pay the same cost here on those paths.
bool willSkipHashing = await _userManager.IsLockedOutAsync(user)
|| !await _signInManager.CanSignInAsync(user);
if (willSkipHashing)
{
_hasher.VerifyHashedPassword(new IdentityUser(), _decoyHash, password);
}
// Still the framework call, so two-factor and lockout accounting stay.
return await _signInManager.PasswordSignInAsync(
user, password, isPersistent: false, lockoutOnFailure: true);
}
}
Why this works: PasswordHasher<TUser>.VerifyHashedPassword(), used internally by SignInManager/UserManager, already performs a constant-time comparison of the computed hash against the stored hash, so the comparison itself is not where a .NET login leaks. What leaks is the lookup: SignInManager<TUser>.PasswordSignInAsync(string userName, ...) - the overload a controller reaches for first - resolves the name with FindByNameAsync and returns SignInResult.Failed when it comes back null, without hashing anything. Measured on Identity 10.0.11 with the default hasher, an unknown username answered in 0.049 ms against 53.5 ms for a real username with a wrong password: 1,102x, from an endpoint that returns the same body either way. Resolving the user first and hashing a decoy on the miss brought the two to 53.15 ms and 53.85 ms - 1.01x - with a correct password still signing in.
Two things the code above is careful about, both of which reopen the gap if dropped. The decoy has to be a real hash from the configured hasher: VerifyHashedPassword against "" or a non-hash string returns Failed in 0.0002 ms rather than the 54 ms a genuine one costs, so a placeholder closes nothing. And PasswordSignInAsync needs the cookie scheme registered (AddAuthentication(IdentityConstants.ApplicationScheme).AddCookie(...)), or the successful path throws InvalidOperationException: No sign-in authentication handlers are registered while both failing paths return normally - which is a fix that only rejects, and only the accept test finds it. CWE-287 covers the surrounding login flow.
Minimal API / Custom Authentication Handlers
// SECURE - custom API key authentication handler
using Microsoft.AspNetCore.Authentication;
using System.Security.Cryptography;
using System.Text;
public class ApiKeyAuthenticationHandler
{
private readonly byte[] _expectedKeyDigest;
public ApiKeyAuthenticationHandler(string configuredKey)
{
_expectedKeyDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configuredKey));
}
public bool Authenticate(string? providedKey)
{
if (providedKey is null)
{
return false;
}
byte[] provided = SHA256.HashData(Encoding.UTF8.GetBytes(providedKey));
return CryptographicOperations.FixedTimeEquals(provided, _expectedKeyDigest);
}
}
Why this works: Custom authentication handlers - the code most likely to hand-roll a comparison - have no framework password verifier to fall back on, so the comparison has to go through FixedTimeEquals() explicitly. That closes the same timing channel a hand-written == check would leave open. (FixedTimeEquals() is itself framework-provided, in CryptographicOperations; what is missing here is a verifier that would have called it for you.) Hashing the configured key once in the constructor and each submitted key on arrival keeps both operands at 32 bytes, so a key of the wrong length is rejected by the same fixed-width comparison as a key of the right length with the wrong contents, rather than by the length check inside FixedTimeEquals.
Considerations
Confirm the compared value is actually a secret. This weakness is about
comparisons an attacker can time their way through, which means the value has
to be one they are trying to guess: a password hash, an HMAC digest, an API
key, a session token, a signature. == on a username, a public identifier or a
feature flag is not this finding, however much it looks like the flagged
pattern. Record those as false positives with the reason.
Check whether the library already did it. Password verification helpers in the major frameworks compare in constant time internally, so a comparison of the boolean result they return is not a timing leak and does not need changing. The finding is about your own comparison of raw secret bytes.
Decide whether leaking the length matters. FixedTimeEquals returns
false as soon as the two spans differ in length, without looking at the
contents - documented behaviour, not an accident. For fixed-size values (a
SHA-256 digest, a signature) the length is public anyway and this costs
nothing. For variable-length secrets such as API keys it publishes the length
of the stored key, which is why the examples above hash both sides first: a
digest is 32 bytes whatever went into it.
Identity's own pre-sign-in checks are a second fast path, which is what the
willSkipHashing branch above is for. PasswordSignInAsync runs
PreSignInCheck before verifying the password, and returns early for every
state that check rejects - not only a locked-out account but any account
CanSignInAsync refuses, which with the template's default
SignIn.RequireConfirmedAccount = true means every user who has registered and
not yet confirmed. Measured on Identity 10.0.11 without that branch: an
unconfirmed account answered in 0.049 ms against 55.2 ms for a confirmed one, a
1,138x gap, and a locked-out account in 0.02 ms against 57.95 ms. Both say
"this account exists", which is exactly what the decoy hash was added to stop
the endpoint saying. It is sharpest immediately after registration, which is
when someone probing for valid addresses is looking. With the branch in place
the four cases measured 54.2, 54.0, 53.3 and 53.2 ms, and a correct password
still signed in.
Asking that question first is also what keeps two-factor working. The
obvious alternative - verify the password yourself with
UserManager.CheckPasswordAsync, then apply the state checks - equalizes the
timings too, at 53.2 to 54.0 ms. But the sign-in it then has to issue is
SignInManager.SignInAsync, which writes the authentication cookie outright,
where PasswordSignInAsync routes through SignInOrTwoFactorAsync and stops.
Measured against a user with an authenticator registered, the framework call
returned RequiresTwoFactor and that replacement returned Succeeded - a login
that skips the second factor, which is a worse weakness than the timing channel
it closes. Calling IsLockedOutAsync and CanSignInAsync to decide whether to
hash a decoy, and then still calling PasswordSignInAsync, avoids it: verified,
the two-factor user returns RequiresTwoFactor through both paths.
Two costs to weigh. Every request naming a locked-out account now pays a full
password hash, which is part of what lockout exists to avoid, so rate-limit by
source address ahead of the login. And timing is only one channel:
SignInResult.NotAllowed and SignInResult.LockedOut still distinguish
themselves from Failed, so a controller that renders different responses for
them hands back through the body what this branch just closed.
Testing
- A correct API key, token, and webhook signature still authenticate - the accept test, without which a fix that rejects everything passes the suite identically to a working one.
- A signature header of
"zzzz","abc","","de ad"and a genuine signature with two characters appended each return401, not500. A500means a decode is still throwing on attacker-controlled input, and it is distinguishable from a401without any timing measurement at all. - Time one login per account state, not three. Unknown username, confirmed user with a wrong password, confirmed user with the right password, locked-out user, and - if
RequireConfirmedAccountis on - a registered but unconfirmed user. Every failing case should land within noise of the others; measured on Identity 10.0.11 they come in at 54.2, 54.0, 53.3 and 53.2 ms. Any sub-millisecond answer is an enumeration oracle, and the unconfirmed and locked-out ones are the two that survive a decoy hash: without thewillSkipHashingbranch they measured 0.049 ms and 0.02 ms. - A two-factor account still returns
RequiresTwoFactor. Register an authenticator on a test user and assert the result, because a login rewritten to equalize timing can silently start returningSucceededinstead. Note that a user withTwoFactorEnabledand no valid provider returnsSucceededfrom the correct implementation too, so the assertion only means something onceGetValidTwoFactorProvidersAsyncis non-empty. - A key of the wrong length is refused with the same code path as a key of the right length with wrong contents, so an attacker cannot separate the two.
- Search the codebase for other
==,.Equals(and.SequenceEqual(call sites that compare against a hash, token, key or secret field - the reported line is a sample, not the population.
Common Pitfalls
- Using
FixedTimeEquals()only on the final comparison, but computing an intermediate hash with a timing-variable step first - for example, using a non-constant-time string comparison to look up which stored secret to compare against before ever reaching the fixed-time check. The lookup step itself must not branch on secret content. - Comparing
SecureStringor plaintext values with==before converting toFixedTimeEquals()inputs - the vulnerable comparison still executes if it happens earlier in the same method, even if a safe comparison follows it. - Assuming Identity's built-in password verification covers custom token or API key checks too -
PasswordHasherprotects password verification specifically; any additional hand-written HMAC, signature, or key comparison still needs its ownFixedTimeEquals()call.