[Python-modules-commits] [python-django] 15/19: CVE-2016-2513: Fixed user enumeration timing attack during login

Luke Faraone lfaraone at moszumanska.debian.org
Thu Jul 21 02:34:42 UTC 2016


This is an automated email from the git hooks/post-receive script.

lfaraone pushed a commit to branch debian/jessie
in repository python-django.

commit a471ae74d0b79b8896dc5411f40840ffa1737dc6
Author: Florian Apolloner <florian at apolloner.eu>
Date:   Thu Jul 21 04:33:12 2016 +0200

    CVE-2016-2513: Fixed user enumeration timing attack during login
    
    Origin: upstream, https://github.com/django/django/commit/f4e6e02f7713a6924d16540be279909ff4091eb6
    Forwarded: not-needed
    Reviewed-by: Salvatore Bonaccorso <carnil at debian.org>
    Last-Update: 2016-03-12
    Applied-Upstream: 1.8.10
---
 django/contrib/auth/hashers.py            |  77 ++++++++++++++------
 django/contrib/auth/tests/test_hashers.py |  60 ++++++++++++++++
 docs/topics/auth/passwords.txt            | 113 ++++++++++++++++++++++++++++++
 3 files changed, 230 insertions(+), 20 deletions(-)

diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py
index e459f39..1ad4bcb 100644
--- a/django/contrib/auth/hashers.py
+++ b/django/contrib/auth/hashers.py
@@ -5,6 +5,7 @@ import binascii
 from collections import OrderedDict
 import hashlib
 import importlib
+import warnings
 
 from django.dispatch import receiver
 from django.conf import settings
@@ -55,10 +56,17 @@ def check_password(password, encoded, setter=None, preferred='default'):
     preferred = get_hasher(preferred)
     hasher = identify_hasher(encoded)
 
-    must_update = hasher.algorithm != preferred.algorithm
-    if not must_update:
-        must_update = preferred.must_update(encoded)
+    hasher_changed = hasher.algorithm != preferred.algorithm
+    must_update = hasher_changed or preferred.must_update(encoded)
     is_correct = hasher.verify(password, encoded)
+
+    # If the hasher didn't change (we don't protect against enumeration if it
+    # does) and the password should get updated, try to close the timing gap
+    # between the work factor of the current encoded password and the default
+    # work factor.
+    if not is_correct and not hasher_changed and must_update:
+        hasher.harden_runtime(password, encoded)
+
     if setter and is_correct and must_update:
         setter(password)
     return is_correct
@@ -217,6 +225,19 @@ class BasePasswordHasher(object):
     def must_update(self, encoded):
         return False
 
+    def harden_runtime(self, password, encoded):
+        """
+        Bridge the runtime gap between the work factor supplied in `encoded`
+        and the work factor suggested by this hasher.
+
+        Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
+        `self.iterations` is 30000, this method should run password through
+        another 10000 iterations of PBKDF2. Similar approaches should exist
+        for any hasher that has a work factor. If not, this method should be
+        defined as a no-op to silence the warning.
+        """
+        warnings.warn('subclasses of BasePasswordHasher should provide a harden_runtime() method')
+
 
 class PBKDF2PasswordHasher(BasePasswordHasher):
     """
@@ -259,6 +280,12 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
         algorithm, iterations, salt, hash = encoded.split('$', 3)
         return int(iterations) != self.iterations
 
+    def harden_runtime(self, password, encoded):
+        algorithm, iterations, salt, hash = encoded.split('$', 3)
+        extra_iterations = self.iterations - int(iterations)
+        if extra_iterations > 0:
+            self.encode(password, salt, extra_iterations)
+
 
 class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
     """
@@ -309,23 +336,8 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
     def verify(self, password, encoded):
         algorithm, data = encoded.split('$', 1)
         assert algorithm == self.algorithm
-        bcrypt = self._load_library()
-
-        # Hash the password prior to using bcrypt to prevent password truncation
-        #   See: https://code.djangoproject.com/ticket/20138
-        if self.digest is not None:
-            # We use binascii.hexlify here because Python3 decided that a hex encoded
-            #   bytestring is somehow a unicode.
-            password = binascii.hexlify(self.digest(force_bytes(password)).digest())
-        else:
-            password = force_bytes(password)
-
-        # Ensure that our data is a bytestring
-        data = force_bytes(data)
-        # force_bytes() necessary for py-bcrypt compatibility
-        hashpw = force_bytes(bcrypt.hashpw(password, data))
-
-        return constant_time_compare(data, hashpw)
+        encoded_2 = self.encode(password, force_bytes(data))
+        return constant_time_compare(encoded, encoded_2)
 
     def safe_summary(self, encoded):
         algorithm, empty, algostr, work_factor, data = encoded.split('$', 4)
@@ -338,6 +350,16 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
             (_('checksum'), mask_hash(checksum)),
         ])
 
+    def harden_runtime(self, password, encoded):
+        _, data = encoded.split('$', 1)
+        salt = data[:29]  # Length of the salt in bcrypt.
+        rounds = data.split('$')[2]
+        # work factor is logarithmic, adding one doubles the load.
+        diff = 2**(self.rounds - int(rounds)) - 1
+        while diff > 0:
+            self.encode(password, force_bytes(salt))
+            diff -= 1
+
 
 class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
     """
@@ -385,6 +407,9 @@ class SHA1PasswordHasher(BasePasswordHasher):
             (_('hash'), mask_hash(hash)),
         ])
 
+    def harden_runtime(self, password, encoded):
+        pass
+
 
 class MD5PasswordHasher(BasePasswordHasher):
     """
@@ -413,6 +438,9 @@ class MD5PasswordHasher(BasePasswordHasher):
             (_('hash'), mask_hash(hash)),
         ])
 
+    def harden_runtime(self, password, encoded):
+        pass
+
 
 class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
     """
@@ -445,6 +473,9 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
             (_('hash'), mask_hash(hash)),
         ])
 
+    def harden_runtime(self, password, encoded):
+        pass
+
 
 class UnsaltedMD5PasswordHasher(BasePasswordHasher):
     """
@@ -478,6 +509,9 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher):
             (_('hash'), mask_hash(encoded, show=3)),
         ])
 
+    def harden_runtime(self, password, encoded):
+        pass
+
 
 class CryptPasswordHasher(BasePasswordHasher):
     """
@@ -512,3 +546,6 @@ class CryptPasswordHasher(BasePasswordHasher):
             (_('salt'), salt),
             (_('hash'), mask_hash(data, show=3)),
         ])
+
+    def harden_runtime(self, password, encoded):
+        pass
diff --git a/django/contrib/auth/tests/test_hashers.py b/django/contrib/auth/tests/test_hashers.py
index 85f1c15..3a06b43 100644
--- a/django/contrib/auth/tests/test_hashers.py
+++ b/django/contrib/auth/tests/test_hashers.py
@@ -9,7 +9,12 @@ from django.contrib.auth.hashers import (is_password_usable, BasePasswordHasher,
     get_hasher, identify_hasher, UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH)
 from django.test import SimpleTestCase
 from django.utils import six
+from django.utils.encoding import force_bytes
 
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 
 try:
     import crypt
@@ -176,6 +181,28 @@ class TestUtilsHashPass(SimpleTestCase):
         self.assertTrue(check_password('', blank_encoded))
         self.assertFalse(check_password(' ', blank_encoded))
 
+    @skipUnless(bcrypt, "bcrypt not installed")
+    def test_bcrypt_harden_runtime(self):
+        hasher = get_hasher('bcrypt')
+        self.assertEqual('bcrypt', hasher.algorithm)
+
+        with mock.patch.object(hasher, 'rounds', 4):
+            encoded = make_password('letmein', hasher='bcrypt')
+
+        with mock.patch.object(hasher, 'rounds', 6), \
+                mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
+            hasher.harden_runtime('wrong_password', encoded)
+
+            # Increasing rounds from 4 to 6 means an increase of 4 in workload,
+            # therefore hardening should run 3 times to make the timing the
+            # same (the original encode() call already ran once).
+            self.assertEqual(hasher.encode.call_count, 3)
+
+            # Get the original salt (includes the original workload factor)
+            algorithm, data = encoded.split('$', 1)
+            expected_call = (('wrong_password', force_bytes(data[:29])),)
+            self.assertEqual(hasher.encode.call_args_list, [expected_call] * 3)
+
     def test_unusable(self):
         encoded = make_password(None)
         self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
@@ -283,6 +310,25 @@ class TestUtilsHashPass(SimpleTestCase):
         finally:
             hasher.iterations = old_iterations
 
+    def test_pbkdf2_harden_runtime(self):
+        hasher = get_hasher('default')
+        self.assertEqual('pbkdf2_sha256', hasher.algorithm)
+
+        with mock.patch.object(hasher, 'iterations', 1):
+            encoded = make_password('letmein')
+
+        with mock.patch.object(hasher, 'iterations', 6), \
+                mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
+            hasher.harden_runtime('wrong_password', encoded)
+
+            # Encode should get called once ...
+            self.assertEqual(hasher.encode.call_count, 1)
+
+            # ... with the original salt and 5 iterations.
+            algorithm, iterations, salt, hash = encoded.split('$', 3)
+            expected_call = (('wrong_password', salt, 5),)
+            self.assertEqual(hasher.encode.call_args, expected_call)
+
     def test_pbkdf2_upgrade_new_hasher(self):
         self.assertEqual('pbkdf2_sha256', get_hasher('default').algorithm)
         hasher = get_hasher('default')
@@ -311,6 +357,20 @@ class TestUtilsHashPass(SimpleTestCase):
             self.assertTrue(check_password('letmein', encoded, setter))
             self.assertTrue(state['upgraded'])
 
+    def test_check_password_calls_harden_runtime(self):
+        hasher = get_hasher('default')
+        encoded = make_password('letmein')
+
+        with mock.patch.object(hasher, 'harden_runtime'), \
+                mock.patch.object(hasher, 'must_update', return_value=True):
+            # Correct password supplied, no hardening needed
+            check_password('letmein', encoded)
+            self.assertEqual(hasher.harden_runtime.call_count, 0)
+
+            # Wrong password supplied, hardening needed
+            check_password('wrong_password', encoded)
+            self.assertEqual(hasher.harden_runtime.call_count, 1)
+
     def test_load_library_no_algorithm(self):
         with self.assertRaises(ValueError) as e:
             BasePasswordHasher()._load_library()
diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt
index f635f88..79eea5b 100644
--- a/docs/topics/auth/passwords.txt
+++ b/docs/topics/auth/passwords.txt
@@ -189,12 +189,125 @@ unmentioned algorithms won't be able to upgrade.
 
     Passwords will be upgraded when changing the PBKDF2 iteration count.
 
+Be aware that if all the passwords in your database aren't encoded in the
+default hasher's algorithm, you may be vulnerable to a user enumeration timing
+attack due to a difference between the duration of a login request for a user
+with a password encoded in a non-default algorithm and the duration of a login
+request for a nonexistent user (which runs the default hasher). You may be able
+to mitigate this by :ref:`upgrading older password hashes
+<wrapping-password-hashers>`.
+
+.. _wrapping-password-hashers:
+
+Password upgrading without requiring a login
+--------------------------------------------
+
+If you have an existing database with an older, weak hash such as MD5 or SHA1,
+you might want to upgrade those hashes yourself instead of waiting for the
+upgrade to happen when a user logs in (which may never happen if a user doesn't
+return to your site). In this case, you can use a "wrapped" password hasher.
+
+For this example, we'll migrate a collection of SHA1 hashes to use
+PBKDF2(SHA1(password)) and add the corresponding password hasher for checking
+if a user entered the correct password on login. We assume we're using the
+built-in ``User`` model and that our project has an ``accounts`` app. You can
+modify the pattern to work with any algorithm or with a custom user model.
+
+First, we'll add the custom hasher:
+
+.. snippet::
+    :filename: accounts/hashers.py
+
+    from django.contrib.auth.hashers import (
+        PBKDF2PasswordHasher, SHA1PasswordHasher,
+    )
+
+
+    class PBKDF2WrappedSHA1PasswordHasher(PBKDF2PasswordHasher):
+        algorithm = 'pbkdf2_wrapped_sha1'
+
+        def encode_sha1_hash(self, sha1_hash, salt, iterations=None):
+            return super(PBKDF2WrappedSHA1PasswordHasher, self).encode(sha1_hash, salt, iterations)
+
+        def encode(self, password, salt, iterations=None):
+            _, _, sha1_hash = SHA1PasswordHasher().encode(password, salt).split('$', 2)
+            return self.encode_sha1_hash(sha1_hash, salt, iterations)
+
+The data migration might look something like:
+
+.. snippet::
+    :filename: accounts/migrations/0002_migrate_sha1_passwords.py
+
+    from django.db import migrations
+
+    from ..hashers import PBKDF2WrappedSHA1PasswordHasher
+
+
+    def forwards_func(apps, schema_editor):
+        User = apps.get_model('auth', 'User')
+        users = User.objects.filter(password__startswith='sha1$')
+        hasher = PBKDF2WrappedSHA1PasswordHasher()
+        for user in users:
+            algorithm, salt, sha1_hash = user.password.split('$', 2)
+            user.password = hasher.encode_sha1_hash(sha1_hash, salt)
+            user.save(update_fields=['password'])
+
+
+    class Migration(migrations.Migration):
+
+        dependencies = [
+            ('accounts', '0001_initial'),
+            # replace this with the latest migration in contrib.auth
+            ('auth', '####_migration_name'),
+        ]
+
+        operations = [
+            migrations.RunPython(forwards_func),
+        ]
+
+Be aware that this migration will take on the order of several minutes for
+several thousand users, depending on the speed of your hardware.
+
+Finally, we'll add a :setting:`PASSWORD_HASHERS` setting:
+
+.. snippet::
+    :filename: mysite/settings.py
+
+    PASSWORD_HASHERS = [
+        'django.contrib.auth.hashers.PBKDF2PasswordHasher',
+        'accounts.hashers.PBKDF2WrappedSHA1PasswordHasher',
+    ]
+
+Include any other hashers that your site uses in this list.
+
 .. _sha1: http://en.wikipedia.org/wiki/SHA1
 .. _pbkdf2: http://en.wikipedia.org/wiki/PBKDF2
 .. _nist: http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
 .. _bcrypt: http://en.wikipedia.org/wiki/Bcrypt
 .. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/
 
+.. _write-your-own-password-hasher:
+
+Writing your own hasher
+-----------------------
+
+.. versionadded:: 1.8.10
+
+If you write your own password hasher that contains a work factor such as a
+number of iterations, you should implement a
+``harden_runtime(self, password, encoded)`` method to bridge the runtime gap
+between the work factor supplied in the ``encoded`` password and the default
+work factor of the hasher. This prevents a user enumeration timing attack due
+to  difference between a login request for a user with a password encoded in an
+older number of iterations and a nonexistent user (which runs the default
+hasher's default number of iterations).
+
+Taking PBKDF2 as example, if ``encoded`` contains 20,000 iterations and the
+hasher's default ``iterations`` is 30,000, the method should run ``password``
+through another 10,000 iterations of PBKDF2.
+
+If your hasher doesn't have a work factor, implement the method as a no-op
+(``pass``).
 
 Manually managing a user's password
 ===================================

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/python-django.git



More information about the Python-modules-commits mailing list