Skip to content

Commit 67b46ba

Browse files
apollo13timgraham
authored andcommitted
Fixed CVE-2016-2513 -- Fixed user enumeration timing attack during login.
This is a security fix.
1 parent c5544d2 commit 67b46ba

File tree

5 files changed

+211
-21
lines changed

5 files changed

+211
-21
lines changed

django/contrib/auth/hashers.py

Lines changed: 57 additions & 20 deletions
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
"""
@@ -305,23 +332,8 @@ def encode(self, password, salt):
305332
def verify(self, password, encoded):
306333
algorithm, data = encoded.split('$', 1)
307334
assert algorithm == self.algorithm
308-
bcrypt = self._load_library()
309-
310-
# Hash the password prior to using bcrypt to prevent password
311-
# truncation as described in #20138.
312-
if self.digest is not None:
313-
# Use binascii.hexlify() because a hex encoded bytestring is
314-
# Unicode on Python 3.
315-
password = binascii.hexlify(self.digest(force_bytes(password)).digest())
316-
else:
317-
password = force_bytes(password)
318-
319-
# Ensure that our data is a bytestring
320-
data = force_bytes(data)
321-
# force_bytes() necessary for py-bcrypt compatibility
322-
hashpw = force_bytes(bcrypt.hashpw(password, data))
323-
324-
return constant_time_compare(data, hashpw)
335+
encoded_2 = self.encode(password, force_bytes(data))
336+
return constant_time_compare(encoded, encoded_2)
325337

326338
def safe_summary(self, encoded):
327339
algorithm, empty, algostr, work_factor, data = encoded.split('$', 4)
@@ -338,6 +350,16 @@ def must_update(self, encoded):
338350
algorithm, empty, algostr, rounds, data = encoded.split('$', 4)
339351
return int(rounds) != self.rounds
340352

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

342364
class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
343365
"""
@@ -385,6 +407,9 @@ def safe_summary(self, encoded):
385407
(_('hash'), mask_hash(hash)),
386408
])
387409

410+
def harden_runtime(self, password, encoded):
411+
pass
412+
388413

389414
class MD5PasswordHasher(BasePasswordHasher):
390415
"""
@@ -413,6 +438,9 @@ def safe_summary(self, encoded):
413438
(_('hash'), mask_hash(hash)),
414439
])
415440

441+
def harden_runtime(self, password, encoded):
442+
pass
443+
416444

417445
class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
418446
"""
@@ -445,6 +473,9 @@ def safe_summary(self, encoded):
445473
(_('hash'), mask_hash(hash)),
446474
])
447475

476+
def harden_runtime(self, password, encoded):
477+
pass
478+
448479

449480
class UnsaltedMD5PasswordHasher(BasePasswordHasher):
450481
"""
@@ -478,6 +509,9 @@ def safe_summary(self, encoded):
478509
(_('hash'), mask_hash(encoded, show=3)),
479510
])
480511

512+
def harden_runtime(self, password, encoded):
513+
pass
514+
481515

482516
class CryptPasswordHasher(BasePasswordHasher):
483517
"""
@@ -512,3 +546,6 @@ def safe_summary(self, encoded):
512546
(_('salt'), salt),
513547
(_('hash'), mask_hash(data, show=3)),
514548
])
549+
550+
def harden_runtime(self, password, encoded):
551+
pass

docs/releases/1.8.10.txt

Lines changed: 33 additions & 0 deletions
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

Lines changed: 33 additions & 0 deletions
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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ unmentioned algorithms won't be able to upgrade. Hashed passwords will be
186186
updated when increasing (or decreasing) the number of PBKDF2 iterations or
187187
bcrypt rounds.
188188

189+
Be aware that if all the passwords in your database aren't encoded in the
190+
default hasher's algorithm, you may be vulnerable to a user enumeration timing
191+
attack due to a difference between the duration of a login request for a user
192+
with a password encoded in a non-default algorithm and the duration of a login
193+
request for a nonexistent user (which runs the default hasher). You may be able
194+
to mitigate this by :ref:`upgrading older password hashes
195+
<wrapping-password-hashers>`.
196+
189197
.. versionchanged:: 1.9
190198

191199
Passwords updates when changing the number of bcrypt rounds was added.
@@ -310,6 +318,29 @@ The corresponding algorithm names are:
310318
* ``unsalted_md5``
311319
* ``crypt``
312320

321+
.. _write-your-own-password-hasher:
322+
323+
Writing your own hasher
324+
-----------------------
325+
326+
.. versionadded:: 1.9.3
327+
328+
If you write your own password hasher that contains a work factor such as a
329+
number of iterations, you should implement a
330+
``harden_runtime(self, password, encoded)`` method to bridge the runtime gap
331+
between the work factor supplied in the ``encoded`` password and the default
332+
work factor of the hasher. This prevents a user enumeration timing attack due
333+
to difference between a login request for a user with a password encoded in an
334+
older number of iterations and a nonexistent user (which runs the default
335+
hasher's default number of iterations).
336+
337+
Taking PBKDF2 as example, if ``encoded`` contains 20,000 iterations and the
338+
hasher's default ``iterations`` is 30,000, the method should run ``password``
339+
through another 10,000 iterations of PBKDF2.
340+
341+
If your hasher doesn't have a work factor, implement the method as a no-op
342+
(``pass``).
343+
313344
Manually managing a user's password
314345
===================================
315346

tests/auth_tests/test_hashers.py

Lines changed: 57 additions & 1 deletion
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
@@ -214,6 +215,28 @@ def setter(password):
214215
finally:
215216
hasher.rounds = old_rounds
216217

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

363+
def test_pbkdf2_harden_runtime(self):
364+
hasher = get_hasher('default')
365+
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
366+
367+
with mock.patch.object(hasher, 'iterations', 1):
368+
encoded = make_password('letmein')
369+
370+
with mock.patch.object(hasher, 'iterations', 6), \
371+
mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
372+
hasher.harden_runtime('wrong_password', encoded)
373+
374+
# Encode should get called once ...
375+
self.assertEqual(hasher.encode.call_count, 1)
376+
377+
# ... with the original salt and 5 iterations.
378+
algorithm, iterations, salt, hash = encoded.split('$', 3)
379+
expected_call = (('wrong_password', salt, 5),)
380+
self.assertEqual(hasher.encode.call_args, expected_call)
381+
340382
def test_pbkdf2_upgrade_new_hasher(self):
341383
hasher = get_hasher('default')
342384
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
@@ -365,6 +407,20 @@ def setter(password):
365407
self.assertTrue(check_password('letmein', encoded, setter))
366408
self.assertTrue(state['upgraded'])
367409

410+
def test_check_password_calls_harden_runtime(self):
411+
hasher = get_hasher('default')
412+
encoded = make_password('letmein')
413+
414+
with mock.patch.object(hasher, 'harden_runtime'), \
415+
mock.patch.object(hasher, 'must_update', return_value=True):
416+
# Correct password supplied, no hardening needed
417+
check_password('letmein', encoded)
418+
self.assertEqual(hasher.harden_runtime.call_count, 0)
419+
420+
# Wrong password supplied, hardening needed
421+
check_password('wrong_password', encoded)
422+
self.assertEqual(hasher.harden_runtime.call_count, 1)
423+
368424
def test_load_library_no_algorithm(self):
369425
with self.assertRaises(ValueError) as e:
370426
BasePasswordHasher()._load_library()

0 commit comments

Comments
 (0)