Problem/Motivation
Currently the equality operator is used throughout Drupal core when hash values are compared. As pointed out elsewhere this can result in false positives under certain circumstances. This can be the case when a hashing operation results in values which are interpreted as a number by PHP (type juggling).
The following grep turns up all relevant hash/token comparisons in the code base excluding vendor directory and clear false positives (for example from the text token replacement system, aka token module):
$ find . -type f -and -not -name '*.js' -and -not -name '*.tokens.inc' -and -not -name 'token.api.php' -and -not -name PoHeader.php -and -not -path '*/vendor/*' | xargs git grep '[^=!]==[^=]' | grep -E 'token|hash' | cat -n
1 core/lib/Drupal/Core/Password/PhpassHashedPassword.php: return ($hash && $stored_hash == $hash);
2 core/modules/toolbar/src/Controller/ToolbarController.php: return AccessResult::allowedIf($this->currentUser()->hasPermission('access toolbar') && $hash == _toolbar_get_subtrees_hash($langcode))->cachePerPermissions();
3 core/modules/user/src/AccountForm.php: $user_pass_reset = $pass_reset = isset($_SESSION['pass_reset_' . $account->id()]) && (\Drupal::request()->query->get('pass-reset-token') == $_SESSION['pass_reset_' . $account->id()]);
4 core/modules/user/src/Controller/UserController.php: if ($timestamp <= $current && $current - $timestamp < $timeout && $user->id() && $timestamp >= $user->getLastLoginTime() && $hashed_pass == user_pass_rehash($user->getPassword(), $timestamp, $user->getLastLoginTime(), $user->id())) {
Proposed resolution
- Introduce a new static method
\Drupal\Component\Utility\Crypt::hashEquals()wrapping around the constant time hash_equals() function. - Replace all relevant hash/token comparisons with the new
Crypt::hashEquals()method.
Remaining tasks
Decide whether to use a constant time fallback ifhash_equals()is not available (instead of===)Implement- Review
User interface changes
None.
API changes
API addition (Crypt::hashEquals()).
Beta phase evaluation
| Issue category | Bug because using == for hash comparison can cause incorrect results and using === can cause timing attacks. |
|---|---|
| Issue priority | Major, because because while we are not currently aware of exploitable vulnerabilities, this is an important security hardening. |
| Prioritized changes | The main goal of this issue is security |
| Disruption | Not disruptive for core/contributed and custom modules/themes. |
Original report
As of PHP 5.6, the function hash_equals() was added to help prevent timing attack security issues.
All this does is provide a string comparison for the use inside of php.
I have made a patch that adds forward compatibility by checking the php version constants.
see: http://us3.php.net/manual/en/function.hash-equals.php
see: http://us3.php.net/manual/en/migration56.new-features.php
<?php
// use more secure hash_equal() that was introduced in php 5.6 and greater.
// see: http://php.net/manual/en/function.hash-equals.php.
if (PHP_MAJOR_VERSION > 4 && PHP_MINOR_VERSION > 5) {
return ($hash && hash_equals($stored_hash, $hash));
}
?>I am checking the php constants instead of using a function_exists() to prevent the possibility of some attacker implementing their own hash_equals() function for php versions < 5.6.
| Comment | File | Size | Author |
|---|---|---|---|
| #76 | harden_the_security-2400197-76.patch | 8.11 KB | neclimdul |
| #75 | harden_the_security-2400197-75.patch | 8.19 KB | neclimdul |
| #72 | harden_the_security-2400197-72.patch | 8.1 KB | mpdonadio |
| #65 | 2400197-65.patch | 8.09 KB | pwolanin |
| #55 | interdiff-2400197-49-55.txt | 1.14 KB | fgm |
Comments
Comment #1
thekevinday commentedComment #2
dcam commentedThe same, old comparison check exists in D8's PhpassHashedPassword::check() method. I'm bumping the version. Also, if this really is a security improvement then this seems like less of a feature request and more of a task to me.
Comment #3
thekevinday commented'vy' should be 'by'.
Comment #4
benjy commentedHow about adding a hash_equals() function to core wrapped in a function_exists() check? That way, we can use this function in all the places as appropriate rather than having both the hash_equals() and the normal checks throughout core.
Comment #5
benjy commentedAlso, not sure if it's an option but Symfony has a constant time equality implementation: https://github.com/symfony/security-core/blob/master/Util/StringUtils.ph... which uses hash_equals() if it exists.
Could we use that class without bringing the entire authentication package in?
Comment #6
znerol commentedAdded issue summary and adapted the scope of this issue.
@thekevinday: Thanks for reporting this issue. If you are willing to work on it and update your patches, then please assign the issue to you.
Comment #7
znerol commentedPlease use
function_exists(). If an attacker has access to the source code or is capable of exploiting a persistent remote code execution vulnerability, then a site has much more serious issues than a function being injected here.Comment #8
David_Rothstein commentedComment #9
znerol commentedComment #10
znerol commentedComment #12
neclimdul!is_string?
Comment #14
znerol commentedRight.
Comment #15
pere orgaComment #16
pwolanin commentedI'm not sure I fully agree with the proposed resolution here.
clearly using === is essential in any case where the hash might start with a digit.
A constant time comparison is only important in cases where we directly compare user input to a secret like a CSRF token.
for passwords, the inclusion of a salt makes the hashed value of a plain-text password unknown to the attacker, so cases like these I don't see that constant time matters (though I guess it doesn't hurt either).
Comment #17
sarciszewski commented...
U$ === Unsalted MD5 passwords. If you build a dictionary and send a bunch of guess login attempts, you can actually leak the valid MD5 hash from the timing difference in your failed authentication attempts and use this to optimize your password cracking efforts and intelligently reduce the number of online guesses.
Implementing hash_equals() prevents the leak.
Comment #18
sarciszewski commentedRegarding the patch: I would encourage preventing mbstring.func_overload from causing strlen() to treat the input strings as Unicode and decide to not evaluate every byte.
e.g. https://github.com/symfony/symfony/pull/13984/files
Comment #19
pwolanin commented@sarciszewski - re: U$ === Unsalted MD5 passwords.
No, that's not TRUE, rather the unstalted MD5 was then hashed again with a salt and stretched hash.
Comment #20
sarciszewski commentedI'm gonna be honest here: I don't see why we're not just using password_hash() and password_verify() facilitated through password_compat for PHP 5.4.x users.
Comment #21
znerol commentedRe #18: Drupal refuses to run/install if
mbstring.func_overloadis enabled.Re #20: see #1845004: Replace custom password hashing library with PHP password_hash()
Comment #22
znerol commentedRe #16:
Agreed. I'd prefer to still use a constant-time check here for consistency reasons. Also because this is what's implement inPHP 5.5 and password_compat.
Comment #23
pwolanin commentedrebuild.php may also have a token comparison that needs to be addressed
Comment #24
pwolanin commentedre-roll for conflict in core/lib/Drupal/Core/Password/PhpassHashedPassword.php
Comment #25
pwolanin commentedAdd the comparison in rebuild.php
Comment #26
pwolanin commentedDo we need a test of the compatibility code?
Comment #27
klausiSo this should also use the hashEquals() method?
I don't think we can do a meaningful test case for the compatibility code? You would have to do a timing test which sounds like random test fails to me. And the correctness of the comparison implementation is demonstrated by lots of places that are using it and that are covered with integration tests already.
Comment #28
pwolanin commentedYes, the attack surface here is small since you'd have to get a logged-in user's account with an active token in the session to guess it from the URL, but doesn't hurt.
Comment #31
pwolanin commentedMoving this to a bug since using == instead of === can cause incorrect results.
Comment #32
pwolanin commentedre-roll for change to user_pass_rehash() in #2508627: Changing email address should invalidate one-time login links
Comment #33
fabianx commentedRTBC, but major in my book.
Comment #35
fabianx commentedPlease do not re-test #32, lets upload a new patch instead.
was one of the failures (just for documentation purposes).
Comment #36
pwolanin commentedLooks like something broke on the bot - I can't reproduce that failure locally.
Re-posting the patch so people can find the sporadic fail to investigate later.
Comment #40
fabianx commentedComment #41
pwolanin commentedreroll
Comment #42
fabianx commentedComment #44
fabianx commentedComment #45
mpdonadioThe patch won't apply because of #2217985: Replace the custom menu caching strategy in Toolbar with Core's standard caching..
Specifically
I think this is the right merge.
Comment #46
fgmIssue #2292835: Mitigate the risk of remote timing attacks on password authentication was just closed as a duplicate of this one, but its patch included extra crypto sanitizing, using an initialization vector and urandom fallback.
I think it probably makes sense to merge that patch into this one. Here is the current patch on the other issue, for comparison.
Comment #48
fabianx commentedYes, lets incorporate that as its a good hardening related to this change.
Comment #49
fgmAdapted patch on top of previous one.
Comment #50
fabianx commentedShould that not use Crypt::hashEquals?
Comment #51
fgmNot too sure about that. AIUI, the purpose in both cases is to avoid timing attacks. In this approach, comparing strings hashed with a nonce cancels the meaning of any timing difference on the strings, so a simple === is sufficient; while Crypt::hashEquals avoids the timing difference by doing the comparison in near-constant time for a given known string.
So it seems combining both is useless, the level of protection against timing attacks is similar, and Crypt::hashEquals() is probably faster, especially with PHP 5.6 and above. Now, if a security expert could chime in on this...
Comment #52
znerol commentedExactly. I think
Crypt::hashEqualsis preferable because it fixes the problem with the least amount of effort (and also POLA).Comment #53
znerol commentedComment #54
fgm@znerol : is it CNW just to remove this bit about the comparison, or something else ?
Comment #55
fgmIf it's the only reason, here is the rerolled patch.
Comment #56
fabianx commentednit - extra new lines, could be fixed on commit.
--
RTBC
Comment #57
fgmAbout nits: actually, these added new lines in
check()have been added per our coding standards, which specify a blank line after a break : https://www.drupal.org/coding-standards#controlstructComment #58
alexpottGiven the importance of using Crypt::hashEquals() I think we should have a CR telling people about this.
Comment #59
subhojit777Change record added https://www.drupal.org/node/2545966
Comment #60
fabianx commentedBack to RTBC - totally forgot about this one.
Comment #63
fabianx commentedComment #64
pwolanin commentedI'm trying a re-roll now
Comment #65
pwolanin commentedsimple change in the use statements, patch command was able to apply everything else.
Comment #66
chx commentedThe tags, the issue summary , the title and the patch is not in harmony. If you'd be worried about hash being numeric and == being stupid then you'd simply use === which I believe is the correct fix for this issue Why we are worried about timing attacks and wasting code and time to mitigate when Drupal page never ever take the exact same time to load. Timing attacks work for simpler script but for Drupal? meh. Just use ===.
Comment #67
pwolanin commented@chx there are actually at least theoretical attacks here like in the password reset link.
I would agree that a bunch of these are bogus/pointless, especially the PhpassHashedPassword one, but we should at least fix all to be ===
Comment #68
fabianx commentedBack to RTBC, tweaked IS a little
Comment #69
gregglesIf there is a downside to using hash_equals then we could consider using === in the cases where it doesn't help. However, for the sake of security researchers/evaluators reviewing Drupal's code I think it makes sense to use hash_equals wherever possible. It's harder for them to know if a case doesn't truly need hash_equals or not so they are likely to flag === as a problem even when it's not.
Comment #71
fabianx commentedNeeds another reroll
Comment #72
mpdonadioReroll b/c #2571647: Create DateFormatterInterface
Comment #73
fabianx commentedComment #75
neclimdulreroll #2533800: Remove all unused use statements from core. don't need credit.
Comment #76
neclimdulespecially when i mess up the reroll.
Comment #78
chx commentedComment #80
catchComment #81
effulgentsia commentedI discussed this with @xjm and hardening security with no BC break is definitely good "rc target" material, so tagging.
Comment #82
alexpott@greggles argument in #69 is persuasive about being consistent even if timing attacks aren't possible.
Committed 6dd4b18 and pushed to 8.0.x. Thanks!
Comment #84
arla commentedNow I'm getting "User warning: Expected user_string to be a string, NULL given in Drupal\Component\Utility\Crypt::hashEquals() (line 159 of core/lib/Drupal/Component/Utility/Crypt.php)" on user/1/edit.
Shouldn't we check that get('pass-reset-token') is not empty, before calling Crypt::hashEquals()?
Comment #85
alexpott@Arla what are the steps to reproduce - just going to user/1/edit does not do it for me.
Comment #87
David_Rothstein commentedThis is marked for Drupal 7 backport.
(Not sure if there's anything left to do for Drupal 8 based on the last couple comments, but currently assuming not.)
Comment #88
arla commentedI quickly looked into this again. I'm not sure what the determining conditions were at the time when I posted #84, but I just found a way to reproduce:
This really seems like an edge case, but to my understanding we should avoid such a warning in any case.
Comment #90
bradjones1I was able to reproduce the regression in #88 and opened a new ticket, #2624986: Fix regression from #2400197, user edit form expects password reset hash, with a patch against D8.
Comment #95
znerol commentedSorta follow-up #2822499: CsrfTokenGenerator should use timing attack safe string comparison
Comment #99
quietone commentedThis was committed
Comment #101
xjmRestoring credits.