Since this is a "learning challenge", I'll present the answer as a "guide" to finding the answer, instead of just straight up telling you what the solution is.
You were actually very close to finding the solution, to the point where you identified all the components necessary for exploitation. You seem to miss just the final step, so let's walk through it step by step.
Password Reset
When I perform a source code review or penetration test, I always look out for "weirdness". Code, that doesn't quite seem standard. In this case, what first tripped me up was the reset()
function. Let's have a look at the rough structure here. I'll abbreviate it so we can see the relevant details.
// $username is fully in our control
private function reset($username) {
$user = getUserForUsername($username)
if($user) {
$token = generateToken()
$user->resetToken = $token
$user->passwordHash = 0
sendResetLink($user->email, $user->resetToken)
}
}
Doesn't it seem weird, that the password is reset the moment we request a password reset, rather than once the email is confirmed? That means, as soon as we know a username (in our case memtash
), we can reset that user's password hash to 0.
Login
So now, you've reached the point where you want to login. Let's look at that code now, which I will abbreviate again:
// $username and $password are fully under our control
private function login($username, $password) {
$user = getUserForUsername($username)
if($user && $user->passwordHash == sha1($password)) {
win();
}
}
This is a remarkably small function, and you are right in suspecting that the sha1()
function should be manipulated somehow.
Your intuition of making it either return false
or 00000000000000000000000000000000
is on the right track - painfully close, in fact. But let's have a look at the official documentation:
Return Values
Returns the sha1 hash as a string.
That means that the sha1()
function will always return a hash as a string, and never false
.
But what about an all-zero hash? In theory, you could find an input that hashes to all-zeroes. In practice, none have been found (or at least, made public).
So that is no good. However, there is something else we can exploit: Type Juggling
Type Juggling
Type Juggling is a bug feature in PHP, which automatically treats types as other types, supposedly to "aid" developers in doing what they "meant" to do, but most often introducing bugs, that are very hard to find unless you know where to look.
PHP has two different operators to compare for equality, ==
and ===
. For the sake of readability, I will call the former "weak comparison" and the latter "strict comparison".
When performing a strict comparison, PHP first checks if the types of both variables are the same, before actually checking for equality. That means something like <int> === <string>
will always be false, because integers are not strings, regardless of their values.
When performing weak comparison, PHP will perform Type Juggling, and bizarrely, will try to cast everything to an integer, if possible.
For some of these, this almost makes sense. For example, 0 == "0"
should evaluate to true, which it does. But PHP's "string to integer" conversion rules are also very strange. For example, the string "123abc"
evaluates to the integer 123
, at least in PHP 5.
What does all of this mean? We need to find a string, which - when converted to an integer by PHP - would evaluate to 0
.
Exponential Notation
Luckily, PHP supports exponential notation, such as 1.23e4
to represent 1.23 * 10^4 (1.23 * 10*10*10*10), or 12300. So what would 0e4
be? 0 * 10^4, which is 0.
In fact, regardless of which number we place after the exponent, it will always evaluate to 0. So "0e29823758752983759283750" would also be 0.
Now we're getting closer to the answer. We need to find a SHA-1 hash, which has the following format: 1
- 1st Digit: 0
- 2nd Digit: e
- 3rd to 40th Digit: [0-9]
You have several ways of finding inputs that generate such hashes. You can either write a script to brute-force them, or you can just google for them. One such input would be 10932435112
. This would actually be remarkably fast to brute force. You can see that the result of sha1("10932435112")
is "0e07766915004133176347055865026311692244"
- which fits perfectly.
And indeed, if you write var_dump(0 == sha1("10932435112"))
, the result will be true
. Magical! Hence why these are called "magic hashes".
A Recap
This may have gone a bit fast, so I will try to recap each step a bit more succinctly:
First, we notice that we can reset the hash of the password to 0 when we call the reset function.
Next, we realize that the relevant code, that checks for successful login is $hash == sha1($input)
.
Because $hash
has been set to 0, we need to find a SHA-1 hash, which evaluates to 0.
Because of the weak comparison, PHP will try to convert string inputs (such as the SHA-1 hash) to integers. Furthermore, PHP will honor "exponential notation" when evaluating integers.
We know that, in exponential notation, 0 times any number is 0. Therefore, we need to find a hash, which begins with 0e
followed by only digits.
This an input, which generates such a hash, can either be brute-forced, or looked up. When looking it up, you will find that sha1("10932435112") == "0e07766915004133176347055865026311692244"
When comparing 0 == "0e07766915004133176347055865026311692244"
, you will find that all PHP versions evalue this to true.
1: In PHP 5, it's even easier, since any "non-digit" after the exponent is simpy ignored, so "0e1foobar..." would also evaluate to 0. But by using only digits, the exploit works in PHP 7 and beyond as well.