Skip to content

CWE-597: Use of Wrong Operator in String Comparison - PHP

Overview

PHP's loose equality operator (==) converts its operands to a common type before comparing, so two strings that are not equal can still test equal. PHP 8.0 removed the worst of it - a non-numeric string is no longer cast to 0 when compared against a number - but the string-to-string case is untouched and is the one that matters for security, because password hashes and tokens are strings on both sides.

Verified on PHP 8.5.8:

  • "0e12345" == "0e67890" is true. Both match PHP's numeric-string grammar as scientific notation, so both are read as zero
  • "1e3" == "1000" is true, for the same reason
  • "0" == false and null == "" are true
  • 0 == "admin" is now false; on PHP 7.4 and earlier it was true, because the string was cast to an integer first. Treat a finding of that shape as an upgrade signal as well as a code fix

Primary Defence: Use strict equality (===) for all security-sensitive string comparisons. Use password_verify() for passwords, and hash_equals() for tokens, signatures and MACs. Where the comparison is not written as an operator - in_array(), switch, array_search() - use the strict form or match.

Common Vulnerable Patterns

Hash Comparison with ==

<?php
// VULNERABLE - two different hashes compare equal when both parse as numbers
$storedHash = getHashFromDatabase($username);   // md5('240610708')
$inputHash  = md5($_POST['password']);          // md5('QNKCDZO')

if ($inputHash == $storedHash) {   // VULNERABLE - both are "0e" + digits
    loginUser($username);
}

Why this is vulnerable:

  • A string matching 0e[0-9]+ is a valid numeric literal in PHP - zero raised to some power - so == compares the two as numbers and finds them both zero. Measured on PHP 8.5.8: md5('240610708') is 0e462097431906509019562988736854, md5('QNKCDZO') is 0e830400451993494058024219903391, and == reports them equal while === and hash_equals() both report them different. An attacker only has to submit one of the known "magic hash" inputs to authenticate as any account whose stored hash also begins 0e.
  • The proportion of hashes affected is small - an MD5 hex digest is 0e followed by thirty digits about once in 3.4 x 10^8, and a SHA-1 one about once in 1.5 x 10^10 - but the odds are not what makes this exploitable. Every 0e-prefixed stored hash matches every 0e-prefixed candidate, so the attacker does not need a collision with a particular account: they submit one of the handful of published magic-hash inputs against every account until one lands. The weakness is that the comparison stopped being an equality test, not the probability.
  • MD5 for password storage is a separate and larger defect (CWE-916). Fixing the operator here without fixing the algorithm leaves the account recoverable offline.

Loose Comparison Without an Operator

<?php
// VULNERABLE - none of these has a == in sight, and all three compare loosely
// Legacy store: $activeKeyHashes holds md5 digests, so an element can parse
// as a number and collide with another that also does.
function isValidApiKey(string $key, array $activeKeyHashes): bool {
    return in_array(md5($key), $activeKeyHashes);   // loose: no third argument
}

function routeByToken(string $token, string $expected): string {
    switch ($token) {                              // switch compares with ==
        case $expected:
            return 'authorized';
        default:
            return 'denied';
    }
}

function indexOfKey(string $key, array $knownKeys): int|false {
    return array_search($key, $knownKeys);         // no third argument: loose
}

Why this is vulnerable:

  • in_array() and array_search() compare with == unless their third argument is true, and switch compares each case with == and has no strict form at all. A reviewer grepping for == finds none of these. Measured on PHP 8.5.8 with the two magic hashes above: in_array($guess, [$stored]) returns true, array_search($guess, [$stored]) returns 0, and the switch takes the case branch - while in_array($guess, [$stored], true) returns false.
  • A loose comparison only ever matches too much, never too little, and that decides which use of it is a breach. Measured across 256 string pairs on PHP 8.5.8, == is true everywhere === is, plus 28 pairs where === is false. So the in_array() above - an allowlist, where a match admits - fails open: a key that is not on the list is accepted because it collates to the same number as one that is. The same call used as a denylist, a revocation or blocked-name list where a match refuses, fails the other way: everything genuinely on the list is still blocked, and some innocent values are blocked too. That is a real defect - it locks out legitimate tokens - but it is not a bypass, and it is worth separating from the allowlist case when triaging.
  • match is the exception and the easiest migration: it compares with ===. Measured on the same pair, match($guess) { $stored => 'MATCHED', default => 'rejected' } returns rejected where the switch matched.
  • The digest decides whether the collision is reachable, and it is worth checking before rating the finding. A 0e-plus-digits hex digest turns up about once in 3.4 x 10^8 for MD5 and once in 1.5 x 10^10 for SHA-1, which is why published magic-hash inputs exist for both; for SHA-256 it is once in about 1.2 x 10^15, and a search of the first 20 million integers on PHP 8.5.8 found no such digest at all. So the same loose in_array() over SHA-256 digests is a latent defect rather than a live bypass. Fix the operator either way - it costs one argument, and the reachability argument evaporates the moment somebody stores something other than a digest in that list - but do not report it at the same severity.

Token Comparison with ==

<?php
// VULNERABLE - loose comparison of tokens, and not timing-safe
function validateCsrfToken(string $formToken, string $sessionToken): bool {
    return $formToken == $sessionToken;
}

Why this is vulnerable:

  • Both operands are declared string, so the integer-coercion cases cannot arise - but the string-to-string numeric comparison still can, and a token drawn from a numeric or hex alphabet can produce one. === costs nothing and removes the question.
  • == and === both exit at the first difference they find, so the time the call takes reflects how much of the submitted token matched. What that leaks in practice is coarser than the usual "recover it one character at a time" description, and the length difference is the part that leaks cleanly - see CWE-208 for the measurements. hash_equals() removes both the coercion and the early exit in one call, so there is no reason to reach for === on a secret.

Legacy PHP 7 Role Check with Loose Equality

<?php
// VULNERABLE on PHP <= 7.4 - type juggling with loose ==
function checkAdminRole($userRole): bool {
    return $userRole == "admin";   // true when $userRole is the integer 0
}

// Attack: the role arrives from a JSON body as a number rather than a string
// json_decode('{"role": 0}') -> $userRole = 0
checkAdminRole(0);   // returns true on PHP 7.4 and earlier

Why this is vulnerable:

  • On PHP 7.4 and earlier, comparing a string to an integer with == cast the string to an integer first, and every non-numeric string casts to 0. So "admin" == 0 was true, and a JSON body sending "role": 0 matched the admin check.
  • PHP 8.0 changed this: the number is now converted to a string when the string is not numeric, so 0 == "admin" is false on any supported runtime. The parameter is also untyped here, which is what let an integer reach the comparison at all. A string type declaration narrows that, but read the next section before treating it as a guarantee: whether it rejects an integer or silently casts it is decided by the calling file, not by this one.

Secure Patterns

Strict Equality for Role and Permission Checks

<?php
declare(strict_types=1);

// SECURE - === checks both value AND type; no coercion
function checkRole(string $userRole, string $requiredRole): bool {
    return $userRole === $requiredRole;
}

// SECURE - strict membership - note the third argument
function hasAnyRole(string $userRole, array $allowedRoles): bool {
    return in_array($userRole, $allowedRoles, true);
}

// SECURE - match compares with ===, so there is no loose form to forget
function permissionFor(string $action): string {
    return match ($action) {
        'delete' => 'admin',
        'update' => 'editor',
        default  => throw new InvalidArgumentException("Unknown action: {$action}"),
    };
}

Why this works:

  • === requires both operands to have the same type and the same value, so no numeric-string reading of either side is possible: "0e12345" === "0e67890" is false, and so is "admin" === 0.
  • in_array($needle, $haystack, true) applies === to each element. The third argument is the entire difference between an allowlist and a value that only looks like one, and it is easy to omit because the two-argument call is valid.
  • match compares with === by definition and has no fall-through, so an unlisted action reaches the default arm rather than the next one. Throwing there rather than returning a value means a permission added to the model and not to the match is a loud failure instead of a quiet grant.
  • declare(strict_types=1) makes type declarations enforced rather than coercive - but it is the calling file's declaration that decides, not the one holding the function. Measured on PHP 8.5.8 with isAdmin(string $role) declared in a file that has the directive: called from another file that also has it, isAdmin(0) raises TypeError; called from a file without it, the same call silently casts 0 to '0' and returns false. Indirect dispatch makes no difference - call_user_func, ReflectionFunction::invoke and array_map all coerced when the file issuing the call was non-strict. That is the common case in a framework, where the code invoking your controller is vendor code you did not write, so treat the type declaration as documentation plus a partial guard rather than as a boundary that cannot be crossed. The === is what does not depend on anybody else's file.

Constant-Time Token Comparison with hash_equals()

<?php
declare(strict_types=1);

// SECURE - hash_equals() is constant-time and string-typed
function validateCsrfToken(string $formToken, string $sessionToken): bool {
    return hash_equals($sessionToken, $formToken);
}

function validateResetToken(string $submittedToken, string $storedToken): bool {
    return hash_equals($storedToken, $submittedToken);
}

Why this works:

  • hash_equals() walks both strings to the end rather than returning at the first difference, so its duration does not reflect how much of the submitted value matched. It also compares bytes rather than values, so neither operand is read as a number.
  • It returns false for a length mismatch rather than raising, so a truncated attacker token produces an ordinary rejection and not a 500 - unlike Node's crypto.timingSafeEqual, which throws. Both arguments must be strings: hash_equals() raises a TypeError for anything else, which is a reason to keep the type declarations rather than to guard around it.
  • The length is the one thing it does not hide, and that is worth stating precisely rather than assuming the constant-time guarantee covers everything. Measured on PHP 8.5.8, a mismatched pair returns in about 59 ns whichever way round the arguments are, against 2,064 ns for two equal 4,096-byte strings - the length check short-circuits before the byte loop. So the submitted token's length is observable and its content is not. Argument order makes no difference to the timing; the signature is hash_equals(string $known_string, string $user_string) and putting the stored value first is a readability convention.

Password Verification with password_verify()

<?php
declare(strict_types=1);

// A genuine bcrypt hash at the same cost as the stored hashes, so the decoy
// branch takes the same time as the real one. Generated once with
// password_hash('unused-dummy-password', PASSWORD_BCRYPT, ['cost' => 12]).
const DUMMY_HASH = '$2y$12$EYlyp2dCPTdwaBcHc17Be.6gJfyo8TJywGLMSGrX.OuU3fjT2H6w2';

function loginUser(string $username, string $submittedPassword): bool {
    $user = getUserFromDatabase($username);

    if ($user === null) {
        // Verify against the decoy so an unknown username costs the same as a
        // known one, and discard the result.
        password_verify($submittedPassword, DUMMY_HASH);
        return false;
    }

    return password_verify($submittedPassword, $user->passwordHash);
}

Why this works:

  • password_verify() extracts the algorithm, cost and salt from the stored hash, derives the candidate with the same parameters, and compares the results in constant time. There is no operator to choose, and no way to compare a stored password hash correctly by hand - === and hash_equals() on the hash strings both fail, because the candidate has to be derived with the stored salt before there is anything to compare.
  • The decoy closes the user-enumeration channel that an early return would open, and it only works if the decoy is a real hash at the cost the stored hashes carry. Measured on PHP 8.5.8 by running the function above: an unknown username took 200.6 ms, a known username with the wrong password 199.0 ms and a known username with the right password 206.8 ms, and the correct password still authenticated. For contrast, on the same machine a cost-10 decoy verifies in 49.5 ms and an empty or malformed one in 0.0004 ms - either reopens the gap while looking exactly like the fix. See CWE-208 and CWE-287.
  • Because every request now pays the full hashing cost, the endpoint needs rate limiting; that is the trade the decoy makes, not an optional extra.

Considerations

  • Establish whether the comparison can fail open before estimating severity, and note that PHP's answer is the opposite of Java's and C#'s. A loose comparison matches too much, never too little, so the dangerous use is an allowlist - a permitted-role list, an active-key list, an accepted-token list - where a value that is not on it compares equal to one that is and gets admitted. A denylist built on the same loose call over-blocks: everything genuinely revoked or reserved is still refused, plus some innocent values, which locks out real users without letting anybody in. That is backwards from a reference-equality bug in Java or C#, where the comparison matches too little and the denylist is the one that silently stops restricting anything. The scanner reports all four identically, so read which way the operator errs before deciding what the finding is worth.
  • Whether the coercion is reachable depends on the alphabet. The string-to-string case needs both operands to parse as numbers, so it applies to hex digests, numeric IDs and codes drawn from [0-9], and not to a base64url token containing - or _. That is a reason to record a specific finding as low severity, not a reason to leave == in place: the alphabet is a property of the current generator, and it changes when someone swaps bin2hex for something else.
  • The type declaration helps, and it is not yours to enforce. A string parameter removes the number-versus-string cases when the caller's file declares strict_types=1; when it does not, PHP casts the argument and the function runs on a value it never asked for. You control the directive in your own files and not in the framework, library or template that calls them, so the declaration is worth adding and cannot be the thing the fix rests on. ===, in_array(..., true) and hash_equals() hold regardless of who called you, which is why they are the primary defence and the type hint is not.
  • The cost factor on a decoy hash has to track the stored hashes. PHP 8.4 raised the PASSWORD_BCRYPT default from 10 to 12, so a decoy generated before that upgrade is now four times faster than a freshly stored hash - an inverted oracle that reads exactly as clearly as the one it replaced. Regenerate the constant whenever the cost changes, or derive it at boot from the same options the application hashes with.

Testing

  • Normal input: verify that valid roles, session tokens, CSRF tokens and passwords still pass their checks after the operator changes. A === where a numeric string was previously coerced can start rejecting a value that used to work.
  • The magic-hash pair: assert that md5('240610708') and md5('QNKCDZO') are refused by the corrected comparison. They compare equal under == and unequal under === and hash_equals(), so this is a single assertion that distinguishes a real fix from a cosmetic one.
  • Boundary values: "", "0", 0, false, null, "0e12345", "1e3". Assert the outcome - that the check refuses them - rather than asserting a TypeError, because whether one is raised depends on the calling file. A test file that declares strict_types=1 will see TypeError for the non-string cases and prove nothing about the framework path, which does not; write one case from a non-strict caller so the coercing path is exercised too.
  • Membership direction: for an allowlist, submit a distinct magic-hash value and assert it is refused; for a denylist, submit both the listed value and a distinct magic-hash value and assert only the listed value is rejected. An in_array() still missing its third argument can pass every ordinary accept test.
  • Unknown versus known username: time the login endpoint for both and assert the difference is within noise. A decoy hash that is malformed, empty or at the wrong cost shows up here as a difference of two orders of magnitude, and nowhere else.

Additional Resources