Небезопасное хеширование паролей (CWE-916): почему SHA-256 недостаточно и как хранить пароли
Хранение паролей в виде несолёных хешей SHA-256, SHA-1 или MD5 означает, что утёкшую таблицу можно перебирать со скоростью миллиардов попыток в секунду на GPU, а все пользователи с одинаковым паролем получают один и тот же хеш. Как соль, требовательные к памяти функции вроде Argon2id, перехеширование при входе и фиктивная проверка защищают украденную базу, на примере Python и argon2-cffi.
Простыми словами (ELI5)
Гардероб хранит копию каждого номерка, чтобы проверять, чьё пальто чьё. Если копии — простые ксерокопии, вор, укравший папку, уйдёт со всеми пальто. Быстрый хеш — это слегка смазанная ксерокопия: вор с быстрым сканером восстанавливает миллионы в секунду. Медленный хеш, требующий много памяти, — это копия, запертая в маленьком сейфе, который открывается целую секунду и только на большом верстаке, по сейфу на номерок и у каждого свой замок. Владелец открывает один сейф у двери и не замечает задержки, а вору нужно открыть миллионы.
Ключевые понятия и термины
- Быстрый хеш
- SHA-256, SHA-1 и MD5 созданы, чтобы быть быстрыми. Один современный GPU вычисляет миллиарды хешей SHA-256 в секунду, поэтому короткие или распространённые пароли поддаются перебору и атаке по словарю за считанные часы.
- Соль
- Случайное значение, хранимое вместе с каждым хешем, чтобы одинаковые пароли давали разные хеши. Без соли одна заранее вычисленная таблица взламывает все учётные записи сразу, а одинаковые хеши выдают пользователей с общим паролем.
- Функция, требовательная к памяти (Argon2id)
- Argon2id делает каждую попытку дорогой и по времени, и по памяти, что лишает GPU большей части преимущества. Минимум OWASP — 19 МиБ памяти, две итерации и одна полоса (
memory_cost=19456,time_cost=2,parallelism=1); scrypt и bcrypt — допустимые альтернативы. - Строка PHC и перехеширование
PasswordHasher.hash()возвращает строку вида$argon2id$v=19$m=19456,t=2,p=1$...с алгоритмом, параметрами, солью и хешем.check_needs_rehash()сообщает, когда сохранённые параметры слабее текущих, чтобы обновить хеш при следующем успешном входе.- Время ответа и перечисление учётных записей
- Если на неизвестные адреса почты сервер отвечает мгновенно, а на настоящие — за время проверки хеша, время ответа выдаёт, какие учётные записи существуют. Проверка против фиктивного хеша уравнивает время обоих путей.
Пошаговый сценарий атаки
Таблица пользователей утекает
SQL-инъекция, открытая резервная копия или украденный ноутбук дают атакующему столбец password_hash.
Атакующий запускает взлом на GPU
Несолёный SHA-256 позволяет один раз захешировать словарь и сравнить его со всеми строками. Тестовая учётная запись с паролем Summer2024! показывает, как быстро падает распространённый шаблон.
Общие пароли падают вместе
У всех, кто выбрал одинаковый пароль, одинаковый хеш, поэтому один взломанный хеш раскрывает всех.
Пароли используют на других сайтах
Взломанные пароли пробуют на почте, в банках и облачных сервисах. В 2012 году LinkedIn потерял около 6,5 миллиона несолёных хешей SHA-1, а в 2016 году в продаже появился набор примерно из 117 миллионов учётных данных из той же утечки.
Исходный код: Уязвимый vs Защищённый вариант
# users.py: один быстрый несолёный хеш на пароль
import hashlib
from .db import db
# одинаковый пароль — одинаковый хеш, миллиарды попыток в секунду на 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))
# на неизвестные адреса ответ мгновенный, время выдаёт существующие учётные записи
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 с солью для каждого пароля и обновлением при входе
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# минимум OWASP для Argon2id: 19 МиБ, 2 итерации, 1 полоса
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# сохранённая строка содержит алгоритм, параметры, соль и хеш
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)))
# неизвестные пользователи тоже стоят одной проверки; слабые хеши обновляются
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
Чек-лист по защите системы для инженеров
- Хешируйте пароли через Argon2id (не меньше 19 МиБ памяти, 2 итерации, 1 полоса), scrypt или bcrypt; добавьте правило Semgrep, отмечающее вызовы
hashlibнад переменными с паролями, и запускайте Bandit B324 для MD5 и SHA-1. - Храните целиком строку PHC, которую возвращает библиотека, и никогда не выделяйте и не переиспользуйте соль.
- Вызывайте
check_needs_rehash()после каждого успешного входа и заменяйте хеши, созданные со слабыми параметрами. - Проверяйте пароль против фиктивного хеша, если учётной записи нет, чтобы время ответа не выдавало зарегистрированные адреса.
- Требуйте не меньше 12 символов, разрешайте длинные парольные фразы хотя бы до 64 символов и отклоняйте пароли из списков утечек.