Zeyomir's Blog Bo piękniej jest wiedzieć coś o wszystkim…

29Sie/12Off
» «

Bezpieczeństwo aplikacji webowych – przechowywanie haseł

W poprzedniej części pisałem o tym, jak zapobiec wyciekowi bazy danych poprzez zabezpieczenie się przed SQL Injection. Co jednak, jeśli coś gdzieś przy zabezpieczaniu przeoczymy, albo w inny sposób nasza baza 'wypłynie' (np jakiś idiota ustawi hasło 'admin1' do panelu administracyjnego, nastąpi włamanie na maszynę programisty, albo nieopatrznie zostawimy backup bazy w sławnym już 'głębokim ukryciu')? 

W większości przypadków- najważniejsze w naszej bazie są hasła użytkowników (inne poufne dane mogą być zaszyfrowane tym hasłem i tak przechowywane, po zalogowaniu rozszyfrowujemy je i trzymamy w sesji). Więc wypadałoby je dodatkowo chronić, na wypadek takiego właśnie wycieku. Pomysły na przechowywanie haseł są zasadniczo 3:
- czystym tekstem
- szyfrowane
- hashowane

Oczywiście pomysł nr 1 odpada w przedbiegach- nie daje nam żadnego bezpieczeństwa. Pomysł nr 2 jest niezły, ale generuje nowy problem- skoro chcemy coś szyfrować to gdzieś musimy trzymać klucz. I to tak, żeby nie wyciekł razem z bazą- bo wtedy na nic się nie zda. Bezpieczne przechowywanie kluczy do szyfrowania nie jest zadaniem prostym.

Z tego powodu standardem jest hashowanie haseł. Na tym jednak problem się nie kończy, a wręcz dopiero zaczyna. Mamy całkiem sporo algorytmów hashujących do wyboru oraz zbiór 'dobrych praktyk' o których trzeba pamiętać, aby całe to hashowanie miało sens i przyniosło pożądany efekt.

Czym jest hashowanie

Hash jest to wynik działania jednoznacznej, nieodwracalnej funkcji matematycznej (zwanej też funkcją skrótu), i ma stałą długość, zależną od użytego algorytmu (funkcji). Stała długość oznacza, że dla literki 'a' algorytm hashowania 'X' zwróci hash '1234567890', a dla stringa 'abcdefghijklmnopqrstuvwxyz', ten sam algorytm zwróci hash '0987654321', czyli ciąg znaków o długości takiej samej, niezależnej od długości oryginału (stąd 'funkcja skrótu').

Nieodwracalność funkcji oznacza, że o ile wyliczenie hasha z wartości oryginalnej jest stosunkowo proste, to niemożliwe jest uzyskanie wartości oryginalnej z samego hasha (nawet znając algorytm jakim został wyliczony).

Jednoznaczność oznacza, jak łatwo się domyślić, że dla tych samych danych wejściowych, ten sam algorytm zwróci zawsze tę samą wartość (ten sam hash). Dodatkowo funkcje te charakteryzują się dużą 'wrażliwością' na zmiany w wartości oryginalnej- załóżmy, że nasz algorytm X dla stringa 'aaaa' zwraca hash '1234567890'. Mała zmiana wartości oryginalnej powoduje duże zmiany w zwracanym hashu- dla stringa 'aaab' otrzymamy hash '7362019278'.

Używanie hashowania haseł jest dość proste. Kiedy użytkownik się rejestruje- zamiast jego hasła zapisujemy tylko hash wyliczony na jego podstawie. Gdy użytkownik chce się zalogować liczymy hash dla hasła które wysłał i sprawdzamy czy zgadza się z hashem w bazie.

"Złe" algorytmy

Znanych algorytmów hashujących jest sporo, jednak nie są one uniwersalne. Oznacza to, że jedne nadają się świetnie do hashowania haseł a do czegoś innego już niekoniecznie. Inne z kolei nadają się do weryfikacji czy obraz płyty z Linuksem pobrany z mirrora jest zgodny z oryginałem ;), ale totalnie nie nadają się do hashowania haseł. Na początek algorytmy 'złe':
- MD5 - algorytm szybki, dzięki czemu można nim w krótkim czasie wyliczyć sumę kontrolną dla dużych danych wejściowych (obraz płyty ;)), niestety został złamany (tzn da się wyliczyć ciąg znaków inny od oryginału, który wygeneruje ten sam hash, jest to tzw kolizja) i jego używanie jest niezalecane
- SHA-1 - opiera się na podobnych zasadach co MD5, również szybki, również złamany, teoretycznie od 2011 roku nie powinien być już używany w żadnych sytuacjach związanych z bezpieczeństwem
- SHA-2 - następca SHA-1, również szybki, istnieją na niego ataki, ale są na razie mało wydajne i prawie nieszkodliwe dla celów takich jak przechowywanie haseł- nie mniej zalecana jest powolna migracja do bardziej zaawansowanych funkcji skrótu

Głównym powodem dla którego algorytmy te nie nadają się do przechowywania haseł nie jest fakt, że są 'złamane'. Tak naprawdę atakujący nie będzie się bawił w szukanie kolizji, jeżeli w rozsądnym czasie będzie mógł wygenerować hashe dla np wszystkich ciągów 8 znakowych i odszukać pasujące hashe w wykradzionej tabeli- w ten sposób dostanie oryginalne hasła. Jest to tzw atak przy pomocy tablic tęczowych (rainbow tables). Co gorsza, gotowe tablice dla różnych zestawów znaków, długości i algorytmów można pobrać z internetu (chociaż potrafią ważyć po kilka terabajtów...)- w takiej sytuacji odczyt wszystkich oryginalnych haseł trwa dosłownie parę sekund.

Solą w tęczę

Używając tablic tęczowych atakujący może złamać N hashy w ciągu kilku sekund. Nawet jeżeli generuje taką tablicę samodzielnie zamiast ściągnąć ją z internetu- robi to tylko raz, a potem może jej używać ponownie do łamania kolejnych hashy- więc czas jej generowania można pominąć. Samo łamanie hashy polega po prostu na zrobieniu joina między wykradzioną tablicą z hashami i tablicą tęczową i dopasowaniu haseł do użytkowników po pasujących hashach.

Żeby temu przeciwdziałać, musimy nasze hashe 'posolić', czyli dodać tzw sól (salt). Sól jest ciągiem znaków który musi być na tyle długi, żeby mógł być unikalny dla każdego użytkownika (czytaj: im dłuższy tym lepszy). Nie, nie musi być losowy (chociaż najczęściej jest). Nie, nie musi być w żaden sposób ukrywany (najczęściej zapisuje się go plaintextem w bazie, zaraz obok hasha który jest nim posolony). Działa to tak, że gdy użytkownik się rejestruje i podaje hasło- losujemy salt (chociaż nie musimy- równie dobrze możemy użyć jako salta np adresu email połączonego z ID użytkownika), który zapisujemy w bazie przy nazwie nowego użytkownika, a następnie liczymy hash z hasła i salta (czyli hash(hasło + salt);) i zapisujemy ten hash do bazy. Kiedy użytkownik chce się zalogować, wysyła login i hasło, a my odczytujemy jego salt, liczymy hash z wysłanego hasła i odczytanej soli i sprawdzamy czy zgadza się z hashem w bazie.

Normalnie dwóch użytkowników z tymi samymi hasłami ma takie same hashe. Gdy używamy unikalnych soli, nawet jednakowe hasła generują różne hashe. Dzięki temu tablice tęczowe stają się bezużyteczne- żeby ich używać, trzebaby wygenerować osobną tablicę dla każdej soli (czyli dla każdego użytkownika, przecież sól jest unikalna), co mija się z celem, bo sprowadza się do łamania hashy bruteforcem.

Szybki algorytm to zły algorytm

Dlaczego więc wymienione wcześniej algorytmy tak naprawdę nie nadają się do hashowania haseł? Przecież SHA-2 z solą powinno być bezpieczne... Problem polega na tym, że te algorytmy są zaprojektowane tak, by były szybkie. A szybkość to ostatnie czego chcemy w przypadku hashowania haseł! Im szybszy algorytm- tym szybciej atakujący jest w stanie złamać hashe. Użytkownikowi różnica między 10ms a 100ms różnicy nie zrobi, ale atakującemu różnica między jednym dniem a 10 dniami już jak najbardziej tak.

Owszem, wygenerowanie nawet jednej tablicy tęczowej dla 8 znakowych haseł alfanumerycznych dla SHA-2 potrafi trwać dość długo na 'zwykłym' domowym komputerze (a przecież używamy soli, więc trzeba wygenerować taką tablicę dla każdego użytkownika). Ale kto powiedział, że atakujący będzie używał do tego celu zwykłego domowego komputera? SHA-2 może być bardzo wydajnie zaimplementowane dla GPU (przyspieszenie rzędu 20x jest spokojnie do osiągnięcia). Można temu przeciwdziałać, zwiększając ilość iteracji, czyli wyliczyć hash dla hasła z saltem, potem do tego hasha znowu dodać salt i wyliczyć nowy hash i tak powiedzmy... 500 razy.

Co prawda, może to ułatwić liczenie kolizji, ale zastanówmy się co chcemy chronić- dostęp do konta użytkownika w naszym serwisie czy hasło użytkownika którego mógł użyć też gdzieś indziej? Skoro ktoś ma naszą bazę danych, to można założyć, że jest w stanie ją modyfikować (czy to przez dostęp do panelu admina czy to przez SQLi)- więc dostęp do konta pojedynczego użytkownika (a zatem znalezienie kolizji dla jego hasła) nie jest mu do niczego potrzebny. Zresztą po wykryciu włamaniu i tak powinniśmy wymusić zmianę haseł u wszystkich naszych użytkowników (czyli nie powinni mieć możliwości zalogowania się starym hasłem, zamiast tego powinni dostać email z informacją na temat włamania, instrukcjami co mają dalej robić i unikalnym linkiem do zmiany hasła).

Priorytetem więc jest to, by atakujący nie poznał oryginalnego hasła, którego mógłby użyć do włamania na konta użytkownika w innych serwisach. A nawet nie tyle 'nie poznał', co 'poznał później'- ostatecznie dysponując wystarczającą ilością czasu i sprzętu będzie on w stanie zawsze znaleźć oryginalne hasło (niezależnie od jego stopnia skomplikowania ani zastosowanego sposobu przechowywania). Ale jeśli pozna je za rok zamiast za tydzień, to damy czas sobie na odkrycie włamania (co nie zawsze jest takie oczywiste) i powiadomienie użytkowników oraz wymuszenie na nich zmiany haseł, a użytkownikom czas na ewentualną zmianę takiego samego hasła w innych serwisach.

Wielokrotne hashowanie spowalnia proces łamania hasła z H+x do n*H+X, gdzie H to czas wykonania jednej tablicy tęczowej/czas złamania jednego hasha przy pojedynczym hashowaniu, n to ilość powtórzeń hashowania, x to czas potrzebny na znalezienie sposobu wyliczania hasha (czy sól dodajemy przed czy po haśle, a może z obu stron, jakiego algorytmu używamy, czym jest sól itp), a X to x + czas potrzebny atakującemu na odgadnięcie liczby iteracji. Zarówno x jak i X trwają krócej, jeśli do naszego serwisu może się zarejestrować każdy (atakujący rejestruje się z hasłem 'aaaaaaaa', a potem szuka różnych kombinacji tak długo aż dostanie swój hash z bazy) lub atakujący ma dostęp do produkcyjnego kodu źródłowego naszej aplikacji (trochę pomaga obfuskacja kodu przed wrzuceniem na produkcję oraz używanie częściowo kompilowanych języków jak Java czy C#- zawsze to więcej kombinowania niż z językiem skryptowym).

Co do wartości x jeszcze, ktoś może zarzucić, że poleganie na utajnionym sposobie wyliczania hasha (sposób doklejania soli, niejawne jej przechowywanie, nieznana ilość iteracji) jest naiwne i podpada pod 'security by obscurity' ('bezpieczeństwo poprzez ukrywanie'- antywzorzec zarówno w świecie kryptografii jak i bezpieczeństwa jako takiego). Wg mnie nie jest to prawda- a dlaczego, wyjaśnię na końcu wpisu.

Dobre algorytmy

Powyżej mamy więc przedstawione dwa sposoby na zwiększenie bezpieczeństwa, których można używać do wzmocnienia 'słabych' algorytmów. Czym więc powinny się charakteryzować 'dobre' algorytmy? Po pierwsze powinny mieć wbudowane używanie soli. Po drugie, powinny mieć wbudowane powtarzanie wielu iteracji hashowania. Po trzecie powinny być w miarę odporne na przyspieszanie ataku przy użyciu wyspecjalizowanego sprzętu. I okazuje się, że takie algorytmy istnieją :).
- PBKDF2 - algorytm aktualnie zalecany przez NIST, stosowany m.in. w TrueCrypcie, BlackBerry i WPA2;
- bcrypt - algorytm hashowania opierający się na (powolnym) algorytmie kryptograficznym blowfish, z tego powodu lepszy niż PBKDF2
- scrypt - algorym dość nowy, jeszcze lepszy niż bcrypt ze względu na większą złożoność pamięciową

Każdy z tych algorytmów przyjmuje jako parametr ilość iteracji do wykonania. Wraz ze zwiększającą się prędkością procesorów- wystarczy zwiększać ten parametr. Nie będę się tutaj wdawał w szczegóły tych algorytmów, bo to materiał na osobny post (a właściwie kilka :P), postaram się je tylko krótko scharakteryzować.

PBKDF2 (Password Based Key Derivation Function 2, gdybyś miał problemy z zapamiętaniem tych literek ;)) w większości implementacji używa wewnątrz SHA-2. Oznacza to, że tak samo można go przyspieszyć używając GPU. To bardzo niedobrze.

Bcrypt używa wewnątrz algorytmu hashującego opartego na algorytmie kryptograficznym blowfish. Blowfish wykonuje dużo operacji korzystających z pamięci, dzięki czemu nie da się go przyspieszyć używając GPU (GPU jest świetne w operacjach logicznych i arytmetycznych, ale jeśli chodzi o dostęp do pamięci- jest powolne). To bardzo fajnie :). Jednak GPU to nie koniec możliwości atakującego- używając układów FPGA można złożyć 'maszynkę' która da radę złamać nasze hashe w przystępnym tempie. To już mniej fajnie.

Scrypt ma dużą złożoność zarówno obliczeniową jak i pamięciową. Dzięki temu nie da się go przyspieszyć nawet używając układów FPGA (niewystarczająca ilość RAMu). Jest jednak dość młody, a co za tym idzie nie jest jeszcze dostatecznie dokładnie przebadany.

Z którego korzystać? Ja polecam bcrypta- jeżeli ktoś jest tak zawzięty na złamanie Twoich hashy, że kupuje do tego specjalną elektronikę, to i tak masz przerąbane ;). Jednak opłaca się śledzić losy scrypta, bo może za rok czy dwa będzie na tyle dojrzały, żeby móc go bez obaw używać. Dla formalności tabelka:

Co jeszcze można zrobić?

Dodatkowym sposobem zabezpieczania hashy jest przyprawienie ich jeszcze bardziej ;). Oprócz soli używamy również pieprzu. Pieprz (pepper) jest to losowy ciąg znaków, trzymany gdzieś poza bazą (np pliki konfiguracyjne, zmienna środowiskowa na serwerze, zahardcodowany w kodzie źródłowym, lub po części w każdym z tych miejsc). Z założenia ma być tajny. Doklejamy go do każdego hasła tak jak dodawaliśmy salt w przypadku SHA-2.

Przyjmijmy, że mamy 16 znakowy pepper i 8 znakowe hasło. Jeżeli atakujący wykradnie tylko bazę danych, to czeka go dłuuugie łamanie, ponieważ dla niego, hasło będzie 24 znakowe. Jeżeli uda mu się skompletować pieprz i poznać algorytm jego dodawania, to wszystko działa tak, jakby pieprzu nie było- więc nic nie tracimy, a zyskujemy czas, który atakujący musi poświęcić na zdobycie tych wszystkich rzeczy. Ktoś może powiedzieć, że to przypomina problem z przechowywaniem klucza i szyfrowaniem haseł. Nie jest to prawda- tam gdy klucz zostanie odkryty, tracimy całą obronę. Tu jest to tylko dodatek i nawet po jego odkryciu, hasła nadal są dobrze zabezpieczone. Pozostaje jeszcze problem taki, że znowu śmierdzi to 'security by obscurity'...

No cóż, obiecałem wyjaśnić. Ogólnie rzecz biorąc, opieranie bezpieczeństwa na tajności algorytmu szyfrowania/hashowania jest złym pomysłem. Tyle tylko, że (według mnie) w naszym wypadku nie może być o tym mowy. Nie polegamy na tajności tych algorytmów. Nawet kiedy atakujący pozna wartość pieprzu oraz to w jaki sposób dodajemy go do hasła, nadal stoi przed takim samym problemem łamania hashy, przed jakim stałby gdybyśmy w ogóle nie używali pieprzu. To samo tyczy się wymyślnego sposobu doklejania salta w przypadku starych funkcji skrótu.

Uwagi ogólne

Nigdy nie pisz własnego algorytmu hashowania czy szyfrowania. Zostaw to naukowcom. Grupa naukowców wymyśliła np SHA-1, następnie był przez lata testowany i uznany za standard, a później i tak ktoś znalazł na niego atak. Głupio byłoby sądzić, że Tobie pójdzie lepiej. Używaj sprawdzonych algorytmów.

Nigdy nie próbuj samemu zaimplementować tych algorytmów. Tzn w ramach ćwiczeń- jasne. Ale nie na potrzeby kodu produkcyjnego. Prawie na pewno popełnisz gdzieś błąd w swojej implementacji, a błędy te jest ciężko zauważyć. Po prostu skorzystaj z gotowej biblioteki.

Nie licz na to, że hasła użytkowników nie zostaną odgadnięte. Nawet używając scrypta z ogromną ilością iteracji. Dostatecznie zdeterminowany atakujący może wykupić czas obliczeniowy w chmurze, może posiadać duży botnet, albo po prostu poprosić ludzi o pomoc na forum (jak w przypadku ostatniego wycieku bazy z LinkedIn).

TL;DR;

Używaj bcrypta z dodawanym do haseł pieprzem (najlepiej podzielonym na części i rozrzuconym w różnych miejscach) i ilością powtórzeń tak dużą jak tylko możesz sobie pozwolić, żeby nie zabić swojego serwera. Skorzystaj z gotowej biblioteki. Hashowanie ma tylko spowolnić atakującego i dać użytkownikom czas na zmianę haseł. Pamiętaj, by powiadomić użytkowników o wycieku i wymusić zmianę haseł w swoim serwisie oraz zasugerować taką zmianę wszędzie gdzie używali takiego samego hasła.

» «
Komentarze (4) Trackbacks (0)
  1. Świetne :) Bardzo dużo dla mnie nowych informacji, no i przyjemnie się czytało!

  2. Bardzo dobry artykuł. Wygrałeś – dodaję Ciebie do RSSa. 😉

  3. Dzięki za ten wpis. Naprawdę fajnie opisane. Pomogło mi to trochę przy pisaniu inżynierki :)


Trackbacks are disabled.