-
Notifications
You must be signed in to change notification settings - Fork 7.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
crypt: Fix validation of malformed BCrypt hashes
PHP’s implementation of crypt_blowfish differs from the upstream Openwall version by adding a “PHP Hack”, which allows one to cut short the BCrypt salt by including a `$` character within the characters that represent the salt. Hashes that are affected by the “PHP Hack” may erroneously validate any password as valid when used with `password_verify` and when comparing the return value of `crypt()` against the input. The PHP Hack exists since the first version of PHP’s own crypt_blowfish implementation that was added in 1e820ec. No clear reason is given for the PHP Hack’s existence. This commit removes it, because BCrypt hashes containing a `$` character in their salt are not valid BCrypt hashes.
- Loading branch information
Showing
2 changed files
with
82 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
--TEST-- | ||
bcrypt correctly rejects salts containing $ | ||
--FILE-- | ||
<?php | ||
for ($i = 0; $i < 23; $i++) { | ||
$salt = '$2y$04$' . str_repeat('0', $i) . '$'; | ||
$result = crypt("foo", $salt); | ||
var_dump($salt); | ||
var_dump($result); | ||
var_dump($result === $salt); | ||
} | ||
?> | ||
--EXPECT-- | ||
string(8) "$2y$04$$" | ||
string(2) "*0" | ||
bool(false) | ||
string(9) "$2y$04$0$" | ||
string(2) "*0" | ||
bool(false) | ||
string(10) "$2y$04$00$" | ||
string(2) "*0" | ||
bool(false) | ||
string(11) "$2y$04$000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(12) "$2y$04$0000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(13) "$2y$04$00000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(14) "$2y$04$000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(15) "$2y$04$0000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(16) "$2y$04$00000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(17) "$2y$04$000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(18) "$2y$04$0000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(19) "$2y$04$00000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(20) "$2y$04$000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(21) "$2y$04$0000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(22) "$2y$04$00000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(23) "$2y$04$000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(24) "$2y$04$0000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(25) "$2y$04$00000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(26) "$2y$04$000000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(27) "$2y$04$0000000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(28) "$2y$04$00000000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(29) "$2y$04$000000000000000000000$" | ||
string(2) "*0" | ||
bool(false) | ||
string(30) "$2y$04$0000000000000000000000$" | ||
string(60) "$2y$04$000000000000000000000u2a2UpVexIt9k3FMJeAVr3c04F5tcI8K" | ||
bool(false) |
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good to see the hack finally removed. Some historical context I could dig up, where I suggested removing the hack in 2009:
https://news-web.php.net/php.internals/44200
https://news-web.php.net/php.internals/44201
https://news-web.php.net/php.internals/44206
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, quick testing suggests that the hack wasn't yet a vulnerability when this code (apparently from Suhosin) was first introduced in PHP 5.3+, but became exposed as a vulnerability via other changes in 5.5+. Versions 5.3.x and 5.4.x appear to either compute classic Unix descrypt hashes (13 chars) for those invalid salt strings (using
$2
as the salt) or pad the bcrypt salts to proper length with$
chars. I did not check where in the code the '$' padding was, I just observed the behavior - maybe there was more of a hack in those versions, whereas that part of the original hack was dropped in an update for 5.5+, making the hack internally inconsistent.c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm surprised that there was not more discussion of the backward breaking fallout of this change.
This now renders passwords that were hashed with this 'hack' un-reproduceable. This means that projects who have this problem cannot validate the old hashes before sending out password reset emails (ie done when the user logs in instead of in bulk).
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@syntaxseed What are those projects?
Wouldn't they previously "erroneously validate any password as valid" (from this commit's description) on PHP 5.5+ and until this commit? If so, that's not acceptable backwards compatibility behavior. Maybe, if many/popular projects are affected, PHP could reintroduce pre-5.5 behavior, but would this really help those projects (I suppose it would only if those hashes were generated on 5.3+ pre-5.5 as well, which is a rather narrow range of PHP versions)?
Can you show an example affected hash and its corresponding password?
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't say much about versions 5.x, but I have an 8.0 project that had legacy users with hashes generated with crypt(). This suddenly stopped working for these users (newer users have password hashes using the password_hash() function).
They didn't erroneously validate if using crypt() to properly compare against the stored hash.
People who followed an example years ago (and still) found on W3Schools to use blowfish with a 21 character salt followed by a $, the hash is reproduced properly in version 8.0.27, but in 8.0.28 and later it generates the
*0
error result.I can't be the only one with a project that used the W3Schools example and was working fine on 8.0.27 and prior.
See example: https://onlinephp.io/c/a7d05
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe the "erroneously validate" concern applies to situations using the password_validate() method. If an app was just using crypt() on the submitted password and comparing to the stored hash... then
*0
would obviously not match the stored hash.c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Testing this at that link, I see that if you shorter the salt further to 19 characters or less, the returned string is just the prefix+salt (no hash), so any password would be "valid".
Per the above, no, it would also apply to
crypt()
, but apparently only for even shorter salts.If
crypt()
were to return*0
, then a valid password would not validate as well - which is precisely the backwards compatibility problem you're facing.Updating your test further to:
Result for 8.0.27:
Result for 8.0.28:
I see that the hash produced by the first
crypt()
call in it wouldn't validate with the second call (where I put the first hash output by the first call on 8.0.27) - the second call returns a different hash even on 8.0.27. So this wouldn't have worked right on 8.0.27 anyway.c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example I currently see there has 22 characters before the
$
, and works the same on 8.0.27 and 8.0.28:c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@solardiz ... I'm not sure what more to say. I have a live project with a database full of password hashes that look like this:
$2a$10$78b367f8d5feaa9b34b61.JKGar4N5ibBXe6lVjdtsLATzqp3UAR.
That used to validate fine and now they don't. And this is the only change I can find in this version that is even remotely connected.
The legacy code parses out the 21 character salt (not including the .) and appends a dollar sign on the end.
It's a relief though if the W3Schools example is fine then hopefully this isn't widespread. 🤞
Thankfully my particular problem resulted in legacy users becoming unable to log in... rather than anyone being able to log in with any password!
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe you should simply remove that legacy code? Let the full 60-character hash string be passed into
crypt()
.c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, while such legacy code was wrong and shouldn't have ever been needed, simply dropping it now isn't expected to make those old hashes work for you, unfortunately - that's based on my test with 8.0.27 above, where a different hash was produced for the truncated salt. So perhaps your hashes in the DB are similarly different. I'm sorry I don't have a good workaround for you.
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or maybe I do. The early
break;
for$
caused a full byte of the salt value to become 0, not just the last character (which encodes only 2 bits). Which is why simply replacing the one$
with a.
doesn't (always) produce the same hash. However, if you also replace the preceding character with a.
, it does! That's because the last two salt characters together exactly correspond to 1 byte (6+2 bits), we got lucky there (this exact matching saved us from the need to calculate the replacement character for this workaround, making it always.
).Result for 8.0.27:
Result for 8.0.28:
https://onlinephp.io?s=s7EvyCjg5UpNzshX0Eguqiwo0VAvSS0uycxLV9dRUFcxSlQxNFAxt0gyNjNPs0gxTUtNTLRMMjZJMjNUUdfUVNBTUIrJU7JW0NdXMDJUSM5ILFIoTswpIctEPT2giRCjjCBGJecXFaUml6SmgA0FAA%2C%2C&v=8.0.28%2C8.0.27
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Holy smokes @solardiz ... that's what I was trying to figure out! 🥇
Thank you so much, this means I can validate users BEFORE performing the password reset/rehash. 🥇
c840f71
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're welcome. This also means you don't need the password reset/rehash - you can update your hash encodings in the database (put the
..
into them where needed) or keep the equivalent magic in code.