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
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.
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.
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.
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
# 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()
# 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
- Calcula el hash de las contraseñas con Argon2id (al menos 19 MiB de memoria, 2 iteraciones, 1 carril), scrypt o bcrypt; añade una regla de Semgrep que señale llamadas a
hashlibsobre variables de contraseña y ejecuta Bandit B324 para MD5 y SHA-1. - Guarda la cadena PHC completa que devuelve la biblioteca y nunca separes ni reutilices la sal.
- Llama a
check_needs_rehash()tras cada inicio de sesión correcto y sustituye los hashes creados con parámetros más débiles. - Verifica contra un hash ficticio cuando la cuenta no exista, para que el tiempo de respuesta no delate correos registrados.
- Exige al menos 12 caracteres, admite frases de contraseña largas de hasta al menos 64 y rechaza contraseñas que aparezcan en listas de filtraciones.