247CTF // Zero Effort - PHP Loose Comparison on Hashes
247CTF // Zero Effort - PHP Loose Comparison on Hashes
Category: type juggling, crypto comparison
Stack: PHP, md5(), ==
- Read the source: the comparison is
==and the target hash starts0efollowed by only digits - Recognize the magic-hash pattern - two
0e[digits]strings compare as 0.0 == 0.0 in PHP - Brute-force offline: hash
salt + candidateand keep any digest matching^0e[0-9]+$ - Short passwords fall in seconds - a wordlist accelerates further
- 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_equalsis strict - string equality, no numeric coercion, so
"0e..." never equals anything but itself.
hash_equalsis 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. Check the comparison operator -
==confirms the class instantly. - 2. Brute-force the *pattern*, not the preimage: any digest
0e[0-9]+wins. - 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
Post a Comment