Hash de senha inseguro (CWE-916): por que SHA-256 não basta e como armazenar senhas
Guardar senhas como digests SHA-256, SHA-1 ou MD5 sem salt significa que uma tabela vazada pode ser testada a bilhões de tentativas por segundo numa GPU, e todos os usuários com a mesma senha compartilham um único hash. Como salts, funções que exigem muita memória como o Argon2id, o rehash no login e a verificação falsa protegem um banco roubado, com um exemplo em Python usando argon2-cffi.
Explicação em Linguagem Simples (ELI5)
Uma chapelaria guarda uma cópia de cada tíquete para conferir quem é dono de qual casaco. Se as cópias forem simples fotocópias, um ladrão que roube a pasta sai com todos os casacos. Um hash rápido é uma fotocópia levemente borrada: um ladrão com um scanner veloz desborra milhões por segundo. Um hash lento e que gasta muita memória é uma cópia trancada num pequeno cofre que leva um segundo inteiro e uma bancada grande para abrir, um cofre por tíquete, cada um com uma fechadura diferente. O dono abre um cofre na porta sem perceber a demora; o ladrão precisa abrir milhões.
Conceitos Centrais e Termos
- Hash rápido
- SHA-256, SHA-1 e MD5 foram feitos para ser rápidos. Uma única GPU moderna calcula bilhões de hashes SHA-256 por segundo, então senhas curtas ou comuns caem em horas diante de força bruta e ataques de dicionário.
- Salt
- Um valor aleatório guardado com cada hash para que senhas iguais gerem hashes diferentes. Sem ele, uma única tabela pré-calculada quebra todas as contas de uma vez, e hashes iguais revelam usuários que compartilham a senha.
- Função que exige memória (Argon2id)
- O Argon2id faz cada tentativa custar tempo e memória, o que elimina a maior parte da vantagem de uma GPU. O mínimo da OWASP é 19 MiB de memória, duas iterações e uma faixa (
memory_cost=19456,time_cost=2,parallelism=1); scrypt e bcrypt são alternativas aceitáveis. - String PHC e rehash
PasswordHasher.hash()devolve uma string como$argon2id$v=19$m=19456,t=2,p=1$...com o algoritmo, os parâmetros, o salt e o hash.check_needs_rehash()avisa quando os parâmetros guardados são mais fracos que os atuais, para que o hash seja atualizado no próximo login bem-sucedido.- Tempo de resposta e enumeração de contas
- Se e-mails desconhecidos respondem na hora e os reais levam o tempo de uma verificação de hash, o tempo de resposta revela quais contas existem. Verificar contra um hash falso faz os dois caminhos levarem o mesmo tempo.
Fluxo de Ataque Passo a Paso
A tabela de usuários vaza
Uma injeção de SQL, um backup exposto ou um notebook roubado entrega ao atacante a coluna password_hash.
O atacante roda um quebrador em GPU
SHA-256 sem salt permite calcular o hash de uma lista de palavras uma vez e comparar com todas as linhas. Uma conta de teste com a senha Summer2024! mostra a rapidez com que um padrão comum cai.
Senhas compartilhadas caem juntas
Todo usuário que escolheu a mesma senha tem o mesmo digest, então um hash quebrado expõe todos eles.
As senhas são reutilizadas em outros lugares
As senhas quebradas são testadas em contas de e-mail, banco e nuvem. Em 2012 o LinkedIn perdeu cerca de 6,5 milhões de hashes SHA-1 sem salt, e em 2016 um conjunto de cerca de 117 milhões de credenciais do mesmo vazamento foi posto à venda.
Código-Fonte: Vulnerável vs. Seguro
# users.py: um digest rápido e sem salt por senha
import hashlib
from .db import db
# mesma senha, mesmo hash, bilhões de tentativas por segundo numa 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))
# e-mails desconhecidos respondem na hora, então o tempo revela quais contas existem
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 com salt por senha e atualização no login
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# mínimo da OWASP para Argon2id: 19 MiB, 2 iterações, 1 faixa
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# a string guardada traz algoritmo, parâmetros, salt e 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)))
# usuários desconhecidos também pagam uma verificação; hashes fracos são atualizados
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 Verificação de Segurança para Engenharia
- Faça o hash das senhas com Argon2id (pelo menos 19 MiB de memória, 2 iterações, 1 faixa), scrypt ou bcrypt; adicione uma regra Semgrep que sinalize chamadas
hashlibsobre variáveis de senha e rode o Bandit B324 para MD5 e SHA-1. - Guarde a string PHC completa que a biblioteca devolve e nunca separe nem reutilize o salt.
- Chame
check_needs_rehash()após cada login bem-sucedido e substitua hashes criados com parâmetros mais fracos. - Verifique contra um hash falso quando a conta não existir, para que o tempo de resposta não revele e-mails cadastrados.
- Exija pelo menos 12 caracteres, aceite frases-senha longas de até pelo menos 64 e rejeite senhas presentes em listas de vazamentos.