Skip to content

Commit af7d09b

Browse files
apollo13timgraham
authored andcommitted
[1.9.x] Fixed CVE-2016-2513 -- Fixed user enumeration timing attack during login.
This is a security fix.
1 parent fc6d147 commit af7d09b

File tree

5 files changed

+211
-21
lines changed

5 files changed

+211
-21
lines changed

django/contrib/auth/hashers.py

+57-20
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import binascii
55
import hashlib
66
import importlib
7+
import warnings
78
from collections import OrderedDict
89

910
from django.conf import settings
@@ -46,10 +47,17 @@ def check_password(password, encoded, setter=None, preferred='default'):
4647
preferred = get_hasher(preferred)
4748
hasher = identify_hasher(encoded)
4849

49-
must_update = hasher.algorithm != preferred.algorithm
50-
if not must_update:
51-
must_update = preferred.must_update(encoded)
50+
hasher_changed = hasher.algorithm != preferred.algorithm
51+
must_update = hasher_changed or preferred.must_update(encoded)
5252
is_correct = hasher.verify(password, encoded)
53+
54+
# If the hasher didn't change (we don't protect against enumeration if it
55+
# does) and the password should get updated, try to close the timing gap
56+
# between the work factor of the current encoded password and the default
57+
# work factor.
58+
if not is_correct and not hasher_changed and must_update:
59+
hasher.harden_runtime(password, encoded)
60+
5361
if setter and is_correct and must_update:
5462
setter(password)
5563
return is_correct
@@ -216,6 +224,19 @@ def safe_summary(self, encoded):
216224
def must_update(self, encoded):
217225
return False
218226

227+
def harden_runtime(self, password, encoded):
228+
"""
229+
Bridge the runtime gap between the work factor supplied in `encoded`
230+
and the work factor suggested by this hasher.
231+
232+
Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
233+
`self.iterations` is 30000, this method should run password through
234+
another 10000 iterations of PBKDF2. Similar approaches should exist
235+
for any hasher that has a work factor. If not, this method should be
236+
defined as a no-op to silence the warning.
237+
"""
238+
warnings.warn('subclasses of BasePasswordHasher should provide a harden_runtime() method')
239+
219240

220241
class PBKDF2PasswordHasher(BasePasswordHasher):
221242
"""
@@ -258,6 +279,12 @@ def must_update(self, encoded):
258279
algorithm, iterations, salt, hash = encoded.split('$', 3)
259280
return int(iterations) != self.iterations
260281

282+
def harden_runtime(self, password, encoded):
283+
algorithm, iterations, salt, hash = encoded.split('$', 3)
284+
extra_iterations = self.iterations - int(iterations)
285+
if extra_iterations > 0:
286+
self.encode(password, salt, extra_iterations)
287+
261288

262289
class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
263290
"""
@@ -308,23 +335,8 @@ def encode(self, password, salt):
308335
def verify(self, password, encoded):
309336
algorithm, data = encoded.split('$', 1)
310337
assert algorithm == self.algorithm
311-
bcrypt = self._load_library()
312-
313-
# Hash the password prior to using bcrypt to prevent password truncation
314-
# See: https://code.djangoproject.com/ticket/20138
315-
if self.digest is not None:
316-
# We use binascii.hexlify here because Python3 decided that a hex encoded
317-
# bytestring is somehow a unicode.
318-
password = binascii.hexlify(self.digest(force_bytes(password)).digest())
319-
else:
320-
password = force_bytes(password)
321-
322-
# Ensure that our data is a bytestring
323-
data = force_bytes(data)
324-
# force_bytes() necessary for py-bcrypt compatibility
325-
hashpw = force_bytes(bcrypt.hashpw(password, data))
326-
327-
return constant_time_compare(data, hashpw)
338+
encoded_2 = self.encode(password, force_bytes(data))
339+
return constant_time_compare(encoded, encoded_2)
328340

329341
def safe_summary(self, encoded):
330342
algorithm, empty, algostr, work_factor, data = encoded.split('$', 4)
@@ -341,6 +353,16 @@ def must_update(self, encoded):
341353
algorithm, empty, algostr, rounds, data = encoded.split('$', 4)
342354
return int(rounds) != self.rounds
343355

356+
def harden_runtime(self, password, encoded):
357+
_, data = encoded.split('$', 1)
358+
salt = data[:29] # Length of the salt in bcrypt.
359+
rounds = data.split('$')[2]
360+
# work factor is logarithmic, adding one doubles the load.
361+
diff = 2**(self.rounds - int(rounds)) - 1
362+
while diff > 0:
363+
self.encode(password, force_bytes(salt))
364+
diff -= 1
365+
344366

345367
class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
346368
"""
@@ -388,6 +410,9 @@ def safe_summary(self, encoded):
388410
(_('hash'), mask_hash(hash)),
389411
])
390412

413+
def harden_runtime(self, password, encoded):
414+
pass
415+
391416

392417
class MD5PasswordHasher(BasePasswordHasher):
393418
"""
@@ -416,6 +441,9 @@ def safe_summary(self, encoded):
416441
(_('hash'), mask_hash(hash)),
417442
])
418443

444+
def harden_runtime(self, password, encoded):
445+
pass
446+
419447

420448
class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
421449
"""
@@ -448,6 +476,9 @@ def safe_summary(self, encoded):
448476
(_('hash'), mask_hash(hash)),
449477
])
450478

479+
def harden_runtime(self, password, encoded):
480+
pass
481+
451482

452483
class UnsaltedMD5PasswordHasher(BasePasswordHasher):
453484
"""
@@ -481,6 +512,9 @@ def safe_summary(self, encoded):
481512
(_('hash'), mask_hash(encoded, show=3)),
482513
])
483514

515+
def harden_runtime(self, password, encoded):
516+
pass
517+
484518

485519
class CryptPasswordHasher(BasePasswordHasher):
486520
"""
@@ -515,3 +549,6 @@ def safe_summary(self, encoded):
515549
(_('salt'), salt),
516550
(_('hash'), mask_hash(data, show=3)),
517551
])
552+
553+
def harden_runtime(self, password, encoded):
554+
pass

docs/releases/1.8.10.txt

+33
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@ redirecting to this URL sends the user to ``attacker.com``.
2222
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
2323
targets and puts such a URL into a link, they could suffer from an XSS attack.
2424

25+
CVE-2016-2513: User enumeration through timing difference on password hasher work factor upgrade
26+
================================================================================================
27+
28+
In each major version of Django since 1.6, the default number of iterations for
29+
the ``PBKDF2PasswordHasher`` and its subclasses has increased. This improves
30+
the security of the password as the speed of hardware increases, however, it
31+
also creates a timing difference between a login request for a user with a
32+
password encoded in an older number of iterations and login request for a
33+
nonexistent user (which runs the default hasher's default number of iterations
34+
since Django 1.6).
35+
36+
This only affects users who haven't logged in since the iterations were
37+
increased. The first time a user logs in after an iterations increase, their
38+
password is updated with the new iterations and there is no longer a timing
39+
difference.
40+
41+
The new ``BasePasswordHasher.harden_runtime()`` method allows hashers to bridge
42+
the runtime gap between the work factor (e.g. iterations) supplied in existing
43+
encoded passwords and the default work factor of the hasher. This method
44+
is implemented for ``PBKDF2PasswordHasher`` and ``BCryptPasswordHasher``.
45+
The number of rounds for the latter hasher hasn't changed since Django 1.4, but
46+
some projects may subclass it and increase the work factor as needed.
47+
48+
A warning will be emitted for any :ref:`third-party password hashers that don't
49+
implement <write-your-own-password-hasher>` a ``harden_runtime()`` method.
50+
51+
If you have different password hashes in your database (such as SHA1 hashes
52+
from users who haven't logged in since the default hasher switched to PBKDF2
53+
in Django 1.4), the timing difference on a login request for these users may be
54+
even greater and this fix doesn't remedy that difference (or any difference
55+
when changing hashers). You may be able to :ref:`upgrade those hashes
56+
<wrapping-password-hashers>` to prevent a timing attack for that case.
57+
2558
Bugfixes
2659
========
2760

docs/releases/1.9.3.txt

+33
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@ redirecting to this URL sends the user to ``attacker.com``.
2222
Also, if a developer relies on ``is_safe_url()`` to provide safe redirect
2323
targets and puts such a URL into a link, they could suffer from an XSS attack.
2424

25+
CVE-2016-2513: User enumeration through timing difference on password hasher work factor upgrade
26+
================================================================================================
27+
28+
In each major version of Django since 1.6, the default number of iterations for
29+
the ``PBKDF2PasswordHasher`` and its subclasses has increased. This improves
30+
the security of the password as the speed of hardware increases, however, it
31+
also creates a timing difference between a login request for a user with a
32+
password encoded in an older number of iterations and login request for a
33+
nonexistent user (which runs the default hasher's default number of iterations
34+
since Django 1.6).
35+
36+
This only affects users who haven't logged in since the iterations were
37+
increased. The first time a user logs in after an iterations increase, their
38+
password is updated with the new iterations and there is no longer a timing
39+
difference.
40+
41+
The new ``BasePasswordHasher.harden_runtime()`` method allows hashers to bridge
42+
the runtime gap between the work factor (e.g. iterations) supplied in existing
43+
encoded passwords and the default work factor of the hasher. This method
44+
is implemented for ``PBKDF2PasswordHasher`` and ``BCryptPasswordHasher``.
45+
The number of rounds for the latter hasher hasn't changed since Django 1.4, but
46+
some projects may subclass it and increase the work factor as needed.
47+
48+
A warning will be emitted for any :ref:`third-party password hashers that don't
49+
implement <write-your-own-password-hasher>` a ``harden_runtime()`` method.
50+
51+
If you have different password hashes in your database (such as SHA1 hashes
52+
from users who haven't logged in since the default hasher switched to PBKDF2
53+
in Django 1.4), the timing difference on a login request for these users may be
54+
even greater and this fix doesn't remedy that difference (or any difference
55+
when changing hashers). You may be able to :ref:`upgrade those hashes
56+
<wrapping-password-hashers>` to prevent a timing attack for that case.
57+
2558
Bugfixes
2659
========
2760

docs/topics/auth/passwords.txt

+31
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ unmentioned algorithms won't be able to upgrade. Hashed passwords will be
195195
updated when increasing (or decreasing) the number of PBKDF2 iterations or
196196
bcrypt rounds.
197197

198+
Be aware that if all the passwords in your database aren't encoded in the
199+
default hasher's algorithm, you may be vulnerable to a user enumeration timing
200+
attack due to a difference between the duration of a login request for a user
201+
with a password encoded in a non-default algorithm and the duration of a login
202+
request for a nonexistent user (which runs the default hasher). You may be able
203+
to mitigate this by :ref:`upgrading older password hashes
204+
<wrapping-password-hashers>`.
205+
198206
.. versionchanged:: 1.9
199207

200208
Passwords updates when changing the number of bcrypt rounds was added.
@@ -288,6 +296,29 @@ Include any other hashers that your site uses in this list.
288296
.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt
289297
.. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/
290298

299+
.. _write-your-own-password-hasher:
300+
301+
Writing your own hasher
302+
-----------------------
303+
304+
.. versionadded:: 1.9.3
305+
306+
If you write your own password hasher that contains a work factor such as a
307+
number of iterations, you should implement a
308+
``harden_runtime(self, password, encoded)`` method to bridge the runtime gap
309+
between the work factor supplied in the ``encoded`` password and the default
310+
work factor of the hasher. This prevents a user enumeration timing attack due
311+
to difference between a login request for a user with a password encoded in an
312+
older number of iterations and a nonexistent user (which runs the default
313+
hasher's default number of iterations).
314+
315+
Taking PBKDF2 as example, if ``encoded`` contains 20,000 iterations and the
316+
hasher's default ``iterations`` is 30,000, the method should run ``password``
317+
through another 10,000 iterations of PBKDF2.
318+
319+
If your hasher doesn't have a work factor, implement the method as a no-op
320+
(``pass``).
321+
291322
Manually managing a user's password
292323
===================================
293324

tests/auth_tests/test_hashers.py

+57-1
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
check_password, get_hasher, identify_hasher, is_password_usable,
1111
make_password,
1212
)
13-
from django.test import SimpleTestCase
13+
from django.test import SimpleTestCase, mock
1414
from django.test.utils import override_settings
1515
from django.utils import six
16+
from django.utils.encoding import force_bytes
1617

1718
try:
1819
import crypt
@@ -209,6 +210,28 @@ def setter(password):
209210
finally:
210211
hasher.rounds = old_rounds
211212

213+
@skipUnless(bcrypt, "bcrypt not installed")
214+
def test_bcrypt_harden_runtime(self):
215+
hasher = get_hasher('bcrypt')
216+
self.assertEqual('bcrypt', hasher.algorithm)
217+
218+
with mock.patch.object(hasher, 'rounds', 4):
219+
encoded = make_password('letmein', hasher='bcrypt')
220+
221+
with mock.patch.object(hasher, 'rounds', 6), \
222+
mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
223+
hasher.harden_runtime('wrong_password', encoded)
224+
225+
# Increasing rounds from 4 to 6 means an increase of 4 in workload,
226+
# therefore hardening should run 3 times to make the timing the
227+
# same (the original encode() call already ran once).
228+
self.assertEqual(hasher.encode.call_count, 3)
229+
230+
# Get the original salt (includes the original workload factor)
231+
algorithm, data = encoded.split('$', 1)
232+
expected_call = (('wrong_password', force_bytes(data[:29])),)
233+
self.assertEqual(hasher.encode.call_args_list, [expected_call] * 3)
234+
212235
def test_unusable(self):
213236
encoded = make_password(None)
214237
self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
@@ -316,6 +339,25 @@ def setter(password):
316339
finally:
317340
hasher.iterations = old_iterations
318341

342+
def test_pbkdf2_harden_runtime(self):
343+
hasher = get_hasher('default')
344+
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
345+
346+
with mock.patch.object(hasher, 'iterations', 1):
347+
encoded = make_password('letmein')
348+
349+
with mock.patch.object(hasher, 'iterations', 6), \
350+
mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
351+
hasher.harden_runtime('wrong_password', encoded)
352+
353+
# Encode should get called once ...
354+
self.assertEqual(hasher.encode.call_count, 1)
355+
356+
# ... with the original salt and 5 iterations.
357+
algorithm, iterations, salt, hash = encoded.split('$', 3)
358+
expected_call = (('wrong_password', salt, 5),)
359+
self.assertEqual(hasher.encode.call_args, expected_call)
360+
319361
def test_pbkdf2_upgrade_new_hasher(self):
320362
hasher = get_hasher('default')
321363
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
@@ -344,6 +386,20 @@ def setter(password):
344386
self.assertTrue(check_password('letmein', encoded, setter))
345387
self.assertTrue(state['upgraded'])
346388

389+
def test_check_password_calls_harden_runtime(self):
390+
hasher = get_hasher('default')
391+
encoded = make_password('letmein')
392+
393+
with mock.patch.object(hasher, 'harden_runtime'), \
394+
mock.patch.object(hasher, 'must_update', return_value=True):
395+
# Correct password supplied, no hardening needed
396+
check_password('letmein', encoded)
397+
self.assertEqual(hasher.harden_runtime.call_count, 0)
398+
399+
# Wrong password supplied, hardening needed
400+
check_password('wrong_password', encoded)
401+
self.assertEqual(hasher.harden_runtime.call_count, 1)
402+
347403
def test_load_library_no_algorithm(self):
348404
with self.assertRaises(ValueError) as e:
349405
BasePasswordHasher()._load_library()

0 commit comments

Comments
 (0)