CWE-597: Use of Wrong Operator in String Comparison - C#
Overview
Using reference equality (== on object types) instead of value equality (.Equals()) for string comparison in C# can compare memory addresses rather than content when strings are not interned, causing security checks to fail unpredictably and enabling authentication bypass and logic errors.
Primary Defence: Always use .Equals() method or String.Equals() static method for string content comparison; specify StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase for security-critical comparisons to avoid culture-sensitive behavior; use CryptographicOperations.FixedTimeEquals() (.NET Core 2.1+) for constant-time comparison of passwords and tokens to prevent timing attacks; prefer enums over strings for security-critical values like roles and permissions.
Common Vulnerable Patterns
Reference Equality in Authentication (ASP.NET Core)
// VULNERABLE - relying on == operator behavior
[HttpPost("login")]
public IActionResult Login(string username, string password)
{
var storedPassword = _userService.GetPassword(username);
// DANGEROUS - may work due to string interning, but unreliable
if (password == storedPassword)
{
return RedirectToAction("Dashboard");
}
return View("Login");
}
// Why this might fail:
// - If storedPassword from database: different object, == may fail
// - If password from user input: different object
// - C# string interning makes this work sometimes, fail other times
Why is this vulnerable: While C# overloads the == operator for strings to perform value equality by default, this behavior can be inconsistent when dealing with strings obtained through different mechanisms. Strings from database queries, configuration files, or user input may not be interned, causing comparison failures. More critically, if code is refactored to use object types or interfaces, the == operator will revert to reference equality without warning, silently breaking security checks.
Reference Equality with Object Type
// VULNERABLE - == reverts to reference equality with object type
[Authorize]
public IActionResult AdminPanel()
{
var user = User.Identity;
object role = _userService.GetRole(user.Name); // Returns as object
if (role == "ADMIN") // WRONG - reference equality!
{
return View("Admin");
}
return Forbid();
}
// What happens:
// - role is object type (not string)
// - == uses reference equality for object
// - Even if role contains "ADMIN", comparison fails
// Authorization broken!
Why is this vulnerable: When strings are stored in object variables, the == operator uses reference equality instead of value equality. This is a common issue when working with reflection, dynamic types, or generic APIs that return object. The comparison role == "ADMIN" checks if they're the same object in memory, not if they contain the same text. This silently breaks authorization checks - administrators are denied access while the code appears syntactically correct. It's particularly dangerous because it compiles without warnings.
Culture-Sensitive Comparison in Security Checks
// VULNERABLE - culture-sensitive comparison
[HttpPost("api/command")]
public IActionResult ExecuteCommand([FromBody] CommandRequest request)
{
// WRONG - uses current culture for comparison
if (request.Action.ToLower() == "delete")
{
DeleteResource(request.ResourceId);
return Ok("Deleted");
}
return BadRequest("Unknown action");
}
// Vulnerability:
// - ToLower() behavior varies by culture
// - In Turkish locale: "DELETE".ToLower() => "delete" but 'I'.ToLower() => 'ı' (not 'i')
// - Attacker with Turkish locale might bypass validation
// - Or legitimate Turkish users might be blocked
Why is this vulnerable: Using .ToLower() or .ToUpper() without specifying a culture uses the current thread's culture, which can vary based on server locale, user preferences, or localization settings. In Turkish locale, the letter 'I' lowercases to 'ı' (dotless i) instead of 'i', causing unexpected comparison failures. An attacker could potentially exploit this by manipulating culture settings or locale headers to bypass security checks. Always use StringComparison.OrdinalIgnoreCase for security-critical comparisons.
Missing Ordinal Comparison for Tokens
// VULNERABLE - default string comparison for CSRF tokens
[HttpPost("transfer")]
public IActionResult Transfer(string csrf)
{
var sessionToken = HttpContext.Session.GetString("csrf");
// WRONG - uses current culture StringComparison
if (csrf.Equals(sessionToken))
{
ProcessTransfer();
return Ok("Transfer complete");
}
return StatusCode(403, "Invalid CSRF token");
}
// Issues:
// - Default Equals() uses CurrentCulture comparison
// - Culture-sensitive comparison for binary token data
// - May behave differently on different servers/locales
// - Timing differences based on culture rules
Why is this vulnerable: The default .Equals() method uses culture-sensitive comparison (StringComparison.CurrentCulture), which is inappropriate for tokens, hashes, or other security-critical values. Culture-sensitive rules can cause unexpected behavior - certain character combinations might be considered equivalent in some locales but not others. For security tokens, you need exact byte-for-byte comparison using StringComparison.Ordinal. Additionally, non-constant-time comparison can leak information through timing attacks.
String Comparison in JWT Validation
// VULNERABLE - culture-sensitive issuer validation
public bool ValidateToken(string token)
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
// WRONG - culture-sensitive comparison
if (jwt.Issuer.ToUpper() == "HTTPS://MYAPP.COM")
{
return true;
}
return false;
}
// Problems:
// - ToUpper() uses current culture
// - In Turkish: 'i'.ToUpper() => 'İ' (dotted I) not 'I'
// - URL "https://myapp.com" might fail in Turkish locale
// - Timing attack possible (non-constant-time)
Why is this vulnerable: JWT issuer validation is critical for preventing token forgery. Using .ToUpper() without specifying invariant culture creates locale-dependent behavior that can fail for legitimate tokens or allow bypass in specific locales. The Turkish 'i'/'I' issue is particularly problematic for URLs. Additionally, string comparison that short-circuits on the first mismatch allows timing attacks where attackers can guess the issuer character-by-character by measuring response times.
Secure Patterns
Value Equality with Ordinal Comparison
// SECURE - explicit ordinal comparison
[HttpPost("login")]
public IActionResult Login(string username, string password)
{
var user = _userService.GetUser(username);
if (user != null &&
String.Equals(user.PasswordHash, HashPassword(password),
StringComparison.Ordinal))
{
return RedirectToAction("Dashboard");
}
return View("Login");
}
// Why StringComparison.Ordinal:
// - Culture-invariant: works same on all servers/locales
// - Binary comparison: exact byte-by-byte match
// - Fast: no culture rules to process
// - Predictable: no locale-based surprises
Why this works: StringComparison.Ordinal performs culture-invariant binary comparison, treating strings as sequences of UTF-16 code units. This ensures consistent behavior across all locales and servers, with no culture-specific transformation rules. It's faster than culture-sensitive comparison and provides predictable results. For passwords, tokens, and other security-critical values, ordinal comparison is the correct choice because these values are binary data represented as strings, not human-readable text that needs localization.
Case-Insensitive Ordinal Comparison
// SECURE - case-insensitive ordinal comparison
[HttpGet("api/{resource}")]
public IActionResult GetResource(string resource)
{
// SECURE - OrdinalIgnoreCase for case-insensitive comparison
if (String.Equals(resource, "admin", StringComparison.OrdinalIgnoreCase))
{
return Forbid(); // Protected resource
}
return Ok(GetResourceData(resource));
}
// Alternative syntax (instance method):
if (resource.Equals("admin", StringComparison.OrdinalIgnoreCase))
{
return Forbid();
}
// Why OrdinalIgnoreCase:
// - Case-insensitive without culture dependence
// - Avoids Turkish 'I'/'i' issues
// - Consistent on all servers
// - Perfect for URLs, file paths, HTTP headers
Why this works: StringComparison.OrdinalIgnoreCase performs case-insensitive comparison without culture-specific rules, avoiding the Turkish locale issues with .ToLower() and .ToUpper(). It uses Unicode case folding rules that work consistently across all locales. This is ideal for comparing URLs, file extensions, HTTP methods, or any case-insensitive identifier. The comparison is both safe and efficient, avoiding the overhead of creating temporary lowercase/uppercase strings.
Enum-Based Authorization
// BEST - use enums for roles
public enum Role
{
User,
Admin,
Moderator
}
[Authorize]
[HttpGet("admin")]
public IActionResult AdminPanel()
{
var userRole = _userService.GetUserRole(User.Identity.Name);
// Safe to use == with enums (value types)
if (userRole == Role.Admin)
{
return View("Admin");
}
return Forbid();
}
// Entity Framework model:
public class User
{
public int Id { get; set; }
public string Username { get; set; }
// EF Core stores as string, retrieves as enum
public Role Role { get; set; }
}
// Why this works:
// - Enums are value types (not reference types)
// - == compares values, not references
// - Type-safe: compiler catches typos
// - No culture issues
// - No null reference issues with constants
Why this works: Enums are value types in C#, so the == operator always compares their underlying integer values, not object references. This makes == safe and correct for enum comparisons. Using enums for roles provides compile-time type safety (typos like "AMIN" cause compilation errors), prevents SQL injection (enum values are validated), and eliminates culture/locale issues. Entity Framework Core can map enums to database strings while maintaining type safety in code.
Constant-Time Comparison for Secrets
// SECURE - constant-time comparison (.NET Core 2.1+)
using System.Security.Cryptography;
[HttpPost("api/validate")]
public IActionResult ValidateToken([FromHeader] string token)
{
var sessionToken = HttpContext.Session.GetString("apiToken");
if (sessionToken == null || token == null)
{
return Unauthorized();
}
// SECURE - constant-time comparison
byte[] sessionBytes = Encoding.UTF8.GetBytes(sessionToken);
byte[] providedBytes = Encoding.UTF8.GetBytes(token);
if (CryptographicOperations.FixedTimeEquals(sessionBytes, providedBytes))
{
return Ok("Valid token");
}
return Unauthorized();
}
// Why constant-time:
// - Prevents timing attacks
// - Takes same time whether tokens match or not
// - Secure comparison for passwords, tokens, HMAC signatures
Why this works: CryptographicOperations.FixedTimeEquals() compares byte arrays in constant time, preventing timing attacks where attackers measure response times to guess secrets character-by-character. Regular string comparison can short-circuit (return early on first mismatch), leaking information about which characters match. For passwords, CSRF tokens, API keys, or HMAC signatures, constant-time comparison is essential. This method examines every byte regardless of mismatches, providing no timing information to attackers.
Null-Safe Comparison
// SECURE - null-safe string comparison
public bool MatchesUsername(User user, string targetUsername)
{
// Option 1: Manual null check
if (user?.Username == null || targetUsername == null)
{
return false;
}
return String.Equals(user.Username, targetUsername,
StringComparison.Ordinal);
// Option 2: Null propagation with Equals
return user?.Username?.Equals(targetUsername,
StringComparison.Ordinal) == true;
// Option 3: String.Equals handles nulls
return String.Equals(user?.Username, targetUsername,
StringComparison.Ordinal);
}
// String.Equals static method behavior:
// String.Equals(null, null, ...) => true
// String.Equals("test", null, ...) => false
// String.Equals(null, "test", ...) => false
Why this works: The static String.Equals() method handles null values correctly - it returns true only when both strings are non-null and equal, or both are null. The null-conditional operator (?.) safely navigates through potentially null references. Using == true with nullable bool ensures the result is false if any intermediate step returns null. This defensive programming prevents NullReferenceExceptions that could crash the application or expose error details to attackers.
CSRF Token Validation Best Practice
// SECURE - proper CSRF token validation
using Microsoft.AspNetCore.Antiforgery;
public class SecureController : Controller
{
private readonly IAntiforgery _antiforgery;
public SecureController(IAntiforgery antiforgery)
{
_antiforgery = antiforgery;
}
[HttpPost("transfer")]
[ValidateAntiForgeryToken] // Best: Use built-in attribute
public IActionResult Transfer(TransferRequest request)
{
ProcessTransfer(request);
return Ok("Transfer complete");
}
// Manual validation (if needed):
[HttpPost("custom")]
public async Task<IActionResult> CustomEndpoint()
{
// Framework handles comparison securely
await _antiforgery.ValidateRequestAsync(HttpContext);
ProcessRequest();
return Ok();
}
}
// Why this works:
// - Framework handles token generation, validation, comparison
// - Uses cryptographically secure comparison
// - Handles base64 encoding/decoding
// - Protects against timing attacks
// - Automatic token rotation
Why this works: ASP.NET Core's [ValidateAntiForgeryToken] attribute uses the framework's built-in antiforgery system, which generates cryptographically random tokens, stores them securely in encrypted cookies, and validates them using constant-time comparison. This eliminates the need to manually compare CSRF tokens and prevents timing attacks. The framework handles all edge cases, including token rotation, same-site cookie policies, and HTTPS enforcement. Using the framework's solution is more secure than manual string comparison.
Testing and Verification
Unit Tests for String Comparison
using Xunit;
using System;
public class StringComparisonTests
{
[Fact]
public void TestOrdinalComparison()
{
string password1 = "Secret123";
string password2 = new string(new[] { 'S', 'e', 'c', 'r', 'e', 't', '1', '2', '3' });
// Ordinal comparison - reliable
Assert.True(String.Equals(password1, password2, StringComparison.Ordinal));
// Also works with instance method
Assert.True(password1.Equals(password2, StringComparison.Ordinal));
}
[Fact]
public void TestCaseInsensitiveOrdinal()
{
string method1 = "DELETE";
string method2 = "delete";
string method3 = "DeLeTe";
Assert.True(String.Equals(method1, method2,
StringComparison.OrdinalIgnoreCase));
Assert.True(String.Equals(method1, method3,
StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void TestNullSafety()
{
string nonNull = "test";
string nullString = null;
// String.Equals handles nulls
Assert.False(String.Equals(nonNull, nullString,
StringComparison.Ordinal));
Assert.True(String.Equals(nullString, nullString,
StringComparison.Ordinal));
// Instance method - would throw on null
Assert.Throws<NullReferenceException>(() =>
nullString.Equals(nonNull, StringComparison.Ordinal));
}
[Fact]
public void TestEnumComparison()
{
Role role1 = Role.Admin;
Role role2 = Role.Admin;
Role role3 = Role.User;
// Safe to use == with enums (value types)
Assert.True(role1 == role2);
Assert.False(role1 == role3);
// Also works with Equals
Assert.True(role1.Equals(role2));
}
[Fact]
public void TestCultureIndependence()
{
// Turkish locale test
var originalCulture = System.Threading.Thread.CurrentThread.CurrentCulture;
try
{
System.Threading.Thread.CurrentThread.CurrentCulture =
new System.Globalization.CultureInfo("tr-TR");
string input = "FILE";
// Culture-sensitive (WRONG for security)
string lowerCurrent = input.ToLower(); // "fıle" in Turkish
Assert.NotEqual("file", lowerCurrent);
// Ordinal (CORRECT - culture-independent)
Assert.True(String.Equals("FILE", "file",
StringComparison.OrdinalIgnoreCase));
}
finally
{
System.Threading.Thread.CurrentThread.CurrentCulture = originalCulture;
}
}
}
Security Tests for Constant-Time Comparison
using Xunit;
using System.Security.Cryptography;
using System.Text;
using System.Diagnostics;
public class ConstantTimeComparisonTests
{
[Fact]
public void TestFixedTimeEquals()
{
string token1 = "secret-token-12345";
string token2 = "secret-token-12345";
string token3 = "wrong-token-67890";
byte[] bytes1 = Encoding.UTF8.GetBytes(token1);
byte[] bytes2 = Encoding.UTF8.GetBytes(token2);
byte[] bytes3 = Encoding.UTF8.GetBytes(token3);
// Should return true for matching tokens
Assert.True(CryptographicOperations.FixedTimeEquals(bytes1, bytes2));
// Should return false for non-matching
Assert.False(CryptographicOperations.FixedTimeEquals(bytes1, bytes3));
}
[Fact]
public void TestConstantTimeProperty()
{
// This test verifies timing is consistent
string correctToken = new string('A', 1000);
string wrongAtStart = "B" + new string('A', 999);
string wrongAtEnd = new string('A', 999) + "B";
byte[] correct = Encoding.UTF8.GetBytes(correctToken);
byte[] wrongStart = Encoding.UTF8.GetBytes(wrongAtStart);
byte[] wrongEnd = Encoding.UTF8.GetBytes(wrongAtEnd);
// Measure time for mismatch at start
var sw1 = Stopwatch.StartNew();
for (int i = 0; i < 10000; i++)
{
CryptographicOperations.FixedTimeEquals(correct, wrongStart);
}
sw1.Stop();
// Measure time for mismatch at end
var sw2 = Stopwatch.StartNew();
for (int i = 0; i < 10000; i++)
{
CryptographicOperations.FixedTimeEquals(correct, wrongEnd);
}
sw2.Stop();
// Times should be similar (within 10% for constant-time)
double ratio = (double)sw1.ElapsedTicks / sw2.ElapsedTicks;
Assert.InRange(ratio, 0.9, 1.1);
}
}
Security Checklist
- No reliance on
==operator for string comparison withobjecttypes - All security-critical string comparisons specify
StringComparison.OrdinalorStringComparison.OrdinalIgnoreCase - No use of
.ToLower()or.ToUpper()without invariant culture for security checks - Password/token comparisons use
CryptographicOperations.FixedTimeEquals() - Security-critical values (roles, permissions) use enums instead of strings
- Null checks performed before string comparisons or use static
String.Equals() - CSRF protection uses
[ValidateAntiForgeryToken]attribute - Unit tests verify string comparison works across different cultures
- Static analysis tools (Roslyn analyzers, SonarQube) configured to detect insecure string comparison
- Integration tests verify authorization works with database-loaded roles