● CWE-916 · OWASP A02:2021

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

Passo 1

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.

Passo 2

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.

Passo 3

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.

Passo 4

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

IMPLEMENTAÇÃO VULNERÁVEL
# 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()
PATCH SEGURO E ROBUSTO
# 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

Fontes

← Ver o diretório completo de segurança Todos os guias de vulnerabilidades →