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 if hash_equals() is not available (instead of ===)
  • Implement
  • Review

User interface changes

None.

API changes

API addition (Crypt::hashEquals()).

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
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.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

thekevinday’s picture

FileSize
571 bytes
dcam’s picture

Version: 7.x-dev » 8.0.x-dev
Category: Feature request » Task

The 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.

thekevinday’s picture

Issue summary: View changes

'vy' should be 'by'.

benjy’s picture

How 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.

benjy’s picture

Also, 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?

znerol’s picture

Title: Support php hash_equals() where possible » Harden the security where hash values are compared
Issue summary: View changes

Added 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.

znerol’s picture

+++ b/includes/password.inc
@@ -258,6 +258,13 @@ function user_check_password($password, $account) {
+  if (PHP_MAJOR_VERSION > 4 && PHP_MINOR_VERSION > 5) {
+    return ($hash && hash_equals($stored_hash, $hash));
+  }

Please 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.

David_Rothstein’s picture

Issue tags: +Needs backport to D7
znerol’s picture

Assigned: Unassigned » znerol
znerol’s picture

Assigned: znerol » Unassigned
Status: Active » Needs review
FileSize
6.66 KB

Status: Needs review » Needs work

The last submitted patch, 10: harden_the_security-2400197-10.patch, failed testing.

neclimdul’s picture

Status: Needs work » Needs review
+++ b/core/lib/Drupal/Component/Utility/Crypt.php
@@ -126,6 +126,49 @@ public static function hashBase64($data) {
+      if (is_string($known_string)) {

!is_string?

The last submitted patch, 1: drupal-7.x-2400197-1.patch, failed testing.

znerol’s picture

Right.

Pere Orga’s picture

Issue tags: +Security improvements
pwolanin’s picture

I'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).

sarciszewski’s picture

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).

    if (substr($account->getPassword(), 0, 2) == 'U$') {
      // This may be an updated password from user_update_7000(). Such hashes
      // have 'U' added as the first character and need an extra md5() (see the
      // Drupal 7 documentation).
      $stored_hash = substr($account->getPassword(), 1);
      $password = md5($password);
    }

...

    return ($hash && $stored_hash == $hash);

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.

sarciszewski’s picture

Regarding 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

pwolanin’s picture

@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.

sarciszewski’s picture

I'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.

znerol’s picture

Re #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()

znerol’s picture

Re #16:

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).

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.

pwolanin’s picture

rebuild.php may also have a token comparison that needs to be addressed

pwolanin’s picture

FileSize
6.69 KB

re-roll for conflict in core/lib/Drupal/Core/Password/PhpassHashedPassword.php

pwolanin’s picture

FileSize
7.33 KB
656 bytes

Add the comparison in rebuild.php

pwolanin’s picture

Do we need a test of the compatibility code?

klausi’s picture

Status: Needs review » Needs work
+++ b/core/modules/user/src/AccountForm.php
@@ -129,7 +129,7 @@ public function form(array $form, FormStateInterface $form_state) {
-        $user_pass_reset = $pass_reset = isset($_SESSION['pass_reset_' . $account->id()]) && (\Drupal::request()->query->get('pass-reset-token') == $_SESSION['pass_reset_' . $account->id()]);
+        $user_pass_reset = isset($_SESSION['pass_reset_' . $account->id()]) && \Drupal::request()->query->get('pass-reset-token') === $_SESSION['pass_reset_' . $account->id()];

So 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.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
7.57 KB
1.13 KB

Yes, 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.

pwolanin queued 28: 2400197-28.patch for re-testing.

Status: Needs review » Needs work

The last submitted patch, 28: 2400197-28.patch, failed testing.

pwolanin’s picture

Category: Task » Bug report
Issue summary: View changes

Moving this to a bug since using == instead of === can cause incorrect results.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
7.36 KB
Fabianx’s picture

Issue summary: View changes
Priority: Normal » Major
Status: Needs review » Reviewed & tested by the community

RTBC, but major in my book.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 32: 2400197-32.patch, failed testing.

Fabianx’s picture

Issue tags: +Random test failure

Please do not re-test #32, lets upload a new patch instead.

simplexml_import_dom(): Invalid Nodetype to importsimplexml_import_dom(Object) Drupal\simpletest\WebTestBase->parse() Drupal\simpletest\WebTestBase->drupalPostForm('contact/foo', Array, 'Send message') Drupal\contact\Tests\ContactSitewideTest->submitContact('qDdLl6fA8qLcWgnC', 'tYdnbM2Ps81P0TGjQ6L0nl2WmnpzM8xH@example.com', 'lhJDK5LPLNFx9hjvQ7QFx4iuEOmQskOCIzIK7GVvUOaPHUqWZbhqAl46gsYkv4Vn', 'foo', '=&}vip7P .IEB+hKcVTYQn?yo6J8{F{( "C#m4yH 86MyS1IMW7mMr&Wy? 6BzmD>&AUu|D3#FLa4K3r&t8^+>|@b**Y2$3k& Cpfm*H6/+=]=q@||Jd"?CP*<3B{vV_') Drupal\contact\Tests\ContactSitewideTest->testAutoReply() Drupal\simpletest\TestBase->run(Array) simpletest_script_run_one_test('213', 'Drupal\contact\Tests\ContactStorageTest')	

was one of the failures (just for documentation purposes).

pwolanin’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
7.36 KB

Looks 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.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 36: 2400197-32.patch, failed testing.

Status: Needs work » Needs review

Fabianx queued 36: 2400197-32.patch for re-testing.

Status: Needs review » Needs work

The last submitted patch, 36: 2400197-32.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs reroll
pwolanin’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
7.4 KB

reroll

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 41: 2400197-41.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs reroll
mpdonadio’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
7.38 KB

The patch won't apply because of #2217985: Replace the custom menu caching strategy in Toolbar with Core's standard caching..

$ git checkout 9024fa61
$ git apply --index 2400197-41.patch
$ git rebase 8.0.x
First, rewinding head to replay your work on top of it...
Applying: 2400197-41.patch
Using index info to reconstruct a base tree...
M	core/modules/toolbar/src/Controller/ToolbarController.php
Falling back to patching base and 3-way merge...
Auto-merging core/modules/toolbar/src/Controller/ToolbarController.php
CONFLICT (content): Merge conflict in core/modules/toolbar/src/Controller/ToolbarController.php
Failed to merge in the changes.
Patch failed at 0001 2400197-41.patch

Specifically

<<<<<<< HEAD
  public function checkSubTreeAccess($hash) {
    return AccessResult::allowedIf($this->currentUser()->hasPermission('access toolbar') && $hash == _toolbar_get_subtrees_hash()[0])->cachePerPermissions();
=======
  public function checkSubTreeAccess($hash, $langcode) {
    $expected_hash = _toolbar_get_subtrees_hash($langcode);
    return AccessResult::allowedIf($this->currentUser()->hasPermission('access toolbar') && Crypt::hashEquals($expected_hash, $hash))->cachePerPermissions();
>>>>>>> 2400197-41.patch

I think this is the right merge.

fgm’s picture

Issue #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.

Status: Needs review » Needs work
Fabianx’s picture

Yes, lets incorporate that as its a good hardening related to this change.

fgm’s picture

Adapted patch on top of previous one.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/Password/PhpassHashedPassword.php
@@ -248,10 +250,14 @@ public function check($password, $hash) {
+    // Mitigate timing attacks.
+    $nonce = Crypt::randomBytes(32);
+    return ($computed_hash && (hash_hmac($hash_type, $stored_hash, $nonce) === hash_hmac($hash_type, $computed_hash, $nonce)));

Should that not use Crypt::hashEquals?

fgm’s picture

Not 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...

znerol’s picture

So it seems combining both is useless

Exactly. I think Crypt::hashEquals is preferable because it fixes the problem with the least amount of effort (and also POLA).

znerol’s picture

Status: Needs review » Needs work
fgm’s picture

@znerol : is it CNW just to remove this bit about the comparison, or something else ?

fgm’s picture

If it's the only reason, here is the rerolled patch.

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community
+++ b/core/lib/Drupal/Core/Password/PhpassHashedPassword.php
@@ -241,6 +241,7 @@ public function check($password, $hash) {
+

@@ -248,10 +249,13 @@ public function check($password, $hash) {
+

nit - extra new lines, could be fixed on commit.

--

RTBC

fgm’s picture

About 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#controlstruct

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record

Given the importance of using Crypt::hashEquals() I think we should have a CR telling people about this.

subhojit777’s picture

Status: Needs work » Needs review
Issue tags: -Needs change record
Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC - totally forgot about this one.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 55: 2400197-hardening-55.patch, failed testing.

The last submitted patch, 55: 2400197-hardening-55.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs reroll
pwolanin’s picture

I'm trying a re-roll now

pwolanin’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
8.09 KB

simple change in the use statements, patch command was able to apply everything else.

chx’s picture

The 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 ===.

pwolanin’s picture

@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 ===

Fabianx’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs issue summary update

Back to RTBC, tweaked IS a little

greggles’s picture

If 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.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 65: 2400197-65.patch, failed testing.

Fabianx’s picture

Issue tags: +Needs reroll

Needs another reroll

mpdonadio’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
FileSize
8.1 KB

Reroll b/c #2571647: Create DateFormatterInterface

$ git checkout 458f5bee7bb4f2715530d56068301b5897c363b3
$ git apply --index 2400197-65.patch 
$ git rebase 8.0.x
First, rewinding head to replay your work on top of it...
Applying: 2400197-65.patch
Using index info to reconstruct a base tree...
M	core/modules/user/src/Controller/UserController.php
Falling back to patching base and 3-way merge...
Auto-merging core/modules/user/src/Controller/UserController.php
Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 72: harden_the_security-2400197-72.patch, failed testing.

neclimdul’s picture

Status: Needs work » Needs review
FileSize
8.19 KB
neclimdul’s picture

especially when i mess up the reroll.

The last submitted patch, 75: harden_the_security-2400197-75.patch, failed testing.

chx’s picture

Status: Needs review » Reviewed & tested by the community

The last submitted patch, 75: harden_the_security-2400197-75.patch, failed testing.

catch’s picture

Issue tags: +rc target triage
effulgentsia’s picture

Issue tags: -rc target triage +rc target

I discussed this with @xjm and hardening security with no BC break is definitely good "rc target" material, so tagging.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

@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!

  • alexpott committed 6dd4b18 on 8.0.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
Arla’s picture

Now 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.

+++ b/core/modules/user/src/AccountForm.php
@@ -127,7 +128,7 @@ public function form(array $form, FormStateInterface $form_state) {
-        $user_pass_reset = $pass_reset = isset($_SESSION['pass_reset_' . $account->id()]) && (\Drupal::request()->query->get('pass-reset-token') == $_SESSION['pass_reset_' . $account->id()]);
+        $user_pass_reset = isset($_SESSION['pass_reset_' . $account->id()]) && Crypt::hashEquals($_SESSION['pass_reset_' . $account->id()], \Drupal::request()->query->get('pass-reset-token'));

Shouldn't we check that get('pass-reset-token') is not empty, before calling Crypt::hashEquals()?

alexpott’s picture

@Arla what are the steps to reproduce - just going to user/1/edit does not do it for me.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

David_Rothstein’s picture

Version: 8.0.x-dev » 7.x-dev
Status: Closed (fixed) » Patch (to be ported)

This 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.)

Arla’s picture

I 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:

  1. Run `drush uli`, open the returned link and click "Log in"
  2. You're now on user/1/edit?pass-reset-token={hash}
  3. Remove the query string (go to user/1/edit)
  4. Receive warning: Expected user_string to be a string, NULL given in Drupal\Component\Utility\Crypt::hashEquals()

This really seems like an edge case, but to my understanding we should avoid such a warning in any case.

  • alexpott committed 6dd4b18 on 8.1.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
bradjones1’s picture

I 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.

  • catch committed bfd7cc2 on 8.2.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...

  • catch committed 7a31523 on 8.1.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...

  • alexpott committed 6dd4b18 on 8.3.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
  • catch committed bfd7cc2 on 8.3.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...

  • alexpott committed 6dd4b18 on 8.3.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
  • catch committed bfd7cc2 on 8.3.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...
znerol’s picture

  • alexpott committed 6dd4b18 on 8.4.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
  • catch committed bfd7cc2 on 8.4.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...

  • alexpott committed 6dd4b18 on 8.4.x
    Issue #2400197 by pwolanin, fgm, znerol, neclimdul, mpdonadio,...
  • catch committed bfd7cc2 on 8.4.x
    Issue #2624986 by Arla, heykarthikwithu, bradjones1, kristofferwiklund:...