<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: ВИСОКА OWASP 2025 A04 · Cryptographic Failures

Паролі й хешуванняPassword hashing

Таблиця users рано чи пізно витікає разом із бекапом, дампом чи через SQL-інʼєкцію. Від того, як ви зберегли паролі, залежить, чи атакувальник прочитає їх за хвилини, чи майже ні за що.

12 хв читання Junior → Senior
01 · ЩО ЦЕ

Пароль, який не можна прочитати навіть із бази

Сервер не мусить знати пароль, лише перевіряти його. Тому в базі лежить результат односторонньої функції: з пароля хеш отримати легко, з хешу пароль ні. Атакувальник із дампом не обертає хеш, а перебирає кандидатів: словник, злиті паролі, варіації. Отже, захист вимірюється тим, скільки коштує одна спроба, і чи можна одним перебором зламати всі акаунти одразу.

md5, sha1 і sha256 створено швидкими: одна відеокарта рахує десятки мільярдів md5 за секунду. Без однакові паролі мають однаковий хеш, тож популярні видно одразу, а готові знаходять їх миттєво. Власна сіль на весь сайт у md5($salt.$password) рятує від готових таблиць, але не від швидкості, і зазвичай витікає разом із кодом.

СЛОВНИКСіль. Випадкові байти, унікальні для кожного пароля, що додаються перед хешуванням. Однакові паролі дають різні хеші, і кожен доводиться перебирати окремо. Сіль не секрет, вона лежить поруч із хешем.
СЛОВНИКRainbow-таблиця. Заздалегідь пораховані хеші мільйонів паролів, стиснуті так, щоб знайти пароль за хешем за секунди. Працює лише проти хешів без солі.

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), бо типовий алгоритм може змінитися.

СЛОВНИКПовільний хеш. Алгоритм, спеціально зроблений дорогим: bcrypt, scrypt, argon2. Параметр cost чи memory задає ціну однієї спроби. Для людини, яка входить раз, це 50–300 мс, для перебору мільярдів це роки.

(PASSWORD_ARGON2ID) вимагає ще й памʼяті, типово 64 МБ на хеш, тож відеокартам він дається важче. Він є лише тоді, коли PHP зібрано з libargon2 або libsodium: перевіряйте defined('PASSWORD_ARGON2ID'). У bcrypt своє обмеження: він бере лише перші 72 байти пароля, а кириличний символ у UTF-8 займає два. Коли ви піднімаєте cost чи змінюєте алгоритм, старі хеші лишаються як були: password_needs_rehash після успішного входу каже, що пора захешувати пароль заново.

СЛОВНИКargon2id. Переможець Password Hashing Competition 2015. Вимагає і процесорного часу, і памʼяті, тому погано паралелиться на GPU й ASIC. OWASP радить його першим, bcrypt як прийнятну альтернативу.

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 через .

СЛОВНИКАтака за часом. Атакувальник міряє, скільки сервер відповідає на різні значення. Якщо порівняння виходить на першому різному байті, відповідь трохи довша, коли перші байти збіглися, і секрет можна підбирати по байту.
СЛОВНИКk-anonymity. Сервер надсилає Have I Been Pwned лише перші 5 символів SHA-1 пароля, отримує всі хеші з таким початком і шукає свій локально. Ні пароль, ні повний хеш не покидають ваш сервер.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від злитого дампу до чужої пошти

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
бекап чи SQLimd5 без соліхвилиниповтор пароляАтакувальникДамп usersПеребір на GPU:qwerty123Пошта, банк, GitHub
АтакувальникДамп usersПеребір на GPU:qwerty123Пошта, банк, GitHub
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Атакувальник створює Дамп users.
  • Дамп users використовує Перебір на GPU (md5 без солі).
  • Дані йдуть від Перебір на GPU до qwerty123.
  • qwerty123 надсилає подію повтор пароля, її отримує Пошта, банк, GitHub.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Від md5 до password_hash

PHP 8.5
1<?php
2
3// Було: швидкий хеш і одна «сіль» на весь сайт.
4const SALT = 'phpukraine';
5
6function hashPassword(string $password): string
7{
8 return md5(SALT . $password);
9}
10
11function checkPassword(string $password, string $stored): bool
12{
13 return hashPassword($password) == $stored;
14}
15
16echo hashPassword('qwerty123'), "\n";
17echo hashPassword('qwerty123'), "\n";
1<?php
2
31// md5 без солі: однаковий пароль дає однаковий хеш.
4echo 'md5: ', md5('qwerty123'), "\n";
5echo 'md5: ', md5('qwerty123'), "\n\n";
6
7// Сіль і параметри лежать у самому рядку хешу.
82$options = ['cost' => 10]; // 10, щоб приклад швидко йшов у браузері
9$hash = password_hash('qwerty123', PASSWORD_DEFAULT, $options);
10echo 'bcrypt: ', $hash, "\n";
11echo 'bcrypt: ', password_hash('qwerty123', PASSWORD_DEFAULT, $options), "\n\n";
12
133echo 'qwerty123: ', var_export(password_verify('qwerty123', $hash), true), "\n";
14echo 'qwerty124: ', var_export(password_verify('qwerty124', $hash), true), "\n\n";
15
164// Вхід зі старим хешем: пароль підійшов, параметри застаріли.
17$stored = password_hash('qwerty123', PASSWORD_BCRYPT, ['cost' => 4]);
18
19if (password_verify('qwerty123', $stored) && password_needs_rehash($stored, PASSWORD_DEFAULT, $options)) {
20 $stored = password_hash('qwerty123', PASSWORD_DEFAULT, $options);
21}
22
23echo 'після входу: ', substr($stored, 0, 7), "…\n\n";
24
255// Нестроге порівняння: обидва md5 схожі на число 0e…
26var_dump(md5('240610708') == md5('QNKCDZO'));
27var_dump(hash_equals(md5('240610708'), md5('QNKCDZO')));
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–5

    Два однакові рядки. Хто має дамп, бачить, у кого паролі збігаються, і перевіряє кандидата для всіх одразу.

  2. 2 рядки 8–11

    Два хеші того самого пароля різні: $2y$ це bcrypt, 10 це cost, далі 22 символи солі й сам хеш. На сервері лишайте типовий cost 12 або більший.

  3. 3 рядки 13–14

    password_verify бере алгоритм, cost і сіль із хешу, тому окремо їх зберігати не треба.

  4. 4 рядки 16–21

    Так перехешовує Laravel після Auth::attempt: старий хеш із cost 4 після входу стає хешем із поточними параметрами.

  5. 5 рядки 25–27

    Обидва md5 починаються з 0e і далі лише цифри, тож == порівнює їх як числа нуль. hash_equals і === порівнюють рядки.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

Laravel і Symfony хешують самі, крім цих місць

LARAVEL · БЕЗ CAST
User::create($request->validated());

Чому це дірка: Без cast hashed у моделі чи Hash::make пароль ляже в базу відкритим текстом. Cast хешує при присвоєнні, а вже готовий хеш (Hash::isHashed) не хешує вдруге.

PHP · 72 БАЙТИ BCRYPT
password_hash($email . $password, PASSWORD_BCRYPT);

Чому це дірка: bcrypt бере лише перші 72 байти. Довгий префікс зʼїдає пароль, а 40 кириличних літер уже 80 байтів, і хвіст не перевіряється. Laravel має опцію bcrypt.limit, яка кидає виняток на задовге значення.

PHP · НЕСТРОГЕ ПОРІВНЯННЯ
if (md5($password) == $user->password)

Чому це дірка: Рядки на кшталт 0e462…, де далі лише цифри, == порівнює як числа. Різні паролі проходять перевірку. Навіть із === md5 лишається надто швидким: беріть password_verify.

PHP · ТОКЕНИ
if ($request->input('token') === $stored)

Чому це дірка: === виходить на першому різному байті, і час відповіді підказує, скільки байтів збіглося. Секрети порівнюють hash_equals(відомий, введений). Токени скидання генерують random_bytes і зберігають як хеш із терміном дії.

LARAVEL · ЛИШЕ ДОВЖИНА
'password' => ['required', 'min:8']

Чому це дірка: Password1 проходить min:8, але він є в кожному словнику. Password::min(8)->uncompromised() перевіряє злиті паролі. Якщо API недоступний, правило пропускає пароль.

ШИФРУВАННЯ ЗАМІСТЬ ХЕШУ
Crypt::encryptString($request->password)

Чому це дірка: Шифрування оборотне: хто має APP_KEY, а він витікає разом із .env, той читає всі паролі. Паролі хешують, шифрують лише те, що треба прочитати назад.

LARAVEL · ЛОГИ
Log::info('login', $request->all());

Чому це дірка: Пароль іде в лог відкритим текстом. Обробник винятків Laravel не повертає у форму password, password_confirmation і current_password, а ваш Log::info нічого не фільтрує.

LARAVEL · ARGON2ID
'driver' => 'argon2id'

Чому це дірка: Кожен хеш бере ARGON_MEMORY, типово 64 МБ. Пʼятдесят одночасних входів це понад 3 ГБ памʼяті, тож ліміт спроб входу тут обовʼязковий. І PHP має бути зібраний з argon2.

SYMFONY · PASSWORD HASHER
password_hashers: { App\Entity\User: 'auto' }

Чому це дірка: auto обирає найкращий доступний алгоритм. Старі хеші оновлюються при вході, лише якщо UserProvider чи репозиторій реалізує PasswordUpgraderInterface.

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут проблеми з паролями

Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.

app/Auth/LegacyLogin.php натисніть на підозрілі рядки
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: password_hash із PASSWORD_DEFAULT сам додає сіль і бере bcrypt.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. Так, пароль іде в лог відкритим текстом. Логи читає більше людей і сервісів, ніж базу. Логуйте пошту й результат, без пароля.
Тут усе гаразд: для невідомої пошти перевірка йде проти фіктивного хешу, тож відповідь займає стільки ж часу й не видає, чи є такий акаунт.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. Теж проблема. md5 надто швидкий, а == пропускає «магічні» хеші, що починаються з 0e. Старий хеш перевіряйте через hash_equals, одразу перехешуйте пароль password_hash і видаляйте legacy_md5.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: password_verify порівнює за сталий час. Бракує хіба що password_needs_rehash після успішного входу.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. І тут. uniqid залежить від часу, а 8 символів md5 легко перебрати. Потрібно bin2hex(random_bytes(32)), а в базі зберігати хеш токена з терміном дії.
Сам запис нормальний. Проблема рядком вище: токен треба генерувати випадковим і зберігати його хеш, а не сам токен.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Тут усе гаразд: сам цей рядок не вразливий.
Пропущено. Нестроге == і порівняння, що виходить на першому різному байті. Порівнюйте hash_equals і перевіряйте термін дії.
Тут усе гаразд: сам цей рядок не вразливий.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Зберігання паролів
  • Паролі хешуються лише 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.
  • Паролі не потрапляють у логи, дампи запитів, листи й повідомлення про помилки.
07 · ЯК У PHP UKRAINE

Як цей сайт зберігає паролі

Модель User має cast hashed, тож пароль хешується в момент присвоєння, хоч із реєстрації, хоч зі скидання чи налаштувань. Власного config/hashing.php немає, тому працюють типові налаштування Laravel 13: bcrypt, BCRYPT_ROUNDS=12, перехешування при вході. Вхід іде через Auth::attempt з лімітом 5 спроб на хвилину для пари пошта й IP, а зміна пошти чи пароля просить поточний пароль, який перевіряє Hash::check, або свіжий вхід через провайдера.

php User.php фрагмент
1protected function casts(): array
2{
3 return [
4 'email_verified_at' => 'datetime',
5 'password' => 'hashed',← Hash::make при присвоєнні
6 'is_company_owner' => 'boolean',
7 'is_test' => 'boolean',
8 ];
9}
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, час хешування й чи потрібне перехешування.
08 · СЛОВНИК
Сіль
Випадкові байти, унікальні для кожного пароля, що додаються перед хешуванням. Однакові паролі дають різні хеші, і кожен доводиться перебирати окремо. Сіль не секрет, вона лежить поруч із хешем.
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 пароля, отримує всі хеші з таким початком і шукає свій локально. Ні пароль, ні повний хеш не покидають ваш сервер.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

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.