5. září 2017 (aktualizováno 2. srpna 2019)

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ě, tedy ještě před únikem, aby byla chráněna všechna hesla ve vaší databázi? Pojďme si ukázat jak to udělat.

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 (nejspíš je cracknul), 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:

SHA-1 → bcrypt nebo Argon2id

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í: bcrypt, Argon2 (varianta Argon2id nebo Argon2i, ale jen pokud chcete, aby hashování hesla trvalo více než sekundu, pro rychlejší použijte 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 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 nebo Argon2id, 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(). Viz např. výsledek z volání password_hash('foo', PASSWORD_DEFAULT) (což je aktuálně stejný výstup jako z password_hash('foo', PASSWORD_BCRYPT), ale hodnota PASSWORD_DEFAULT se v budoucnu bude měnit):

 bcrypt (2y)
 ┌┐ cost (2¹⁰ = 1024 opakování)
 ││ ┌┐
$2y$10$7REcgj13ZZTW9XSYGWfZVODMB0uIPn3c2jZmse1kjz7LHGzTdUnGm
       └────────────────────┘└─────────────────────────────┘
            128-bit salt              184-bit hash
                  obojí nestandardní Base64

Součástí výstupu je také kryptografická sůl (salt), automaticky a správně vygenerovaná funkcí password_hash(), ručně tedy žádnou další přidávat nemusíte a ani byste neměli, algoritmus byste tím mohli nějak poškodit. Implementace bcryptu v PHP navíc heslo ořízne na maximálně 72 znaků (to nevadí), takže kdybyste před heslo připojili 80 znaků soli, tak k přihlášení nebude heslo vůbec potřeba. O sůl se tedy nemusíte starat, přenechte to funkcím password_hash() a password_verify().

Od PHP 7.2 je dostupný algoritmus Argon2i (PASSWORD_ARGON2I), doporučovaná varianta Argon2id (PASSWORD_ARGON2ID) je v PHP od 7.3. Výstup z password_hash(..., PASSWORD_ARGON2ID) vypadá takto:

  algo.   ver.   parametery
┌───────┐ ┌──┐ ┌────────────┐
$argon2id$v=19$m=1024,t=2,p=2$<128-bit salt>$<256-bit hash>

Salt i hash jsou zakódované do Base64. Verze v je 1.3 (nejnovější verze Argon2 v době vývoje PHP 7.3) reprezentovaná jako decimální číslo 19, hexadecimálně 0x13. Parametry jsou následující:

  • „Memory cost“ m udává paměťovou náročnost algoritmu (v kilobajtech, od 8p do 232 – 1)
  • „Time cost“ t, který určuje počet iterací a tím i jak dlouho se bude hashovat (od 1 do 232 – 1)
  • A „faktor paralelnosti“ p určující počet vláken (od 1 do 16777215)

V PHP budou mít parametry tyto výchozí hodnoty: 1024 kB zabrané paměti, 2 iterace, 2 vlákna. Generování soli opět nechte na password_hash().

Ale zpátky k vylepšování hashování hesel již zaregistrovaných uživatelů. Pokud to chcete udělat, tak máte tyto možnosti:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. Vypočítá nový hash „přehashováním“ starého:
    $newHash = password_hash($row->password, PASSWORD_DEFAULT)
  2. Pokud starý hash používá salt, tak ho uloží do proměnné např. $oldSalt
  3. Provede UPDATE v databázi a uloží $newHash do sloupce password (a případně $oldSalt do sloupce old_salt), type nastaví na 1, ale vše pouze v případě, že typ je NULL, 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 pomocí password_needs_rehash() transparentně měnit parametry hashovacích funkcí, příp. jak změnit algoritmus z bcryptu na Argon2i (nebo Argon2id, který bude v PHP 7.3). 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 (nebo -id) 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.

Pro ověřování hesel při přihlašování, zvlášť pokud to chcete mít hotové do sekundy, je podle Jeremiho a Steva z komise Password Hashing Competition bcrypt dokonce pořád vhodnější než Argon2.

Prosím, chraňte hesla svých uživatelů.


Mohlo by vás také zajímat

Aktualizace článku

2. srpna 2019 bcrypt je vhodnější než Argon2 pro hashování, které má být kratší než 1s

16. listopadu 2018 Přidán popis výstupu password_hash() pro Argon2id

31. ledna 2018 Přidán popis výstupu password_hash()

Michal Špaček

Michal Špaček

Vyvíjím webové aplikace, zajímá mě jejich bezpečnost. Nebojím se o tom mluvit veřejně, hledám hranice tak, že je posouvám. Chci naučit webové vývojáře stavět bezpečnější a výkonnější weby a aplikace.

Veřejná školení

Zvu vás na následující školení, která pořádám a vedu: