● CWE-916 · OWASP A02:2021

Hash de contraseñas inseguro (CWE-916): por qué SHA-256 no basta y cómo almacenar contraseñas

Guardar contraseñas como digests SHA-256, SHA-1 o MD5 sin sal significa que una tabla filtrada puede probarse a miles de millones de intentos por segundo en una GPU, y que todos los usuarios con la misma contraseña comparten un único hash. Cómo protegen una base de datos robada las sales, las funciones que exigen mucha memoria como Argon2id, el rehash al iniciar sesión y la verificación ficticia, con un ejemplo en Python con argon2-cffi.

Explicación en Lenguaje Sencillo (ELI5)

Un guardarropa guarda una copia de cada resguardo para comprobar quién es dueño de cada abrigo. Si las copias son simples fotocopias, un ladrón que robe la carpeta se lleva todos los abrigos. Un hash rápido es una fotocopia algo emborronada: un ladrón con un escáner veloz desemborrona millones por segundo. Un hash lento que consume mucha memoria es una copia guardada en una pequeña caja fuerte que tarda un segundo entero y necesita un banco de trabajo grande para abrirse, una caja por resguardo y cada una con una cerradura distinta. El dueño abre una caja en la puerta sin notar la espera; el ladrón tiene que abrir millones.

Conceptos Clave y Términos

Hash rápido
SHA-256, SHA-1 y MD5 están diseñados para ser rápidos. Una sola GPU moderna calcula miles de millones de hashes SHA-256 por segundo, así que las contraseñas cortas o comunes caen en horas ante la fuerza bruta y los ataques de diccionario.
Sal
Un valor aleatorio guardado con cada hash para que contraseñas iguales den hashes distintos. Sin ella, una única tabla precalculada rompe todas las cuentas a la vez y los hashes iguales delatan a usuarios que comparten contraseña.
Función que exige memoria (Argon2id)
Argon2id hace que cada intento cueste tiempo y memoria, lo que elimina casi toda la ventaja de una GPU. El mínimo de OWASP es 19 MiB de memoria, dos iteraciones y un carril (memory_cost=19456, time_cost=2, parallelism=1); scrypt y bcrypt son alternativas aceptables.
Cadena PHC y rehash
PasswordHasher.hash() devuelve una cadena como $argon2id$v=19$m=19456,t=2,p=1$... con el algoritmo, los parámetros, la sal y el hash. check_needs_rehash() indica cuándo los parámetros guardados son más débiles que los actuales, para mejorar el hash en el siguiente inicio de sesión correcto.
Tiempos y enumeración de cuentas
Si los correos desconocidos responden al instante y los reales tardan lo que una verificación de hash, los tiempos de respuesta revelan qué cuentas existen. Verificar contra un hash ficticio hace que ambos caminos tarden lo mismo.

Flujo de Ataque Paso a Paso

Paso 1

Se filtra la tabla de usuarios

Una inyección SQL, una copia de seguridad expuesta o un portátil robado dan al atacante la columna password_hash.

Paso 2

El atacante lanza un crackeador en GPU

SHA-256 sin sal le permite calcular una vez el hash de una lista de palabras y compararla con todas las filas. Una cuenta de prueba con la contraseña Summer2024! muestra lo rápido que cae un patrón común.

Paso 3

Las contraseñas compartidas caen juntas

Todos los usuarios que eligieron la misma contraseña tienen el mismo digest, así que un hash roto los expone a todos.

Paso 4

Las contraseñas se reutilizan en otros sitios

Las contraseñas rotas se prueban en cuentas de correo, banca y nube. En 2012 LinkedIn perdió unos 6,5 millones de hashes SHA-1 sin sal, y en 2016 se puso a la venta un conjunto de unos 117 millones de credenciales de la misma brecha.

Código Fuente: Vulnerable vs. Seguro

IMPLEMENTACIÓN VULNERABLE
# users.py: un digest rápido y sin sal por contraseña
import hashlib

from .db import db


# misma contraseña, mismo hash, miles de millones de intentos por segundo en una GPU
def create_user(email: str, password: str) -> None:
    digest = hashlib.sha256(password.encode()).hexdigest()
    db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))


# los correos desconocidos responden al instante, así que el tiempo delata qué cuentas existen
def check_login(email: str, password: str) -> bool:
    row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
    return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
PARCHE SEGURO Y ROBUSTO
# users.py: Argon2id con sal por contraseña y mejora al iniciar sesión
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError

from .db import db

# mínimo de OWASP para Argon2id: 19 MiB, 2 iteraciones, 1 carril
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")


# la cadena guardada lleva algoritmo, parámetros, sal y hash
def create_user(email: str, password: str) -> None:
    if not 12 <= len(password) <= 128:
        raise ValueError("password must be 12 to 128 characters")
    db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))


# los usuarios desconocidos también pagan una verificación; los hashes débiles se mejoran
def check_login(email: str, password: str) -> bool:
    row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
    stored = row[1] if row else DUMMY_HASH
    try:
        hasher.verify(stored, password)
    except (VerificationError, InvalidHashError):
        return False
    if row is None:
        return False
    if hasher.check_needs_rehash(stored):
        db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
    return True

Lista de Verificación de Seguridad para Ingeniería

Fuentes

← Ver el directorio completo de seguridad Todas las guías de vulnerabilidades →