● CWE-916 · OWASP A02:2021

Небезопасное хеширование паролей (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() сообщает, когда сохранённые параметры слабее текущих, чтобы обновить хеш при следующем успешном входе.
Время ответа и перечисление учётных записей
Если на неизвестные адреса почты сервер отвечает мгновенно, а на настоящие — за время проверки хеша, время ответа выдаёт, какие учётные записи существуют. Проверка против фиктивного хеша уравнивает время обоих путей.

Пошаговый сценарий атаки

Шаг 1

Таблица пользователей утекает

SQL-инъекция, открытая резервная копия или украденный ноутбук дают атакующему столбец password_hash.

Шаг 2

Атакующий запускает взлом на GPU

Несолёный SHA-256 позволяет один раз захешировать словарь и сравнить его со всеми строками. Тестовая учётная запись с паролем Summer2024! показывает, как быстро падает распространённый шаблон.

Шаг 3

Общие пароли падают вместе

У всех, кто выбрал одинаковый пароль, одинаковый хеш, поэтому один взломанный хеш раскрывает всех.

Шаг 4

Пароли используют на других сайтах

Взломанные пароли пробуют на почте, в банках и облачных сервисах. В 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

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →