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 CreditAttribution: thekevinday commentedComment #2
dcam CreditAttribution: 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 CreditAttribution: thekevinday commented'vy' should be 'by'.
Comment #4
benjy CreditAttribution: benjy at CodeDrop 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 CreditAttribution: benjy at CodeDrop 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 CreditAttribution: 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 CreditAttribution: 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 CreditAttribution: David_Rothstein as a volunteer commentedComment #9
znerol CreditAttribution: znerol commentedComment #10
znerol CreditAttribution: znerol commentedComment #12
neclimdul!is_string?
Comment #14
znerol CreditAttribution: znerol commentedRight.
Comment #15
Pere OrgaComment #16
pwolanin CreditAttribution: 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 CreditAttribution: 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 CreditAttribution: 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 CreditAttribution: 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 CreditAttribution: 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 CreditAttribution: znerol commentedRe #18: Drupal refuses to run/install if
mbstring.func_overload
is enabled.Re #20: see #1845004: Replace custom password hashing library with PHP password_hash()
Comment #22
znerol CreditAttribution: 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 CreditAttribution: pwolanin commentedrebuild.php may also have a token comparison that needs to be addressed
Comment #24
pwolanin CreditAttribution: pwolanin as a volunteer commentedre-roll for conflict in core/lib/Drupal/Core/Password/PhpassHashedPassword.php
Comment #25
pwolanin CreditAttribution: pwolanin as a volunteer commentedAdd the comparison in rebuild.php
Comment #26
pwolanin CreditAttribution: pwolanin as a volunteer 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 CreditAttribution: pwolanin as a volunteer 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 CreditAttribution: pwolanin as a volunteer commentedMoving this to a bug since using == instead of === can cause incorrect results.
Comment #32
pwolanin CreditAttribution: pwolanin as a volunteer commentedre-roll for change to user_pass_rehash() in #2508627: Changing email address should invalidate one-time login links
Comment #33
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedRTBC, but major in my book.
Comment #35
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedPlease do not re-test #32, lets upload a new patch instead.
was one of the failures (just for documentation purposes).
Comment #36
pwolanin CreditAttribution: pwolanin as a volunteer 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedComment #41
pwolanin CreditAttribution: pwolanin as a volunteer commentedreroll
Comment #42
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedComment #44
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: znerol commentedExactly. I think
Crypt::hashEquals
is preferable because it fixes the problem with the least amount of effort (and also POLA).Comment #53
znerol CreditAttribution: 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedBack to RTBC - totally forgot about this one.
Comment #63
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedComment #64
pwolanin CreditAttribution: pwolanin as a volunteer commentedI'm trying a re-roll now
Comment #65
pwolanin CreditAttribution: pwolanin as a volunteer commentedsimple change in the use statements, patch command was able to apply everything else.
Comment #66
chx CreditAttribution: 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 CreditAttribution: pwolanin as a volunteer 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting commentedNeeds another reroll
Comment #72
mpdonadioReroll b/c #2571647: Create DateFormatterInterface
Comment #73
Fabianx CreditAttribution: Fabianx as a volunteer and at Tag1 Consulting 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 CreditAttribution: chx commentedComment #80
catchComment #81
effulgentsia CreditAttribution: effulgentsia at Acquia 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
ArlaNow 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 CreditAttribution: David_Rothstein as a volunteer 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
ArlaI 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 CreditAttribution: znerol commentedSorta follow-up #2822499: CsrfTokenGenerator should use timing attack safe string comparison