Změna hashování existujících hesel
Publikováno: 7.9.2017
Používáte pro ukládání uživatelských hesel funkce jako MD5 nebo SHA-1 a chtěli byste to změnit na třeba bcrypt? A chcete to udělat pořádně, aby byla chráněna všechna hesla ve vaší databázi? Pojďme si ukázat, jak to udělat.
Text vyšel původně na autorově blogu.
Z internetové nákupní galerie Mall.cz unikla uživatelská data včetně zahashovaných hesel. Někdo zatím neznámým způsobem získal přes 750 tisíc účtů, nahrál je na Ulož.to a odkaz v 27. července zveřejnil na Pastebinu. O incidentu informovala firma prostřednictvím poměrně skvělého příspěvku na blogu a e-mailu zákazníkům, ve kterém jim oznamuje, že Mall hesla resetoval a pokud se chtějí znovu přihlásit, tak si musí nastavit heslo nové. Data na Ulož.to již nejsou dostupná, ale Lupa soubor získala a prozkoumala a zjistila, že obsahuje 750 tisíc e-mailových adres a hesel v čitelné podobě. U části zákazníků byla uvedena i telefonní čísla.
Zatím není známé, jakým způsobem útočník získal čitelná hesla, Mall totiž údajně hesla hashoval:
Od listopadu 2012 jsme bezpečnost hesel zajišťovali hashovací metodou SHA1 + unikátní solí a od října 2016 chráníme přístupové údaje jednou z nejsilnějších hashovacích metod bcrypt. Do roku 2012 byly údaje hashovány metodou MD5, která dnes již není považována za bezpečnou. Většina prolomených hesel pochází právě z doby, kdy byla používána tato metoda. U starších účtů jsme proto změnili heslo a automaticky je převedli na zmiňovanou nejnovější hashovací metodu bcrypt, kterou aktuálně chráníme přístupové údaje všech účtů.
Pomineme-li, že bcrypt je z roku 1999 a že Mall.cz dříve MD5 zatajil, tak z toho nelze vyčíst, jestli hesla hashovaná pomocí MD5 nějak převáděli na bezpečnější bcrypt. V komentářích pod příspěvkem na blogu pak dodávají, že heslo „přehashovali“ po úspěšném přihlášení uživatele.
Jak to lze udělat lépe, aby ani při případném úniku nebyla ohrožena stará slabě hashovaná hesla uživatelů, kteří se dlouho nepřihlásili? Před několika lety jsme to udělali na Slevomatu a kdyby na to dělala návod IKEA, tak by vypadal asi nějak takhle:
Změna hashování
Nejdřív bych měl připomenout, co to vlastně takový na ukládání hesel nevhodný algoritmus je. Jsou to všechny ty MD5, SHA-1, SHA-2, SHA-3, a to v jakékoliv variantě. Se saltem („solí“), nebo bez, „zesílené“ pomocí několika stovek tisíc iterací, nebo jen jedno volání, je to jedno. Na ukládání hesel by se měla použít některá z těchto funkcí: Argon2i, bcrypt, scrypt nebo PBKDF2. Jsou relativně pomalé, takže pro lamače je časově i finančně náročně hesla cracknout.
Také bych měl zmínit, že tento článek není o reakci na bezpečnostní incident. Pokud už vám jakkoliv uložená hesla unikla (a vy jste si toho všimli), tak je všem uživatelům vyresetujte. Nová hesla pak rovnou ukládejte pomocí „nového“ hashe.
Jestliže používáte PHP, tak na uložení použijte funkci password_hash(..., PASSWORD_DEFAULT)
a na ověření password_verify()
. „Algoritmus“ PASSWORD_DEFAULT
aktuálně zajistí použití bcryptu, do budoucna to může být např. Argon2i, nicméně hashe uložené dnes půjdou ověřit i po změně defaultního algoritmu. Ten se totiž určuje jen při vytváření hashů, pro ověření se použije nastavení zapsané do samotného hashe, resp. nastavení je součástí výstupu z password_hash()
.
Pokud chcete vylepšit hashování hesel již zaregistrovaných uživatelů, tak máte tyto možnosti:
- Vymazat hesla všem uživatelům a tím je donutit zadat nové heslo hashované novým způsobem. To není moc dobrý nápad, uživatelé nebudou nadšení, bude je to otravovat a budou se vcelku oprávněně zlobit, proč jste jejich hesla nezabezpečili mnohem dříve. Reset hesel se dá provést v aplikacích pro pár stovek zaměstnanců, ale rozhodně ne v aplikacích, do kterých se může registrovat kdokoliv.
- Můžete heslo uživatele „přeuložit“ po úspěšném ověření při přihlašování, v tu chvíli totiž v aplikaci máte heslo k dispozici v čitelné podobě, takže ho můžete pěkně zahashovat bezpečnějším hashem. Z pohledu uživatele je tento způsob mnohem lepší, nicméně databáze bude stále obsahovat slabé hashe hesel uživatelů, kteří se od změny hashování nepřihlásili. A těch může být docela dost, protože například zvolili „permanentní přihlášení“ apod. Podle všeho Mall zvolil právě tento způsob.
- Všechny staré hashe najednou „přehashujete“ novým silnějším hashem a při ověřování pak vezmete uživatelské heslo z přihlašovacího formuláře, zahashujete starým hashem a pošlete na ověření novým hashem. Když se ověření povede, tak to trochu vyčistíte: heslo zahashujete pouze novým hashem a uložíte. První krok tohoto způsobu nevyžaduje žádnou akci na straně uživatele, takže ochrání i hesla uživatelů, kteří se dlouho nepřihlásili.
- Můžete zkusit cracknout všechna hesla a ta cracknutá pak uložit pomocí nového algoritmu. Kdepak, nedělejte to. S největší pravděpodobností nedokážete obhájit útočení na uživatelská hesla, mohla by se z toho celkem jednoduše stát PR pohroma. Navíc byste potřebovali přesunout hashe z vaší databáze někam mimo a nějakou dobu někde uchovávat cracknutá hesla v čitelné podobě, z čehož se může rychle vyklubat i bezpečnostní problém. Vaší prací je chránit hesla, ne na ně útočit nebo je nechat uniknout. Tohle prostě nedělejte.
Pojďme ten třetí způsob trochu rozebrat na atomy. V příkladech se objeví pár PHP funkcí, ale na principu to nic nemění, ten se dá využít i v jiných jazycích nebo prostředích (třeba takhle se to dělá v Djangu). Kód zde uvedený je spíš ukázkou, jak takovou věc udělat, rozhodně ho nekopírujte, tohle není Stack Overflow.
Úprava databáze
Ujistěte se, že do sloupečku password
se vejde nový hash, doporučuje se nastavit VARCHAR(255)
nebo podobný typ, který pojme alespoň těch 255 znaků, bude se to hodit i pro případné rozšiřování do budoucna.
Budete potřebovat nový sloupeček, ve kterém bude uložen způsob hashování hesla pro toho konkrétního uživatele. Skript na přehashování (viz dále) může běžet klidně i několik dní, takže v databázi budou staré i nové hashe zároveň a přihlašování s tím musí počítat. Ten nový sloupec pojmenujeme např. type
. Nenastavujte NOT NULL
, hodnota NULL
bude určovat starý hash.
Pokud váš starý hash používá unikátní salt pro každého uživatele (statický salt, stejný pro všechny uživatele, není salt), tak budete ještě potřebovat sloupeček, do kterého tento „starý“ salt uložíte, můžeme mu říkat třeba old_salt
.
Tabulku s přihlašovacími údaji není třeba upravovat, typ a případný starý salt si můžete ukládat do jednoho sloupečku společně s hashem a oddělit je třeba dvojtečkou nebo dolarem a při zpracování si je zase „odseknout“. Pro jednoduchost budu používat samostatné sloupečky.
Skript na přehashování
Vlastní přehashování zajistí skript, který spustíte a on najednou „upgraduje“ všechna hesla. Skript vezme třeba tisíc řádků s type IS NULL
a pro každý provede tuhle operaci:
- Vypočítá nový hash „přehashováním“ starého:
$newHash = password_hash($row->password, PASSWORD_DEFAULT)
- Pokud starý hash používá salt, tak ho uloží do proměnné např.
$oldSalt
. - Provede
UPDATE
v databázi a uloží$newHash
do sloupcepassword
(a případně$oldSalt
do sloupceold_salt
),type
nastaví na1
, ale vše pouze v případě, že typ jeNULL
, abychom nepřepsali heslo změněné uživatelem v době od vytažení dat z databáze do přehashování.
Kód by mohl vypadat nějak takto:
$rows = $db->query('SELECT ... FROM ... WHERE type IS NULL LIMIT 1000');
foreach ($rows as $row) {
$newHash = password_hash($row->password, PASSWORD_DEFAULT);
$oldSalt = ...;
$db->query('UPDATE ... SET password = ?, old_salt = ?, type = 1
WHERE username = ? AND type IS NULL',
$newHash,
$oldSalt,
$row->username
);
}
Doporučoval bych takový skript spustit z příkazové řádky. Může totiž běžet docela dlouho, v případě velkých databází klidně i několik dní. Taky může z nějakého důvodu spadnout a vy ho budete muset spustit znovu. To nebude vadit, s tím se počítá, již přehashovaným heslům se skript vyhne.
Před spuštěním skriptu je potřeba upravit přihlašování, aby počítalo i s novým hashem.
Přihlašování
V databázi budeme mít uložen (nový) hash z původního (starého) hashe, takže do funkce na ověření hesel nebudeme posílat heslo zadané uživatelem do formuláře, ale nejdříve musíme znovu spočítat původní (starý) hash a teprve až ten pošleme na ověření. Ověřování ale musí počítat i se zatím nepřevedenými hesly, jinak by se část uživatelů nemohla přihlásit, dokud se jim heslo nepřehashuje.
K rozhodnutí jak uživatele ověřit využijeme obsah sloupce type
. Neprovádějte ověření hesla nejdřív pomocí „nového hashe přes starý“ a pak, v případě selhání, pomocí starého. To je zbytečně pomalé, využijte raději ten sloupeček. Vůbec nevadí, když je způsob hashování známý, stejně musíte předpokládat, že nepřítel systém zná.
Podstatná část kódu:
$row = $db->query('SELECT ... FROM ... WHERE username = ?', $_POST['username']);
switch ($row->type) {
case null: // starý hash
$verified = hash_equals($row->password, sha1($row->old_salt . $_POST['password']));
break;
case 1: // nový hash přes starý
$verified = password_verify(sha1($row->old_salt . $_POST['password']), $row->password);
break;
default:
$verified = false;
break;
}
Pokud starý hash nepotřebuje salt, tak $row->old_salt
samozřejmě vynechejte. Funkce pro bezpečné porovnávání hashů hash_equals()
je dostupná od PHP 5.6, pokud máte starší, tak upgradujte. V nejhorším případě ji můžete nahradit za obyčejné porovnání $row->password === sha1(...)
, to platí i pro ostatní jazyky.
Takovéhle „skládání“ různých hashovacích funkcí není z kryptografického hlediska úplně čisté, běžně se nedoporučuje a není to moc prozkoumáno, ale v tomto případě je mnohem lepší, než používat slabé hashe pro hesla uživatelů, kteří se dlouho nepřihlásí.
Uložení čistého nového hashe
Po úspěšném přihlášení má aplikace k dispozici heslo v čitelné podobě, takže ho můžeme zahashovat „čistým“ novým hashem a této kryptografické nedokonalosti se zbavit. Využijeme opět sloupeček type
, aby ověřování hesla vědělo, že tentokrát nemá před voláním password_verify()
dělat žádný cviky. V tomto případě určitě nepoužívejte ověřování stylem nejdřív zkusím čistý nový hash, pak nový přes starý a pak starý, šlo by se totiž přihlásit jen hashem nalezeným v nějaké zveřejněné databázi, jak správně podotkl David Grudl.
Připravíme si funkci pro uložení nového hashe, nastavení nového typu (2
pro „čistý“ hash) a případné vynulování starého saltu, už ho nebudeme potřebovat:
function saveNewHash($username, $password)
{
$db->query('UPDATE ... SET password = ? , old_salt = NULL, type = 2 WHERE username = ?',
password_hash($password, PASSWORD_DEFAULT),
$username
);
}
A po ověření hesla pomocí nového + starého hashe ji zavoláme. Můžeme ji volat také po ověření jen pomocí starého hashe, ničemu to vadit nebude a aspoň nepatrně ulehčíme skriptu na převod všech hashů. Dále přidáme větev case 2
pro ověření pouze pomocí nového hashe:
$row = $db->query('SELECT ... FROM ... WHERE username = ?', $_POST['username']);
switch ($row->type) {
case null: // starý hash
$verified = hash_equals($row->password, sha1($row->old_salt . $_POST['password']));
if ($verified) {
saveNewHash($_POST['username'], $_POST['password']);
}
break;
case 1: // nový hash přes starý
$verified = password_verify(sha1($row->old_salt . $_POST['password']), $row->password);
if ($verified) {
saveNewHash($_POST['username'], $_POST['password']);
}
break;
case 2: // pouze nový hash
$verified = password_verify($_POST['password'], $row->password);
break;
default:
$verified = false;
break;
}
Spuštění skriptu
Náš úžasný skript na přehashování můžeme konečně spustit. Doporučuji ho předtím velmi dobře otestovat a případně si udělat zálohu té správné tabulky, kdyby se něco náhodou nepovedlo. Po doběhnutí skriptu můžete odstranit větev case null
z přihlašování, staré hashe by již v databázi neměly být. Dá se to ověřit pomocí SELECT COUNT(*) ... WHERE type IS NULL
, výsledkem by měla být nula.
Pokud jste si udělali zálohu, tak ji nezapomeňte bezpečně smazat. To se týká i všech ostatních pravidelných záloh databáze, ty také zlikvidujte nebo z nich staré hashe odstraňte. Zálohy se velmi často ztrácejí a mohou být zdrojem úniku starých slabých hashů.
Co dál
Nezapomeňte při registraci a změně hesla (i zapomenutého) ukládat pouze nový hash a nastavit typ na „pouze nový“ (v našich příkladech to je type = 2
). Tedy v podstatě to, co dělá námi vytvořená funkce saveNewHash($username, $password)
.
Použití silného (a relativně pomalého) hashe samotnému lámání hesel nezabrání, jen útočníkovi bude trvat příliš dlouho, takže ho to snad přestane bavit. Slabá hesla typu password i přesto získá vcelku rychle (protože budou to první, co vyzkouší), takže by bylo fajn mu v crackování nějak zabránit. Občas se doporučuje přimíchat do hesla tzv. pepper („pepř“, jakože sůl a pepř, chápete), tedy další statickou „sůl“ stejnou pro všechny uživatele. Pravděpodobnost, že by útočník získal databázi i pepper z konfigurace, a mohl tak začít crackovat hesla, je o dost menší, než že získá jen databázi.
Na pepper zapomeňte, hashovací funkce na jeho použití nejsou navržené a na jeho použití neexistuje žádný rozumný výzkum. Stejného efektu se dá dosáhnout zašifrováním hashů (ne hesel), to je navíc kryptograficky čistá operace. Ale o tom zas někdy příště.
V některém dalším článku si také ukážeme, jak transparentně měnit parametry hashovacích funkcí, příp. jak změnit algoritmus z bcryptu na Argon2i. V současnosti je použití bcryptu stále v pořádku, hesla ochrání dostatečně, i když použijete defaultní cost
(měl by být aspoň 10) a na Argon2i se dá přejít třeba až bude v PHP jako PASSWORD_DEFAULT
. Pak to bude stačit udělat způsobem 2), tedy přeuložením po přihlášení, ale nebudeme předbíhat.
Prosím, chraňte hesla svých uživatelů.