PHP Type Juggling & Loose Comparisons
PHP Type Juggling & Loose Comparisons
The Root Cause
PHP is a weakly typed language. The == operator does not compare types — it converts both operands to a common type first, then compares values. This "type juggling" behavior leads to surprising and exploitable results, especially in authentication and token validation code.
Critical Note: PHP 7 and below are much more vulnerable to type juggling. PHP 8 changed the behavior of == when comparing strings to integers, fixing many classic attacks. Always check the PHP version.
== vs === — The Key Difference
// == (loose) — performs type coercion
var_dump(0 == "a"); // TRUE in PHP 7, FALSE in PHP 8
var_dump(0 == ""); // TRUE in PHP 7, FALSE in PHP 8
var_dump(0 == "0"); // TRUE in both
var_dump("1" == "01"); // TRUE (both numeric strings)
var_dump("10" == "1e1"); // TRUE (1e1 = 10)
var_dump(100 == "1e2"); // TRUE (1e2 = 100)
var_dump(true == "admin"); // TRUE
var_dump(null == false); // TRUE
var_dump(null == 0); // TRUE
// === (strict) — checks type AND value
var_dump(0 === "a"); // FALSE — always safe
var_dump("1" === "01"); // FALSE — different strings
Magic Hashes — The Classic Attack
PHP's loose comparison treats strings starting with 0e followed by digits as scientific notation — i.e., 0 × 10^n = 0. If two different MD5 hashes both start with 0e[digits], they compare as equal!
// "Magic" MD5 hashes — all equal 0 under ==
md5("240610708") = "0e462097431906509019562988736854"
md5("QNKCDZO") = "0e830400451993494058024219903391"
md5("aabg7XSs") = "0e087386482136013740957780965295"
// SHA1 magic hashes
sha1("aaroZmOk") = "0e66507019969427134894567494305185566735"
sha1("aaK1STfY") = "0e76658526655756207688271159624026011393"
// Attack scenario:
$hash = "0e462097431906509019562988736854"; // stored in DB for user
if (md5($_POST['password']) == $hash) { // loose comparison!
// login success
}
// Send password = "QNKCDZO" → md5 = "0e830..." == "0e462..." → TRUE → bypassed!
strcmp() Bypass
In PHP, strcmp() returns 0 if equal, non-zero if different. When an array is passed instead of a string, strcmp() returns NULL — and NULL == 0 is true!
// VULNERABLE
if (strcmp($_POST['password'], $secret) == 0) {
// logged in
}
// Attack: send password as array
// POST: password[]=anything
// strcmp(["anything"], "secret") → NULL → NULL == 0 → TRUE → bypassed!
in_array() Loose Comparison
// in_array() uses == by default
$whitelist = [1, 2, 3, 4];
var_dump(in_array("1 malicious string", $whitelist)); // TRUE! "1 malicious" == 1
// Also dangerous:
$blocked = ["admin", "root"];
var_dump(in_array(0, $blocked)); // TRUE in PHP 7! 0 == "admin" → TRUE
// SAFE — use strict mode (third parameter)
in_array($value, $whitelist, true); // strict comparison
switch() Type Juggling
// switch uses == internally
$role = $_GET['role']; // attacker sends: "0"
switch ($role) {
case "admin":
grant_admin(); // 0 == "admin" → TRUE in PHP 7!
break;
case "user":
grant_user();
break;
}
JSON Deserialization Type Confusion
// JSON decode returns typed values
$data = json_decode('{"admin": true}', true);
$data = json_decode('{"admin": 1}', true);
// Vulnerable check
if ($data['admin'] == "true") { /* true == "true" → TRUE */ }
// Attack via JSON: send {"password": true}
// if (json['password'] == $hash) → true == "0e123..." → TRUE in PHP 7
Prevention
// ALWAYS use strict comparison ===
if ($password === $stored_hash) { }
// ALWAYS use strict in_array
in_array($val, $arr, true);
// Use password_verify() for passwords — never raw == comparison
if (password_verify($_POST['password'], $stored_hash)) { }
// Use hash_equals() for token comparison — also timing-safe
if (hash_equals($token, $_GET['token'])) { }
// strcmp with ===
if (strcmp($a, $b) === 0) { }
Rule: Always use === in security-sensitive comparisons. Never use == to compare passwords, tokens, hashes, or roles. Use password_verify() and hash_equals() where applicable.