Rether / docs
security/web/Type juggling
reference

Type juggling

updated 2026.07.06created 2026.07.06~6 min

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.

// php== coerces, === does not
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 !== bool

Whenever 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:

ComparisonPHP 7PHP 8Why
'abc' == 0truefalsenon-numeric string → 0 (PHP 7 only)
'1abc' == 1truefalseleading numeric prefix 1 (PHP 7 only)
'' == 0truefalseempty string → 0 (PHP 7 only)
'' == nulltruetrueboth coerce to empty
'0' == falsetruetrueboth falsy
'0010e2' == '1e3'truetruetwo numeric strings → 1000 == 1000
'0e123' == '0e456'truetruetwo 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.

AlgorithmInputHash
md52406107080e462097431906509019562988736854
md5QNKCDZO0e830400451993494058024219903391
sha1109324351120e07766915004133176347055865026311692244
// shelltwo different inputs, one 'value'
#php -r 'var_dump(md5("240610708") == md5("QNKCDZO"));'
bool(true)

The classic sink is a password/token check that hashes then compares loosely:

// phpmagic-hash authentication bypass
// $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:

// phpstrcmp() returns NULL on wrong type (PHP 7)
// 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 → true
// phpin_array() and switch use loose comparison by default
in_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:

// jsNode.js — an object masquerading as a 1000-char string
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 avoid switch on 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.

Keyboard

search this vaultK / /
step the sidebarjk
open selected
switch browse mode123
switch vaultgv
toggle themet
close / clearesc
this help?
esc to close