Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f4e6e02

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

File tree

4 files changed

+177
-21
lines changed

4 files changed

+177
-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
"""
@@ -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)
@@ -337,6 +349,16 @@ def safe_summary(self, encoded):
337349
(_('checksum'), mask_hash(checksum)),
338350
])
339351

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

341363
class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
342364
"""
@@ -384,6 +406,9 @@ def safe_summary(self, encoded):
384406
(_('hash'), mask_hash(hash)),
385407
])
386408

409+
def harden_runtime(self, password, encoded):
410+
pass
411+
387412

388413
class MD5PasswordHasher(BasePasswordHasher):
389414
"""
@@ -412,6 +437,9 @@ def safe_summary(self, encoded):
412437
(_('hash'), mask_hash(hash)),
413438
])
414439

440+
def harden_runtime(self, password, encoded):
441+
pass
442+
415443

416444
class UnsaltedSHA1PasswordHasher(BasePasswordHasher):
417445
"""
@@ -444,6 +472,9 @@ def safe_summary(self, encoded):
444472
(_('hash'), mask_hash(hash)),
445473
])
446474

475+
def harden_runtime(self, password, encoded):
476+
pass
477+
447478

448479
class UnsaltedMD5PasswordHasher(BasePasswordHasher):
449480
"""
@@ -477,6 +508,9 @@ def safe_summary(self, encoded):
477508
(_('hash'), mask_hash(encoded, show=3)),
478509
])
479510

511+
def harden_runtime(self, password, encoded):
512+
pass
513+
480514

481515
class CryptPasswordHasher(BasePasswordHasher):
482516
"""
@@ -511,3 +545,6 @@ def safe_summary(self, encoded):
511545
(_('salt'), salt),
512546
(_('hash'), mask_hash(data, show=3)),
513547
])
548+
549+
def harden_runtime(self, password, encoded):
550+
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/topics/auth/passwords.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@ sure never to *remove* entries from this list. If you do, users using
194194
unmentioned algorithms won't be able to upgrade. Passwords will be upgraded
195195
when changing the PBKDF2 iteration count.
196196

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

199207
Password upgrading without requiring a login
@@ -283,6 +291,28 @@ Include any other hashers that your site uses in this list.
283291
.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt
284292
.. _`bcrypt library`: https://pypi.python.org/pypi/bcrypt/
285293

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

287317
Manually managing a user's password
288318
===================================

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
@@ -177,6 +178,28 @@ def test_bcrypt(self):
177178
self.assertTrue(check_password('', blank_encoded))
178179
self.assertFalse(check_password(' ', blank_encoded))
179180

181+
@skipUnless(bcrypt, "bcrypt not installed")
182+
def test_bcrypt_harden_runtime(self):
183+
hasher = get_hasher('bcrypt')
184+
self.assertEqual('bcrypt', hasher.algorithm)
185+
186+
with mock.patch.object(hasher, 'rounds', 4):
187+
encoded = make_password('letmein', hasher='bcrypt')
188+
189+
with mock.patch.object(hasher, 'rounds', 6), \
190+
mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
191+
hasher.harden_runtime('wrong_password', encoded)
192+
193+
# Increasing rounds from 4 to 6 means an increase of 4 in workload,
194+
# therefore hardening should run 3 times to make the timing the
195+
# same (the original encode() call already ran once).
196+
self.assertEqual(hasher.encode.call_count, 3)
197+
198+
# Get the original salt (includes the original workload factor)
199+
algorithm, data = encoded.split('$', 1)
200+
expected_call = (('wrong_password', force_bytes(data[:29])),)
201+
self.assertEqual(hasher.encode.call_args_list, [expected_call] * 3)
202+
180203
def test_unusable(self):
181204
encoded = make_password(None)
182205
self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH)
@@ -284,6 +307,25 @@ def setter(password):
284307
finally:
285308
hasher.iterations = old_iterations
286309

310+
def test_pbkdf2_harden_runtime(self):
311+
hasher = get_hasher('default')
312+
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
313+
314+
with mock.patch.object(hasher, 'iterations', 1):
315+
encoded = make_password('letmein')
316+
317+
with mock.patch.object(hasher, 'iterations', 6), \
318+
mock.patch.object(hasher, 'encode', side_effect=hasher.encode):
319+
hasher.harden_runtime('wrong_password', encoded)
320+
321+
# Encode should get called once ...
322+
self.assertEqual(hasher.encode.call_count, 1)
323+
324+
# ... with the original salt and 5 iterations.
325+
algorithm, iterations, salt, hash = encoded.split('$', 3)
326+
expected_call = (('wrong_password', salt, 5),)
327+
self.assertEqual(hasher.encode.call_args, expected_call)
328+
287329
def test_pbkdf2_upgrade_new_hasher(self):
288330
hasher = get_hasher('default')
289331
self.assertEqual('pbkdf2_sha256', hasher.algorithm)
@@ -312,6 +354,20 @@ def setter(password):
312354
self.assertTrue(check_password('letmein', encoded, setter))
313355
self.assertTrue(state['upgraded'])
314356

357+
def test_check_password_calls_harden_runtime(self):
358+
hasher = get_hasher('default')
359+
encoded = make_password('letmein')
360+
361+
with mock.patch.object(hasher, 'harden_runtime'), \
362+
mock.patch.object(hasher, 'must_update', return_value=True):
363+
# Correct password supplied, no hardening needed
364+
check_password('letmein', encoded)
365+
self.assertEqual(hasher.harden_runtime.call_count, 0)
366+
367+
# Wrong password supplied, hardening needed
368+
check_password('wrong_password', encoded)
369+
self.assertEqual(hasher.harden_runtime.call_count, 1)
370+
315371
def test_load_library_no_algorithm(self):
316372
with self.assertRaises(ValueError) as e:
317373
BasePasswordHasher()._load_library()

0 commit comments

Comments
 (0)