Opened 3 weeks ago
Closed 4 days ago
#37362 closed Bug (wontfix)
check_password() raises unhandled AssertionError when stored hash starts with "default$" (identify_hasher/get_hasher sentinel collision)
| Reported by: | Harsh Parmar | Owned by: | Kritarth Patel |
|---|---|---|---|
| Component: | contrib.auth | Version: | dev |
| Severity: | Normal | Keywords: | hashers get_hasher identify_hasher check_password assertionerror default |
| Cc: | Harsh Parmar, RYAN AKMAL PASYA | Triage Stage: | Unreviewed |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
Django version: 6.2.dev (main, commit 446d9cf602a5c42862da118b714a5679bb61cf27)
Python version: 3.13.12
Summary
get_hasher(algorithm="default") treats the literal string "default" as
a sentinel meaning "no algorithm was specified, use the first configured
hasher":
def get_hasher(algorithm="default"): if hasattr(algorithm, "algorithm"): return algorithm elif algorithm == "default": return get_hashers()[0] else: hashers = get_hashers_by_algorithm() try: return hashers[algorithm] except KeyError: raise ValueError(...)
identify_hasher(encoded) derives the algorithm name purely by splitting
the encoded hash string on its first $:
def identify_hasher(encoded): if (len(encoded) == 32 and "$" not in encoded) or (...): algorithm = "unsalted_md5" elif len(encoded) == 46 and encoded.startswith("sha1$$"): algorithm = "unsalted_sha1" else: algorithm = encoded.split("$", 1)[0] return get_hasher(algorithm)
If an encoded hash string's own algorithm segment (the text before the
first $) happens to literally be the string "default",
identify_hasher() passes "default" straight into get_hasher(), which
matches the sentinel case and returns the first configured hasher --
regardless of whether that hasher's own .algorithm actually matches the
string. That mismatched hasher's .decode(encoded) then hits:
algorithm, iterations, salt, hash = encoded.split("$", 3) assert algorithm == self.algorithm
algorithm here is "default", parsed straight from the string, which
never equals the real hasher's .algorithm (e.g. "pbkdf2_sha256"), so
the assertion fails with a bare, unhandled AssertionError -- not the
ValueError that identify_hasher()'s own docstring promises ("Raise
ValueError if algorithm cannot be identified").
This is reachable through check_password() -- the exact function
User.check_password() / ModelBackend.authenticate() call on every
login attempt, given the user's *stored* password hash as encoded.
Reproduction
import django from django.conf import settings settings.configure( SECRET_KEY="x", PASSWORD_HASHERS=["django.contrib.auth.hashers.PBKDF2PasswordHasher"], ) django.setup() from django.contrib.auth.hashers import check_password check_password("some guess", "default$$$$")
Traceback:
File "django/contrib/auth/hashers.py", line 83, in check_password
is_correct, must_update = verify_password(password, encoded, preferred=preferred)
File "django/contrib/auth/hashers.py", line 62, in verify_password
must_update = hasher_changed or preferred.must_update(encoded)
File "django/contrib/auth/hashers.py", line 362, in must_update
decoded = self.decode(encoded)
File "django/contrib/auth/hashers.py", line 339, in decode
assert algorithm == self.algorithm
AssertionError
Independently reproduced twice via a clean pip install of Django
straight from GitHub main into a fresh virtualenv (most recently commit
446d9cf6), confirming this is not an artifact of a locally modified
checkout:
$ pip install --upgrade "django @ git+https://github.com/django/django.git@main"
Resolved https://github.com/django/django.git to commit 446d9cf602a5c42862da118b714a5679bb61cf27
$ python3 -c "
import django
from django.conf import settings
settings.configure(SECRET_KEY='x', PASSWORD_HASHERS=['django.contrib.auth.hashers.PBKDF2PasswordHasher'])
django.setup()
from django.contrib.auth.hashers import check_password
check_password('some guess', 'default\$\$\$\$')"
Traceback (most recent call last):
...
File ".../site-packages/django/contrib/auth/hashers.py", line 339, in decode
assert algorithm == self.algorithm
AssertionError
Reachability
This requires a User.password value that literally starts with
"default$". Under fully standard Django usage this cannot occur
organically -- set_password()/make_password() always produce a hash
prefixed with the real hasher's algorithm name (pbkdf2_sha256$...,
argon2$..., bcrypt$...), never the literal word "default".
Realistic ways this string could still end up in password:
- Constructing a user object without going through
set_password()/create_user()(e.g.User.objects.create(username=..., password="default$whatever")) -- a well-known footgun in data-import scripts, test fixtures, and migrations from other systems. - Any bulk-import / legacy-system-migration path that copies a
passwordcolumn verbatim without passing it throughmake_password().
Impact if triggered: every subsequent login attempt for that account
crashes with an unhandled AssertionError instead of failing cleanly
with "invalid credentials" -- denial of service against that account's
ability to authenticate, plus noisy 500s on every attempt against it
(including from routine automated credential-stuffing traffic).
Not a duplicate of #18144
Trac search for "check_password AssertionError" returns one result,
#18144 (closed 2012) -- a different assertion in
MD5PasswordHasher.encode()'s empty-salt backward-compatibility handling,
unrelated to this "default" sentinel collision. Confirmed via Trac's
own search before filing.
Suggested fix
identify_hasher() should reject the literal strings "default",
"unsalted_md5", and "unsalted_sha1" explicitly before calling
get_hasher() (these are internal sentinels, not real encoded-hash
prefixes), or get_hasher()'s "default" special-case should only apply
when the argument truly wasn't supplied by the caller (e.g. a dedicated
sentinel object instead of overloading the string "default", which is
also a syntactically-possible-if-never-real encoded-hash prefix).
AI disclosure
This report was found and drafted with assistance from Claude Code
(Anthropic). Specifically: coverage-guided fuzzing (Google's atheris/
libFuzzer bindings) was used to fuzz identify_hasher() and each
hasher's .decode(), restricted to the realistic call pattern (only
ever decoding with the hasher identify_hasher() itself selected, matching
how verify_password() calls it internally) -- this surfaced the crash.
It was then manually reduced to the minimal repro above, independently
re-verified from scratch against a fresh pip install of Django from
GitHub main (twice, on two separate commits), and checked against Trac's
own search for duplicates before filing.
Change History (4)
comment:1 by , 12 days ago
| Cc: | added |
|---|
comment:2 by , 12 days ago
Thanks for confirming the repro, Ryan! Fine by me if you take it once the ticket is accepted.
A note for triage: identify_hasher() is documented to raise ValueError for unrecognized hashes, and callers such as check_password() depend on that. Because "default" and the "unsalted_*" names are special names get_hasher() uses internally, these hashes get past that check and fail on an assert in decode(). The caller gets an AssertionError instead of the ValueError it expects.
comment:3 by , 11 days ago
| Has patch: | set |
|---|---|
| Owner: | set to |
| Status: | new → assigned |
Submitted a pull request with fix and regression tests: https://github.com/django/django/pull/22058
comment:4 by , 4 days ago
| Resolution: | → wontfix |
|---|---|
| Status: | assigned → closed |
As far as I know Django doesn't support writing passwords to the database directly. The ticket comments along these lines:
Under fully standard Django usage this cannot occur organically
The ticket then goes on to give examples where it could happen - setting passwords and on migrations.
Setting a password directly is specifically documented as something that should not be done see docs. Users should use the provided API.
The migration path is also documented and would result in this scenario not occurring.
Happy to look again if the issue can be shown with a valid use case.
Confirmed repro on main @ 5a4511a, Python 3.14:
check_password("guess", "default$$$$") raises bare AssertionError in BasePasswordHasher.decode(), instead of ValueError as identify_hasher() docstring promises.
I have a local fix + regression test (reject "default"/"unsalted_md5"/"unsalted_sha1" in identify_hasher before get_hasher()) and auth_tests.test_hashers green (49 tests OK). Happy to submit PR once ticket is accepted — may I take this?