#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 password column verbatim without passing it through make_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 (0)

Note: See TracTickets for help on using tickets.
Back to Top