Přeskočit na hlavní obsah

Nenechte si uhodnout Session ID

Při práci se sessions si mezi sebou server a klient neustále vyměňují SID. Jedná se o náhodně vygenerovaný token, podle kterého si server páruje dohromady jednotlivé požadavky konkrétního návštěvníka. Kdo zná SID, ten má přístup i k celé příslušné session.

Protože se na sessions obvykle váží takové citlivé věci, jako je přihlášení uživatele, stává se bezpečnost SID jedním z kritických míst aplikace. A jak ukážu v závěru článku, problém je obecnější a netýká se zdaleka jen sessions.

Uhodnout, spočítat, vymlátit

Útočník může získat token své oběti v podstatě třemi způsoby:

  1. spočítat či uhodnout její existující SID,
  2. odchytit její existující SID, například z probíhající komunikace či z logů (session hijacking),
  3. vygenerovat nové SID a oběti ho podstrčit (session fixation).

V tomto textu se zabývám první možností, zbylé dvě si zaslouží někdy v budoucnu každá přinejmenším jeden vlastní článek.

Základní pravidla pro tvorbu SID

Aby se minimalizovalo nebezpečí, že nám útočník naše SID zvenčí nějak spočítá, uhodne či odhalí pomocí útoku hrubou silou, je dobré při jeho generování respektovat následující principy. SID by mělo být:

Unikátní
Každá návštěva by měla dostat nový dosud nepoužitý token. Nemělo by se tedy stát, že se někomu přidělí již existující identifikátor.
Náhodné
Nemělo by být možné vyvolat či napodobit stejné okolnosti, které povedou k vygenerování stejného tokenu, jakou má již přidělenou oběť. Například pokud by se SID generovalo z IP adresy a User-Agenta, bylo by vytvoření vhodných okolností triviální.
Nezávislé
SID by nemělo být odvozeno od žádných smysluplných údajů, mělo by se jednat opravdu o náhodný identifikátor. V rámci tokenu by tedy neměly být žádné věci, jako je uživatelské jméno, IP adresa, aktuální timestamp, už vůbec ne citlivé informace, jako je například heslo. Token by neměl být od žádného podobného údaje ani nijak odvozen.
Typickou chybou je například generování SID jako md5(time()). Takto vytvoření identifikátor je odhadnutelný ve velice krátké době, protože je nutné pomocí útoku hrubou silou otestovat relativně málo možností.
Neodvoditelné
Když si vezmu deset již existujících SID, neměl bych najít žádný systém, pomocí kterého dovedu určit jedenáctý platný token. Jinými slovy funkce, která SID generuje, by měla vykazovat velkou míru entropie.
Překvapivě častý příklad chybného postupu je, že se tokeny generují jako lineární číselná řada. Příští platný token se pak dá snadno spočítat jako další číslo v řadě.
Dostatečně dlouhé
Útok hrubou silou je založen na tom, že útočník zkouší systematicky jeden identifikátor za druhým, dokud se netrefí. Čím je SID delší, tím více možností musí vyzkoušet. Bezpečný token má takovou délku, při které je tento způsob útoku v rozumném časovém horizontu výpočetně neuskutečnitelný.
Expirující
Při neomezené časové platnosti dávám útočníkovi možnost zkoušet brute-force attack neomezeně dlouho. Je tedy důležité platnost nepoužívaného tokenu časově omezit.
Timeout se nejčastěji nastavuje na dobu někde mezi 5 a 30 minutami. V případě aktivní používané session je zase vhodné její SID průběžně obměňovat.

Jak tedy SID vygenerovat?

Standardní obsluha sessions, která je v PHP, uvedená pravidla vesměs zohledňuje. PHP aplikace, které používají standardní obsluhu sessions, jsou tedy samy od sebe relativně odolné, vše se děje transparentně a není u nich potřeba cokoliv řešit. Myslím ale, že i přesto je dobré si tyto na pozadí skryté vztahy uvědomit a ujasnit.

Někdy vás ale přeci jen může přepadnout potřeba si nějaký token generovat svépomocí. Základním pravidlem pak je vyhnout se psaní vlastních sofistikovaných algoritmů, které budou token nějak vymýšlet. Dost pravděpodobně totiž některý z výše uvedených principů porušíte a oslabíte tak bezpečnost celé aplikace.

Místo toho využijte klasický ověřený postup, kde se pomocí uniqid() a rand() či lépe mt_rand() vygeneruje pseudonáhodný řetězec. Pro případ, že by ve stejný okamžik přišlo více různých dotazů, se navíc zkombinuje s IP adresou klienta. Nakonec se to celé prožene skrz nějakou hashovací funkci:

$token = md5(uniqid(mt_rand()) . $_SERVER['REMOTE_ADDR']);

Ve starších verzích PHP nezapomeňte na začátku inicializovat generátor pseudonáhodných čísel funkcí srand() respektive mt_srand().

Více než jen sessions

Proč to ale celé píšu, když nám celou obsluhu sessions zajišťuje transparentně samo PHP? Nejde tu totiž zdaleka jen o sessions a jejich SID. Úplně stejné principy platí i pro jakékoliv další tokeny, které ve své aplikaci můžete využívat. Například:

  • pro ověření nějaké akce jako ochrana proti CSRF,
  • jako ověřovací klíč při e-mailovém potvrzování,
  • pro zapamatování si aktuálního uživatele a zajištění permanentního přihlášení.

A ve všech těchto případech už si musíte nějaký ten identifikátor generovat a obhospodařovat sa­mi.

Obzvláště poslední příklad představuje potenciální slabinu většiny dnešních aplikací, které permanentní přihlášení nabízejí, protože tento token už z principu nemůže expirovat a mimo jiným tak poskytuje i velký prostor právě pro brute-force attack. Pak musí nastoupit další ochranné mechanizmy, které možnost útoku hrubou silou omezí.

Na hrubý pytel hrubá záplata

Nejjednodušším opatřením je ještě prodloužit token, jehož otestování pak bude trvat o několik řádů déle. Obdobně můžeme cíleně prodlužovat prodlevy mezi jednotlivými odpověďmi na neúspěšné pokusy, čímž časová náročnost útoku opět rapidně vzrůstá. Také lze omezit počet unikátních tokenů, které se v daný okamžik uplatňují z jedné IP adresy.

Pokročilejší ochranu pak představuje detekce útoku hrubou silou a jeho následné odvracení. To se může dít v podobě obrany před DoS útokem třeba na úrovni firewallu či s pomocí modulu mod_dosevasive pro Apache, kdy lze přistoupit až k zablokování dané IP adresy, zahazování příchozích paketů apod.

Specialitkou jsou tokenové pasti. Jsou založené na myšlence, že si v sadě všech možných tokenů určíte některé, které nemohou být nikdy vygenerovány jako platné. Například řekněme ty, které začínají na nulu. Generovat nový token pak budete třeba takto:

do {
    $token = md5(uniqid(mt_rand()) . $_SERVER['REMOTE_ADDR']);
} while ($token{0} == '0');

Tím zajistíte, že žádný z vygenerovaných tokenů na nulu začínat nebude. Důležité je, že zvolené pravidlo musí zůstat v tajnosti. Útočník, který bere jeden identifikátor za druhým, pak samozřejmě dříve či později zkusí otestovat i některý z neplatných tokenů. V okamžiku, kdy vám od uživatele přijde dotaz s tokenem začínajícím na nulu, hned víte, že se jedná o brute-force attack a můžete dotyčného bez milosti zablokovat.

Seriál o bezpečnosti sessions

  1. Nenechte si uhodnout Session ID (právě čtete)
  2. Session hijacking aneb ukradení Session ID
  3. Sidejacking aneb nasloucháme v síti
  4. Session ID do URL nepatří
  5. Předávání SID pomocí cookies
  6. Bráníme se zneužití ukradeného SID
  7. Session fixation aneb nenechte si podstrčit SID

Komentáře

  1. 1. Z jakého důvodu při generování tokenu používáš funkci md5()?

    2. Proč pro identifikátor trvalého přihlášení nepoužít session_id()?

  2. [1] Samotné uniqid() použít nelze, protože porušuje některé výše uvedené principy, zejména princip neodvoditelnosti, protože generuje lineární řadu. Je tedy nutné ho převést do nějaké nelineární podoby. Pro tento účel jsou jednocestné funkce ideální, protože výsledné tokeny nelze převést zpět na původní lineární řadu.

    A proč zrovna md5? Proč ne? Její oslabená bezkoliznost zde vůbec ničemu nevadí, takže je pro daný účel stejně dobrá jako jakákoliv jiná hashovací funkce. Lze samozřejmě podle libosti použít i jinou funkci, například SHA1.

    [1] K identifikátoru trvalého přihlášení – pro evidenci trvalého přihlášení osobně používám úplně jinou speciální trvalou cookie, protože vazbu mezi ní a daným uživatelem si eviduji zvlášť a ne v sessions. Je to praktické a systémovější. Jinak jsem ale v závěrečné části článku psal o tokenech obecně, takže tam session_id() použít nejde.

  3. Splňuje mt_rand tyto požadavky? Já jsem se kdysi bezpečným generováním tokenu zabýval. Ještě jsem neznal mt_rand, tak jsem použil zpožděnou náhodu. (Tabulka v DB, ze které náhodně vyberu řádek a následně jej pak změním) Ale dokonalé to asi nebylo, protože útočník může udělat dostatečné množství požadavků, čímž nechá v krátkém čase vygenerovat zcela novou tabulku.

  4. nad tímhle jsem si jednu dobu docela pohrál, stal se ze mě díky tomu docela slušný paranoik :D Pořád jsem musel vylepšovat zabezpečení…

  5. [2] 1. Já zastávám názor, že správný token je prostě náhodné číslo. Čím delší a náhodnější, tím lepší, ale nějaké hešování a přidávání uniqid() je jen divadýlko okolo.

    Pokud je totiž algoritmus generování tokenů známý (a dobré bezpečnostní postupy se na tajnost algoritmu nespoléhají), tak přidání uniqid() přidá jen velmi malý prostor, který je potřeba prozkoumat. Přidání md5() nepřidá dokonce žádný prostor. Takže ať použijeme mt_rand() nebo md5(uniqid(mt_rand())), je potřeba prozkoumat skoro stejný prostor.

    [2] 2. Úplně jiná trvalá cookie to samozřejmě být může, proč by ale nemohla mít hodnotu session_id()?

    [3] Mersenne Twister je považován za kvalitní generátor pseudonáhodných čísel. Komu by to nestačilo, může použít Unixový /dev/random, který dokonce zdržuje v případě, že nenashromáždil dost náhodných dat. Obálka pro PHP nad tímto souborem je k dispozici na http://php.vrana.cz/nahodna-cisla.php

  6. Proc vymyslet neco co uz je davno implemtovano.
    Session_id je dost bezpecne a generuje dostatecne nahodne tokeny.

    „Důležité je, že zvolené pravidlo musí zůstat v tajnosti“
    – takze to tvoje uz v tajnosti neni, co? :-)

  7. ahoj,

    ..while ($token{0} == ‚0‘);

    pokud by šlo o distribuovaný útok, tak útočník může snadno odhalit tvoje pravidlo. proto by bylo lepší:

    ..while (moje_tajna_checksum_funkce($token) == ‚0‘);

  8. Jen drobnost – pokud generátor pseudonáhodných čísel se inicializuje pouze podle aktuálního času, může dojít k tomu, že dva requesty probíhají (z pohledu rozlišovací schopnosti té časové fce) ve stejný okamžik a dojde k vygenerování stejného ID. Není proto špatné přidat ještě entropii například v podobě: md5(xyrand() . $_SERVER[‚REMOTE_ADDR‘]) apod. Tady je (ad [5]) už použití hashovací funkce nutností, protože přídavek obvykle nesplňuje podmínky náhodného čísla.

    Jak je který generátor implementovaný je nelepší mrknout do zdrojáků PHP. Spoléhat se na dostatečnou přesnost generátoru času nelze, podobný omyl způsobil, že na Pentiích nad 200 MHz přestaly fungovat aplikace psané v Turbo Pascalu.

  9. [5][6] Když to bereš takhle, tak cookie pro permanentní přihlášení skutečně může mít hodnotu SID, tomu nic nebrání. Jak jsem ale už psal, pro jakýkoliv token obecně to vhodné není.

    [6][7] S nulou na začátku to byl samozřejmě jenom jednoduchý ukázkový příklad pro účely článku.

    [8] Otázka je, jak moc pravděpodobná tahle situace je. Nicméně dobrá, buďme ještě papežštější než papež. Pro případ paralelního běhu aplikace na více strojích najednou lze navíc přidat ještě IP adresu daného serveru. Pro případ současného přístupu dvou lidí za jednou IP adresou můžeme přidat třeba User-Agenta. Další nápady? :)

  10. [9] zkusme to spočítat. Z praxe mám vyzkoušené, že návštěvnost/počet zobrazení ve špičce/hod odpovídá asi desetině až osmině návštěvnosti/pageviews za den. Takže server s (malým) počtem 10.000 stránek za den má ve špičce cca 10.000/8 = 1250 stránek. Tj cca stránku za každé tři vteřiny.

    Dále podle zdrojáků PHP se generátor inicializuje pomocí funkce gettimeofday(), která vrací čas s přesností na mikrosekundy. Otázka je, s jakým rozlišením skutečně pracuje. Dejme tomu, že s plným. Pak by pravděpodobnost byla 1/3mil. Vzhledem k návštevnosti by ke kolizi došlo každých 300 dní.

    Na rovinu, ve hře je ještě několik dalších faktorů, které mohou celý ten výpočet rozcupovat na kousky. Ale něco na tom možná bude, protože jsem si právě teď při průzkumu zdrojových kódů PHP všiml, jak se v souboru session.c generuje SID:

    hashovaci_fce(REMOTE_ADDR . microtime . combined_lcg)

    :-)

  11. [10] Díky za postřeh, v uvedeném smyslu jsem aktualizoval text článku.

  12. tady to, ale není za jistých okolností k ničemu. Je třeba pokusit se zabezpečit vše i z jiných hledisek

  13. abych byl konkrétnější – pokud bude mít „někdo“ dostatečné znalosti a s tím dostatečné možnosti, tak tě před prvotním brutal atakem nic neochrání (chci se robotem přihlásit – a jak toto ochránit neřešíme) a pokud budeš blokovat IP adresy můžeš zavařit serveru (požadavků na přihlášení budu mít z proxin mnoho a uděláš si to sám). Otázku mám zda se o prolomení bude někdo snažit. – výsledek těchto kroků mi tedy dávají jedinný možný způsob jak získat přístup do aplikace – vyplňovat ti formulář pro přihlášení – a pokud jsme u toho toto vyřešit znamená provozovat přihlašovací skript na neznámé doméně. (zajímal by mě tvůj názor, ale znáš to…)

  14. [8] Muzu potvrdit. Sice nechapu jak se to mohlo stat, ale na pokud jsem generoval jmena uploadovanejch souboru jako sha1(microtime()) dochazelo ke kolizim.

  15. [14] Samozřejmě, protože hašovací funkce má stejný výstup pro stejný vstup – pokud tedy v jednom microtime přijde více požadavků, tak bude mít hašovací funkce 100% kolizní stav. V podobném případě je dobré přidat nějaké unikátní ID k hašovanému základu.