Type juggling
PHP is a loosely typed language, which means it tries to predict the programmer's intent and automatically converts variables to different types whenever it seems necessary. For example, a string containing only numbers can be treated as an integer or a float. However, this automatic conversion (or type juggling) can lead to unexpected results, especially when comparing variables using the '==' operator, which only checks for value equality (loose comparison), not type and value equality (strict comparison).[1]
PHP is not the only language that coerces types during comparison. The trap
shows up anywhere ==-style equality silently casts its operands: PHP,
JavaScript / Node.js, Perl, and SQL engines that implicitly cast
(MySQL / MariaDB, Postgres, SQLite). Note the distinction — a
dynamically typed language is not automatically a juggling one. Python is
dynamically typed but strongly typed: 0 == "abc" is simply False, no
coercion. The bug is about the comparison rules, not about types being dynamic.
The examples below are PHP (where the tables and magic hashes are canonical), with a short parallel for Node.js and a link to a worked case study.
Loose vs strict comparison
Loose comparison (==, !=) checks only that the two operands have the same
value after coercion. Strict comparison (===, !==) checks value and
type, with no coercion. The vulnerability class exists entirely in the gap
between them.
var_dump('123' == 123); // bool(true) — string coerced to int
var_dump('0' == false); // bool(true) — both coerce to falsy
var_dump('123' === 123); // bool(false) — string !== int
var_dump('0' === false);// bool(false) — string !== boolWhenever an attacker controls one side of a == and the code assumes a type it
never enforced, the comparison can be steered to true.
The coercion rules
Under PHP 7 and earlier, a non-numeric string compared against a number is cast
to 0, and a "leading-numeric" string keeps only its numeric prefix. That makes
a surprising set of comparisons return true:
| Comparison | PHP 7 | PHP 8 | Why |
|---|---|---|---|
'abc' == 0 | true | false | non-numeric string → 0 (PHP 7 only) |
'1abc' == 1 | true | false | leading numeric prefix 1 (PHP 7 only) |
'' == 0 | true | false | empty string → 0 (PHP 7 only) |
'' == null | true | true | both coerce to empty |
'0' == false | true | true | both falsy |
'0010e2' == '1e3' | true | true | two numeric strings → 1000 == 1000 |
'0e123' == '0e456' | true | true | two numeric strings → 0 == 0 (see magic hash) |
The first three are what PHP 8 fixed.[2] The string-to-string rows are unchanged: when both operands are numeric strings, PHP still compares them as numbers in every version — the detail that keeps magic hashes alive (below).
Magic hashes (0e…)
A string of the form 0e followed by only digits is a valid numeric string:
PHP reads it as scientific notation, 0 × 10ⁿ, which is 0. So two different
hashes that both start with 0e[0-9]+ compare equal under ==, both being
zero.
| Algorithm | Input | Hash |
|---|---|---|
md5 | 240610708 | 0e462097431906509019562988736854 |
md5 | QNKCDZO | 0e830400451993494058024219903391 |
sha1 | 10932435112 | 0e07766915004133176347055865026311692244 |
The classic sink is a password/token check that hashes then compares loosely:
// $stored is a hash that happens to be a 0e-magic value.
if (md5($_POST['password']) == $stored) {
// Any input whose md5 is also 0e[0-9]+ lands here — no password needed.
grant_access();
}Because this is a string == string comparison of two numeric strings, it is not fixed by PHP 8 — the "magic hashes died in PHP 8" claim only holds for comparisons against an integer literal or a non-numeric string. The real fix is a type-aware, constant-time compare (see defence).
Vulnerable sinks
Beyond raw ==, several PHP functions coerce or misbehave on unexpected types:
// Passing an array makes strcmp emit a warning and return NULL.
// NULL == 0 is true, so the comparison passes.
if (strcmp($_POST['password'], $real) == 0) { grant_access(); }
// Exploit: password[]=x → strcmp([], $real) === NULL → NULL == 0 → truein_array(0, ['secret']); // PHP 7: true (0 == 'secret'); PHP 8: false
in_array(0, ['secret'], true); // false — strict flag, always safe
switch ($_GET['action']) { // switch compares with ==
case 0: /* 'anything' == 0 was true in PHP 7 */ break;
}In PHP 8, strcmp([], …) throws a TypeError instead of returning NULL,[3] so
that specific bypass is closed; in_array/switch still use loose comparison,
so the strict flag and explicit typing remain necessary.
Array / JSON injection
The exploits above all depend on feeding a type the code never expected. PHP
makes this easy: ?param[]=x turns $_GET['param'] into an array, and any
JSON endpoint accepts arbitrary JSON values. Passing an array (or object) where
the code assumes a string is what triggers the strcmp-NULL and length-coercion
tricks.
The same shape appears in Node.js, where there is no strcmp but the object
model itself is the weapon — a plain JSON object can fake a length and index
its way past a validator:
Array("1000").length; // 1 — string length, not 1000: the loop shrinks to one pass
// { "length": "1000", "0": "a", "999": "a" } walks a palindrome check unharmed.For the full worked exploit — nginx body-size limit plus a loose length gate, defeated by a ~48-byte JSON object — see the palindrome writeup.
Defence
The root cause is trusting a value's type without enforcing it, then comparing loosely. Fixes, in order of preference:
- Compare strictly. Use
===/!==so a type mismatch fails closed. - Compare hashes/MACs with
hash_equals()— type-safe and constant-time, which also defeats timing attacks that==invites. - Never roll your own password check. Use
password_hash()/password_verify(). - Pass the strict flag to
in_array($x, $set, true)(and avoidswitchon attacker-controlled values, or normalise the type first). - Validate the input type at the boundary — reject arrays/objects where a
string is expected (
is_string(), a JSON schema, or an explicit cast) so the wrong type never reaches the comparison.