Пароль, який не можна прочитати навіть із бази
Сервер не мусить знати пароль, лише перевіряти його. Тому в базі лежить результат односторонньої функції: з пароля хеш отримати легко, з хешу пароль ні. Атакувальник із дампом не обертає хеш, а перебирає кандидатів: словник, злиті паролі, варіації. Отже, захист вимірюється тим, скільки коштує одна спроба, і чи можна одним перебором зламати всі акаунти одразу.
md5, sha1 і sha256 створено швидкими: одна відеокарта рахує десятки мільярдів md5 за секунду. Без однакові паролі мають однаковий хеш, тож популярні видно одразу, а готові знаходять їх миттєво. Власна сіль на весь сайт у md5($salt.$password) рятує від готових таблиць, але не від швидкості, і зазвичай витікає разом із кодом.
password_hash($password, PASSWORD_DEFAULT) робить усе разом: генерує випадкову сіль, бере і записує алгоритм, cost і сіль у сам рядок, як-от $2y$12$… password_verify читає параметри звідти й порівнює результат за сталий час. У PHP 8.4 PASSWORD_DEFAULT це bcrypt із cost 12 (до 8.4 було 10). Хеш bcrypt має 60 символів, але колонку роблять VARCHAR(255), бо типовий алгоритм може змінитися.
(PASSWORD_ARGON2ID) вимагає ще й памʼяті, типово 64 МБ на хеш, тож відеокартам він дається важче. Він є лише тоді, коли PHP зібрано з libargon2 або libsodium: перевіряйте defined('PASSWORD_ARGON2ID'). У bcrypt своє обмеження: він бере лише перші 72 байти пароля, а кириличний символ у UTF-8 займає два. Коли ви піднімаєте cost чи змінюєте алгоритм, старі хеші лишаються як були: password_needs_rehash після успішного входу каже, що пора захешувати пароль заново.
Laravel загортає це у фасад Hash: Hash::make, Hash::check, Hash::needsRehash, типово bcrypt з BCRYPT_ROUNDS=12. Cast hashed у моделі хешує пароль при присвоєнні й не чіпає вже готовий хеш. Auth::attempt сам перехешовує пароль після входу (hashing.rehash_on_login), а для інших секретів, як-от токенів, порівняння має бути через hash_equals: === зупиняється на першому різному байті, і за можна вгадувати. Слабкі паролі ловить Password::min(8)->uncompromised(): він питає Have I Been Pwned через .
Три кроки від злитого дампу до чужої пошти
- Атакувальник створює Дамп users.
- Дамп users використовує Перебір на GPU (md5 без солі).
- Дані йдуть від Перебір на GPU до qwerty123.
- qwerty123 надсилає подію повтор пароля, її отримує Пошта, банк, GitHub.
Від md5 до password_hash
-
1
рядки 3–5
Два однакові рядки. Хто має дамп, бачить, у кого паролі збігаються, і перевіряє кандидата для всіх одразу.
-
2
рядки 8–11
Два хеші того самого пароля різні: $2y$ це bcrypt, 10 це cost, далі 22 символи солі й сам хеш. На сервері лишайте типовий cost 12 або більший.
-
3
рядки 13–14
password_verify бере алгоритм, cost і сіль із хешу, тому окремо їх зберігати не треба.
-
4
рядки 16–21
Так перехешовує Laravel після Auth::attempt: старий хеш із cost 4 після входу стає хешем із поточними параметрами.
-
5
рядки 25–27
Обидва md5 починаються з 0e і далі лише цифри, тож == порівнює їх як числа нуль. hash_equals і === порівнюють рядки.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel і Symfony хешують самі, крім цих місць
User::create($request->validated());
Чому це дірка: Без cast hashed у моделі чи Hash::make пароль ляже в базу відкритим текстом. Cast хешує при присвоєнні, а вже готовий хеш (Hash::isHashed) не хешує вдруге.
password_hash($email . $password, PASSWORD_BCRYPT);
Чому це дірка: bcrypt бере лише перші 72 байти. Довгий префікс зʼїдає пароль, а 40 кириличних літер уже 80 байтів, і хвіст не перевіряється. Laravel має опцію bcrypt.limit, яка кидає виняток на задовге значення.
if (md5($password) == $user->password)
Чому це дірка: Рядки на кшталт 0e462…, де далі лише цифри, == порівнює як числа. Різні паролі проходять перевірку. Навіть із === md5 лишається надто швидким: беріть password_verify.
if ($request->input('token') === $stored)
Чому це дірка: === виходить на першому різному байті, і час відповіді підказує, скільки байтів збіглося. Секрети порівнюють hash_equals(відомий, введений). Токени скидання генерують random_bytes і зберігають як хеш із терміном дії.
'password' => ['required', 'min:8']
Чому це дірка: Password1 проходить min:8, але він є в кожному словнику. Password::min(8)->uncompromised() перевіряє злиті паролі. Якщо API недоступний, правило пропускає пароль.
Crypt::encryptString($request->password)
Чому це дірка: Шифрування оборотне: хто має APP_KEY, а він витікає разом із .env, той читає всі паролі. Паролі хешують, шифрують лише те, що треба прочитати назад.
Log::info('login', $request->all());
Чому це дірка: Пароль іде в лог відкритим текстом. Обробник винятків Laravel не повертає у форму password, password_confirmation і current_password, а ваш Log::info нічого не фільтрує.
'driver' => 'argon2id'
Чому це дірка: Кожен хеш бере ARGON_MEMORY, типово 64 МБ. Пʼятдесят одночасних входів це понад 3 ГБ памʼяті, тож ліміт спроб входу тут обовʼязковий. І PHP має бути зібраний з argon2.
password_hashers: { App\Entity\User: 'auto' }
Чому це дірка: auto обирає найкращий доступний алгоритм. Старі хеші оновлюються при вході, лише якщо UserProvider чи репозиторій реалізує PasswordUpgraderInterface.
Де тут проблеми з паролями
Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.
Вставте у свій шаблон пул-реквесту
- Паролі хешуються лише password_hash, Hash::make або cast hashed. md5, sha1, sha256 і шифрування для паролів не використовуються.
- Колонка для хешу VARCHAR(255), у продакшені bcrypt cost не менше 12 або argon2id; у тестах BCRYPT_ROUNDS=4.
- Після успішного входу застарілий хеш перехешовується: rehash_on_login увімкнено або викликано password_needs_rehash.
- Старі md5-хеші мігруються при вході й видаляються, неактивним акаунтам надіслано скидання пароля.
- Токени генеруються random_bytes чи Str::random, зберігаються як хеш із терміном дії й порівнюються hash_equals.
- Новий пароль перевіряється Password::min(...)->uncompromised(), а максимальна довжина враховує 72 байти bcrypt.
- Паролі не потрапляють у логи, дампи запитів, листи й повідомлення про помилки.
Як цей сайт зберігає паролі
Модель User має cast hashed, тож пароль хешується в момент присвоєння, хоч із реєстрації, хоч зі скидання чи налаштувань. Власного config/hashing.php немає, тому працюють типові налаштування Laravel 13: bcrypt, BCRYPT_ROUNDS=12, перехешування при вході. Вхід іде через Auth::attempt з лімітом 5 спроб на хвилину для пари пошта й IP, а зміна пошти чи пароля просить поточний пароль, який перевіряє Hash::check, або свіжий вхід через провайдера.
app/Http/Controllers/AuthController.phpAuth::attempt перевіряє пароль, а SessionGuard загортає перевірку в Timebox на 200 мс, тож невідома пошта не відповідає помітно швидше за неправильний пароль.app/Auth/AccountSecurity.phpconfirms() перевіряє поточний пароль через Hash::check, changePassword() записує новий і змінює remember_token, тож старі «запамʼятати мене» більше не працюють.app/Tools/PasswordHash.phpІнструмент сайту показує password_hash наживо: bcrypt із cost від 10 до 13 чи argon2id, час хешування й чи потрібне перехешування.- Сіль
- Випадкові байти, унікальні для кожного пароля, що додаються перед хешуванням. Однакові паролі дають різні хеші, і кожен доводиться перебирати окремо. Сіль не секрет, вона лежить поруч із хешем.
- Rainbow-таблиця
- Заздалегідь пораховані хеші мільйонів паролів, стиснуті так, щоб знайти пароль за хешем за секунди. Працює лише проти хешів без солі.
- Повільний хеш
- Алгоритм, спеціально зроблений дорогим: bcrypt, scrypt, argon2. Параметр cost чи memory задає ціну однієї спроби. Для людини, яка входить раз, це 50–300 мс, для перебору мільярдів це роки.
- argon2id
- Переможець Password Hashing Competition 2015. Вимагає і процесорного часу, і памʼяті, тому погано паралелиться на GPU й ASIC. OWASP радить його першим, bcrypt як прийнятну альтернативу.
- Атака за часом
- Атакувальник міряє, скільки сервер відповідає на різні значення. Якщо порівняння виходить на першому різному байті, відповідь трохи довша, коли перші байти збіглися, і секрет можна підбирати по байту.
- k-anonymity
- Сервер надсилає Have I Been Pwned лише перші 5 символів SHA-1 пароля, отримує всі хеші з таким початком і шукає свій локально. Ні пароль, ні повний хеш не покидають ваш сервер.
Питання зі співбесід
md5 створено швидким: відеокарта перевіряє десятки мільярдів кандидатів за секунду, тож словник і злиті паролі проходять за хвилини. Сіль рятує від rainbow-таблиць і від перебору всіх акаунтів разом, але не від швидкості. Потрібен навмисно повільний алгоритм, а в PHP це password_hash з PASSWORD_DEFAULT.
Хеш зберігає параметри, з якими його створили. Коли ви піднімаєте cost або змінюєте алгоритм, старі хеші лишаються слабшими. Після успішного password_verify відкритий пароль є в памʼяті, тож це єдиний момент, коли можна перехешувати: password_needs_rehash каже, чи параметри відрізняються від поточних. Laravel робить це сам в Auth::attempt через hashing.rehash_on_login.
password_verify лише для паролів. Токени скидання, підписи вебхуків, API-ключі й HMAC порівнюються окремо, і === чи strcmp виходять на першому різному байті. За часом відповіді можна підбирати значення по байту, а == ще й порівнює рядки 0e… як числа. hash_equals порівнює всю довжину за однаковий час. Аргументи в порядку: спершу відоме значення, потім введене.
argon2id вимагає памʼяті, тому GPU і ASIC перебирають його гірше; OWASP радить його першим (мінімум 19 МБ, t=2, p=1), bcrypt з cost від 10 як запасний варіант. Але argon2id з 64 МБ на хеш потребує ліміту входів і PHP, зібраного з argon2. Перехід: міняємо HASH_DRIVER, а старі хеші перевіряємо тим алгоритмом, яким їх зроблено. password_verify визначає його з префікса сам, а Laravel з HASH_VERIFY=true кидає виняток на хеш чужого алгоритму, тож або вимикаємо цю перевірку на час міграції, або перевіряємо через Hash::driver('bcrypt'). Після входу пароль перехешовується новим. Для md5-спадщини одразу загортаємо кожен хеш у password_hash(md5) і при вході переводимо на чистий password_hash.