Opened 43 minutes ago
#37362 new Bug
check_password() raises unhandled AssertionError when stored hash starts with "default$" (identify_hasher/get_hasher sentinel collision)
| Reported by: | Harsh Parmar | Owned by: | |
|---|---|---|---|
| Component: | contrib.auth | Version: | dev |
| Severity: | Normal | Keywords: | hashers get_hasher identify_hasher check_password assertionerror default |
| Cc: | Harsh Parmar | Triage Stage: | Unreviewed |
| Has patch: | no | 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.