In PHP, how can comparing a hash with == (or in_array without strict) let an attacker bypass a check, and what is the fix?
answer
- two numeric strings compare as numbers
- '0e...' hex hashes juggle to zero
- 0e123 == 0e456 is true
- in_array default strict is false
- use hash_equals or ===
basics
~20 sSome md5/sha1 outputs are '0e' followed by digits; under == PHP treats two such hashes as the number 0, so different inputs compare equal — a 'magic hash' bypass PHP 8.0 did not change. Compare with hash_equals(), or === and in_array(..., true).
solid answer
~50 sWhen both operands of `==` are **numeric strings**, PHP compares them **as numbers**, and PHP 8.0's comparison reform left that rule untouched. A hex hash like `md5($x)` that happens to match `^0e\d+$` is a numeric string in exponent form, so it juggles to `0.0e0 == 0`. Two such hashes — `"0e462..."` and `"0e830..."` — are therefore **equal under `==`** even though the strings differ; these are **magic hashes**. If code checks `md5($input) == $stored`, an attacker can supply an input whose hash is a `0e` string and match any stored `0e` hash without knowing the secret. `in_array($needle, $set)` has the same trap because its **`strict` parameter defaults to `false`**, so it compares with `==`. The fix: use `hash_equals($known, $user)` for token/hash checks (it is also constant-time), and use `===` and `in_array($needle, $set, true)` everywhere else so type is compared and no juggling occurs.
go deeper
Recall that comparing a hash with === (or hash_equals) is required, and == can be bypassed.
Explain that two numeric '0e' strings juggle to 0 under ==, that in_array defaults to loose, and that PHP 8.0 did not change the numeric-string case.
Spot a magic-hash bypass in a token/login check under load, prove it with sample inputs, and fix it with hash_equals or strict comparison across the codebase.
Set a lint/static-analysis rule banning == on secrets and default in_array on hashes, and reason about residual risk from third-party comparisons.
## The mechanism, from the comparison rules PHP's `==` performs **type juggling** before comparing. One rule dominates here: *if both operands are numeric strings, compare them as numbers.* A string like `"1e3"` is numeric (exponent notation), so `"1e3" == "1000"` is true. Crucially, PHP 8.0 changed only the *number-vs-non-numeric-string* case (`0 == "foo"` became false); **two numeric strings still compare numerically on PHP 8.5**. Hex hash digests are strings of `0-9a-f`. A subset of them match `^0e\d+$` — a `0`, an `e`, then only digits — which PHP reads as *zero times ten to some power*, i.e. `0`. So: - `md5("240610708")` is `"0e462097431906509019562988736854"` → numeric, value `0` - `md5("QNKCDZO")` is `"0e830400451993494058024219903391"` → numeric, value `0` - therefore `md5("240610708") == md5("QNKCDZO")` is **true** These are **magic hashes**. Any two `0e`-form digests are equal under `==`, and there are known short inputs that produce them for md5 and sha1. ## Where it becomes an auth bypass Consider a token or password-hash check written loosely: ```php if (md5($_POST['token']) == $storedTokenHash) { grantAccess(); } ``` If `$storedTokenHash` is itself a magic hash, an attacker submits any input whose md5 is also a `0e` string (e.g. `240610708`) and the comparison returns true — no knowledge of the real token. The same defect appears in: - **`in_array($userValue, $validHashes)`** — `in_array`'s third parameter `strict` **defaults to `false`**, so it uses `==`. A magic-hash needle matches a magic-hash entry. `array_search` shares the default. - **`switch ($hash) { case '0e123...': }`** — `switch` compares loosely, so a magic-hash subject can hit a magic-hash case. ## Why PHP 8.0 did not save you | Comparison | PHP 7.x | PHP 8.5 | |---|---|---| | `0 == "foo"` | true | **false** (the 8.0 fix) | | `"0e1" == "0e2"` | true | **true** (both numeric strings) | | `"1e3" == "1000"` | true | **true** | | `in_array("0e1", ["0e2"])` | true | **true** (loose default) | The 8.0 change is real but narrow: it only affects a *number against a non-numeric string*. Magic hashes are two *numeric strings*, so the rule that catches them was never touched. Believing "PHP 8 fixed loose comparison" is the classic senior-level mistake here. ## The fixes 1. **`hash_equals($knownString, $userString)`** for comparing hashes, MACs, or tokens. It returns a bool, does a **byte-for-byte, length-aware, constant-time** comparison, and is immune to juggling. (The constant-time and CSRF-token details belong to the passwords/CSRF leaves; here the point is that it compares as bytes.) 2. **`===`** for any exact-value check, so type must match and no conversion happens. 3. **`in_array($needle, $set, true)`** and `array_search($needle, $set, true)` — always pass the strict flag. 4. **Don't hand-roll password checks at all** — `password_verify()` does the right comparison internally. Storing raw `md5()` for passwords is a separate, worse problem (owned by the passwords leaf); the loose-comparison bug is orthogonal and bites even with a strong hash if compared with `==`. The full `==` rule set is owned by the operators leaf; what matters at this sink is that a secret or hash compared with `==` (or a default `in_array`) is a bypass primitive, and `hash_equals`/`===`/strict are the PHP calls that close it.
- Does using SHA-256 instead of md5 remove the magic-hash risk?It shrinks it — `0e`-form outputs are rarer for longer digests — but it does not remove it in principle, and it misses the point: the bug is the *loose comparison*, not the algorithm. Compare with `hash_equals` or `===` and any digest is safe; keep `==` and even a strong hash can be bypassed if a `0e` collision exists.
- Why is `in_array` a hidden version of this bug?Because `in_array($needle, $haystack)` defaults `strict` to `false`, so it compares each element with `==`. A magic-hash needle matches a magic-hash element just as `==` would. Always call `in_array($needle, $haystack, true)` when the values are hashes, tokens, or anything type-sensitive.
- Isn't hash_equals only for timing attacks?Constant-time comparison is its headline purpose, but because it compares byte strings and returns false on any length or content difference, it also sidesteps type juggling entirely — so it fixes the magic-hash bypass as a side effect. For secret comparisons it is the right call on both counts.
saying these in an interview costs you the question
- PHP 8.0 fixed loose comparison so magic hashes no longer work
- == compares hashes as strings, so different strings are never equal
- in_array is strict by default
- switching md5 to sha256 fully removes the bypass
- hash_equals is only about timing, not type juggling
- '0e123' == '0e456' is false because the strings differ