Fixed CVE-2016-2513 -- Fixed user enumeration timing attack during login.
This is a security fix.
This commit is contained in:
parent
c5544d2892
commit
67b46ba701
|
@ -4,6 +4,7 @@ import base64
|
||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib
|
import importlib
|
||||||
|
import warnings
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -46,10 +47,17 @@ def check_password(password, encoded, setter=None, preferred='default'):
|
||||||
preferred = get_hasher(preferred)
|
preferred = get_hasher(preferred)
|
||||||
hasher = identify_hasher(encoded)
|
hasher = identify_hasher(encoded)
|
||||||
|
|
||||||
must_update = hasher.algorithm != preferred.algorithm
|
hasher_changed = hasher.algorithm != preferred.algorithm
|
||||||
if not must_update:
|
must_update = hasher_changed or preferred.must_update(encoded)
|
||||||
must_update = preferred.must_update(encoded)
|
|
||||||
is_correct = hasher.verify(password, 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:
|
if setter and is_correct and must_update:
|
||||||
setter(password)
|
setter(password)
|
||||||
return is_correct
|
return is_correct
|
||||||
|
@ -216,6 +224,19 @@ class BasePasswordHasher(object):
|
||||||
def must_update(self, encoded):
|
def must_update(self, encoded):
|
||||||
return False
|
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):
|
class PBKDF2PasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -258,6 +279,12 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
|
||||||
algorithm, iterations, salt, hash = encoded.split('$', 3)
|
algorithm, iterations, salt, hash = encoded.split('$', 3)
|
||||||
return int(iterations) != self.iterations
|
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):
|
class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -305,23 +332,8 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
|
||||||
def verify(self, password, encoded):
|
def verify(self, password, encoded):
|
||||||
algorithm, data = encoded.split('$', 1)
|
algorithm, data = encoded.split('$', 1)
|
||||||
assert algorithm == self.algorithm
|
assert algorithm == self.algorithm
|
||||||
bcrypt = self._load_library()
|
encoded_2 = self.encode(password, force_bytes(data))
|
||||||
|
return constant_time_compare(encoded, encoded_2)
|
||||||
# Hash the password prior to using bcrypt to prevent password
|
|
||||||
# truncation as described in #20138.
|
|
||||||
if self.digest is not None:
|
|
||||||
# Use binascii.hexlify() because a hex encoded bytestring is
|
|
||||||
# Unicode on Python 3.
|
|
||||||
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)
|
|
||||||
|
|
||||||
def safe_summary(self, encoded):
|
def safe_summary(self, encoded):
|
||||||
algorithm, empty, algostr, work_factor, data = encoded.split('$', 4)
|
algorithm, empty, algostr, work_factor, data = encoded.split('$', 4)
|
||||||
|
@ -338,6 +350,16 @@ class BCryptSHA256PasswordHasher(BasePasswordHasher):
|
||||||
algorithm, empty, algostr, rounds, data = encoded.split('$', 4)
|
algorithm, empty, algostr, rounds, data = encoded.split('$', 4)
|
||||||
return int(rounds) != self.rounds
|
return int(rounds) != self.rounds
|
||||||
|
|
||||||
|
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):
|
class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -385,6 +407,9 @@ class SHA1PasswordHasher(BasePasswordHasher):
|
||||||
(_('hash'), mask_hash(hash)),
|
(_('hash'), mask_hash(hash)),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MD5PasswordHasher(BasePasswordHasher):
|
class MD5PasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -413,6 +438,9 @@ class MD5PasswordHasher(BasePasswordHasher):
|
||||||
(_('hash'), mask_hash(hash)),
|
(_('hash'), mask_hash(hash)),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
|
class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -445,6 +473,9 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
|
||||||
(_('hash'), mask_hash(hash)),
|
(_('hash'), mask_hash(hash)),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UnsaltedMD5PasswordHasher(BasePasswordHasher):
|
class UnsaltedMD5PasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -478,6 +509,9 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher):
|
||||||
(_('hash'), mask_hash(encoded, show=3)),
|
(_('hash'), mask_hash(encoded, show=3)),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CryptPasswordHasher(BasePasswordHasher):
|
class CryptPasswordHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
|
@ -512,3 +546,6 @@ class CryptPasswordHasher(BasePasswordHasher):
|
||||||
(_('salt'), salt),
|
(_('salt'), salt),
|
||||||
(_('hash'), mask_hash(data, show=3)),
|
(_('hash'), mask_hash(data, show=3)),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def harden_runtime(self, password, encoded):
|
||||||
|
pass
|
||||||
|
|
|
@ -22,6 +22,39 @@ redirecting to this URL sends the user to ``attacker.com``.
|
||||||
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
|
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
|
||||||
targets and puts such a URL into a link, they could suffer from an XSS attack.
|
targets and puts such a URL into a link, they could suffer from an XSS attack.
|
||||||
|
|
||||||
|
CVE-2016-2513: User enumeration through timing difference on password hasher work factor upgrade
|
||||||
|
================================================================================================
|
||||||
|
|
||||||
|
In each major version of Django since 1.6, the default number of iterations for
|
||||||
|
the ``PBKDF2PasswordHasher`` and its subclasses has increased. This improves
|
||||||
|
the security of the password as the speed of hardware increases, however, it
|
||||||
|
also creates a timing difference between a login request for a user with a
|
||||||
|
password encoded in an older number of iterations and login request for a
|
||||||
|
nonexistent user (which runs the default hasher's default number of iterations
|
||||||
|
since Django 1.6).
|
||||||
|
|
||||||
|
This only affects users who haven't logged in since the iterations were
|
||||||
|
increased. The first time a user logs in after an iterations increase, their
|
||||||
|
password is updated with the new iterations and there is no longer a timing
|
||||||
|
difference.
|
||||||
|
|
||||||
|
The new ``BasePasswordHasher.harden_runtime()`` method allows hashers to bridge
|
||||||
|
the runtime gap between the work factor (e.g. iterations) supplied in existing
|
||||||
|
encoded passwords and the default work factor of the hasher. This method
|
||||||
|
is implemented for ``PBKDF2PasswordHasher`` and ``BCryptPasswordHasher``.
|
||||||
|
The number of rounds for the latter hasher hasn't changed since Django 1.4, but
|
||||||
|
some projects may subclass it and increase the work factor as needed.
|
||||||
|
|
||||||
|
A warning will be emitted for any :ref:`third-party password hashers that don't
|
||||||
|
implement <write-your-own-password-hasher>` a ``harden_runtime()`` method.
|
||||||
|
|
||||||
|
If you have different password hashes in your database (such as SHA1 hashes
|
||||||
|
from users who haven't logged in since the default hasher switched to PBKDF2
|
||||||
|
in Django 1.4), the timing difference on a login request for these users may be
|
||||||
|
even greater and this fix doesn't remedy that difference (or any difference
|
||||||
|
when changing hashers). You may be able to :ref:`upgrade those hashes
|
||||||
|
<wrapping-password-hashers>` to prevent a timing attack for that case.
|
||||||
|
|
||||||
Bugfixes
|
Bugfixes
|
||||||
========
|
========
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,39 @@ redirecting to this URL sends the user to ``attacker.com``.
|
||||||
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
|
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
|
||||||
targets and puts such a URL into a link, they could suffer from an XSS attack.
|
targets and puts such a URL into a link, they could suffer from an XSS attack.
|
||||||
|
|
||||||
|
CVE-2016-2513: User enumeration through timing difference on password hasher work factor upgrade
|
||||||
|
================================================================================================
|
||||||
|
|
||||||
|
In each major version of Django since 1.6, the default number of iterations for
|
||||||
|
the ``PBKDF2PasswordHasher`` and its subclasses has increased. This improves
|
||||||
|
the security of the password as the speed of hardware increases, however, it
|
||||||
|
also creates a timing difference between a login request for a user with a
|
||||||
|
password encoded in an older number of iterations and login request for a
|
||||||
|
nonexistent user (which runs the default hasher's default number of iterations
|
||||||
|
since Django 1.6).
|
||||||
|
|
||||||
|
This only affects users who haven't logged in since the iterations were
|
||||||
|
increased. The first time a user logs in after an iterations increase, their
|
||||||
|
password is updated with the new iterations and there is no longer a timing
|
||||||
|
difference.
|
||||||
|
|
||||||
|
The new ``BasePasswordHasher.harden_runtime()`` method allows hashers to bridge
|
||||||
|
the runtime gap between the work factor (e.g. iterations) supplied in existing
|
||||||
|
encoded passwords and the default work factor of the hasher. This method
|
||||||
|
is implemented for ``PBKDF2PasswordHasher`` and ``BCryptPasswordHasher``.
|
||||||
|
The number of rounds for the latter hasher hasn't changed since Django 1.4, but
|
||||||
|
some projects may subclass it and increase the work factor as needed.
|
||||||
|
|
||||||
|
A warning will be emitted for any :ref:`third-party password hashers that don't
|
||||||
|
implement <write-your-own-password-hasher>` a ``harden_runtime()`` method.
|
||||||
|
|
||||||
|
If you have different password hashes in your database (such as SHA1 hashes
|
||||||
|
from users who haven't logged in since the default hasher switched to PBKDF2
|
||||||
|
in Django 1.4), the timing difference on a login request for these users may be
|
||||||
|
even greater and this fix doesn't remedy that difference (or any difference
|
||||||
|
when changing hashers). You may be able to :ref:`upgrade those hashes
|
||||||
|
<wrapping-password-hashers>` to prevent a timing attack for that case.
|
||||||
|
|
||||||
Bugfixes
|
Bugfixes
|
||||||
========
|
========
|
||||||
|
|
||||||
|
|
|
@ -186,6 +186,14 @@ unmentioned algorithms won't be able to upgrade. Hashed passwords will be
|
||||||
updated when increasing (or decreasing) the number of PBKDF2 iterations or
|
updated when increasing (or decreasing) the number of PBKDF2 iterations or
|
||||||
bcrypt rounds.
|
bcrypt rounds.
|
||||||
|
|
||||||
|
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>`.
|
||||||
|
|
||||||
.. versionchanged:: 1.9
|
.. versionchanged:: 1.9
|
||||||
|
|
||||||
Passwords updates when changing the number of bcrypt rounds was added.
|
Passwords updates when changing the number of bcrypt rounds was added.
|
||||||
|
@ -310,6 +318,29 @@ The corresponding algorithm names are:
|
||||||
* ``unsalted_md5``
|
* ``unsalted_md5``
|
||||||
* ``crypt``
|
* ``crypt``
|
||||||
|
|
||||||
|
.. _write-your-own-password-hasher:
|
||||||
|
|
||||||
|
Writing your own hasher
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. versionadded:: 1.9.3
|
||||||
|
|
||||||
|
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
|
Manually managing a user's password
|
||||||
===================================
|
===================================
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,10 @@ from django.contrib.auth.hashers import (
|
||||||
check_password, get_hasher, identify_hasher, is_password_usable,
|
check_password, get_hasher, identify_hasher, is_password_usable,
|
||||||
make_password,
|
make_password,
|
||||||
)
|
)
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase, mock
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import crypt
|
import crypt
|
||||||
|
@ -214,6 +215,28 @@ class TestUtilsHashPass(SimpleTestCase):
|
||||||
finally:
|
finally:
|
||||||
hasher.rounds = old_rounds
|
hasher.rounds = old_rounds
|
||||||
|
|
||||||
|
@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):
|
def test_unusable(self):
|
||||||
encoded = make_password(None)
|
encoded = make_password(None)
|
||||||
self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
|
self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
|
||||||
|
@ -337,6 +360,25 @@ class TestUtilsHashPass(SimpleTestCase):
|
||||||
finally:
|
finally:
|
||||||
hasher.iterations = old_iterations
|
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):
|
def test_pbkdf2_upgrade_new_hasher(self):
|
||||||
hasher = get_hasher('default')
|
hasher = get_hasher('default')
|
||||||
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
|
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
|
||||||
|
@ -365,6 +407,20 @@ class TestUtilsHashPass(SimpleTestCase):
|
||||||
self.assertTrue(check_password('letmein', encoded, setter))
|
self.assertTrue(check_password('letmein', encoded, setter))
|
||||||
self.assertTrue(state['upgraded'])
|
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):
|
def test_load_library_no_algorithm(self):
|
||||||
with self.assertRaises(ValueError) as e:
|
with self.assertRaises(ValueError) as e:
|
||||||
BasePasswordHasher()._load_library()
|
BasePasswordHasher()._load_library()
|
||||||
|
|
Loading…
Reference in New Issue