O jakich aplikacjach mówię
Rozpocznijmy od zdefiniowania obiektu naszych rozważań. W tym wypadku chodzi o aplikacje mobilne, czyli aplikacje instalowane na urządzeniu mobilnym użytkownika. Nie chodzi o "mobilne" wersje zwykłych aplikacji webowych, czyli te same aplikacje co dla "zwykłej" przeglądarki, tylko o nieco zmodyfikowanym wyglądzie w taki sposób, by ich użycie na urządzeniu mobilnym było wygodniejsze. Nie ma natomiast większej różnicy, czy aplikacja ta jest aplikacją natywną dla danego urządzenia, czy też aplikacją hybrydową.
Aplikacje, o których chcę mówić, do swojego działania wymagają kontaktu z serwerem. Nie interesują mnie aplikacje, które działają kompletnie lokalnie, wyłącznie na urządzeniu użytkownika. Chodzi mi o ten przypadek, w którym aplikacja lokalnie właściwie tylko realizuje GUI i różnego rodzaju "wodotryski", natomiast sama logika biznesowa jest realizowana po stronie serwera. Do wykonania określonych akcji aplikacja musi wywołać określone metody z udostępnianego przez serwer API. I to właśnie po stronie serwera leżą wszystkie potencjalne zasoby/wartości, do których dostęp może chcieć zdobyć nasz potencjalny atakujący.
Oczywiście, by móc mówić o wartościach, które chce zdobyć atakujący, musi być tak, że użytkownicy różnią się między sobą, mają dostęp do różnych funkcji, różnych danych i różnych innych zasobów. Skoro tak jest, cały system musi umożliwiać identyfikację i uwierzytelnienie użytkownika. I właśnie błędy w tym mechanizmie bezpieczeństwa, uwierzytelnieniu, będę tym razem rozważać.
By nasz hipotetyczny cel nabrał jakichś bardziej konkretnych kształtów ustalmy, że jest to aplikacja mobilnej bankowości internetowej. Przykłady tu podawane mają swój pierwowzór w prawdziwych aplikacjach, ale z oczywistych powodów pewnych szczegółów, na przykład nazw aplikacji, nie podam. Poza tym moim celem jest pokazanie kilku typowych klas błędów, z którymi się spotkałem, a nie demonstrowanie wykorzystania konkretnej podatności w konkretnej aplikacji.
Specyfika aplikacji mobilnych
Ponownie chcę zwrócić uwagę na fakt, że z aplikacjami mobilnymi są w pewnym stopniu specyficzne. Przede wszystkim trzeba sobie uświadomić, że aplikacja taka właściwie w całości działa w środowisku kontrolowanym przez atakującego. W związku z tym atakujący ma przede wszystkim możliwość analizy aplikacji, zarówno statycznej poprzez analizę jej kodu, jak i dynamicznej poprzez śledzenie jej wykonania. Nie można więc zakładać, że atakujący nie będzie wiedział czegoś na temat działania aplikacji i na tym założeniu opierać jej bezpieczeństwo. W przypadku kryptografii dokładnie o tym mówi zasada Kerckhoffsa.
Atakujący może nie tylko analizować aplikację, ale również wprowadzać do niej dowolne modyfikacje, w szczególności może napisać własną aplikację, która od strony API po stronie serwera będzie całkowicie nie do odróżnienia od aplikacji oryginalnej. Ten fakt może mieć pewne znaczenie dla rozważanego tutaj tematu uwierzytelnienia użytkownika, aczkolwiek nie jest on kluczowy. Trzeba jednak o nim pamiętać i z góry odrzucać wszystkie założenia opierające się na tym, że użytkownik będzie korzystał wyłącznie z oryginalnej wersji udostępnionej aplikacji, oraz że system będzie w stanie rozpoznać i zablokować aplikacje w jakimś stopniu zmodyfikowane.
Tu przy okazji warto wspomnieć o nieudanych próbach stworzenia narzędzia, które miało blokować dekompilację aplikacji systemu Android do kodu Java: Unhosing APKs. Zresztą kod aplikacji w postaci kodu Java wcale nie jest tym, co przy analizie takiej aplikacji jest najbardziej przydatne. Smali jest wystarczającą postacią, a dodatkowo często bardziej precyzyjną (bardziej odpowiadającą zachowaniu aplikacji), niż kod Java uzyskany po dekompilacji aplikacji.
Telefon to nie jest bezpieczne urządzenie
Kolejną istotną rzeczą jest fakt, że telefon to nie jest "bezpieczne urządzenie" i nie możemy polegać w pełni na oferowanych w obrębie danej platformy funkcji bezpieczeństwa i oczekiwać, że będą one działać w sposób zgodny ze specyfikacją. Podobnie jak w przypadku zwykłych systemów operacyjnych i zwykłych komputerów, również w przypadku urządzeń mobilnych musimy uwzględnić przypadek, w którym na urządzeniu będzie działał wrogi kod (malware). Teoretycznie system operacyjny urządzenia zapewnia separację między różnymi aplikacjami, jednak nie jest ona w pełni skuteczna. Wrogi kod ma możliwość eskalowania swoich uprawnień z wykorzystaniem błędów systemu operacyjnego urządzenia, co w efekcie może doprowadzić do uzyskania pełnego dostępu do wszystkich zasobów urządzenia.
Oddzielnym tematem jest jailbreaking. Z jednej strony proces ten pozwala użytkownikowi telefonu wykorzystać swoje urządzenie w sposób bardziej zgodny z jego oczekiwaniami. Jednocześnie powoduje on, że wiele z istniejących w systemie mechanizmów bezpieczeństwa traci swoją efektywność.
Następna sprawa to stopień wykorzystania dostępnych w danym systemie funkcji bezpieczeństwa. Zwykły użytkownik niekoniecznie chce być administratorem swojego telefonu, może nawet nie rozumieć wszystkich dostępnych funkcji i ustawień. Rzeczywiście, można skonfigurować urządzenie mobilne w taki sposób, by dostęp do przechowywanych na nim danych nie był trywialny, niestety nie można zakładać, że tak skonfigurowane będą wszystkie urządzenia, na których będzie używana nasza aplikacja. Trzeba założyć, że odpowiednio zmotywowany atakujący z bezpośrednim fizycznym dostępem do urządzenia ma dużą szansę uzyskania dostępu do przechowywanych na tym urządzeniu danych. Mechanizm uwierzytelnienia wykorzystywany przez aplikację powinien uwzględniać taką ewentualność i bronić się przed nią. Najbardziej oczywistym błędem byłoby przechowywanie hasła bezpośrednio na urządzeniu, ale jak pokażę w dalszej części, błędów można popełnić więcej i błędy te wcale nie muszą być tak bardzo oczywiste.
Aplikacja ma być wygodna
Musimy pamiętać, że użytkownicy mają coraz większe oczekiwania odnośnie wygody korzystania z aplikacji i szeroko pojętego user experience. Z drugiej strony jest tak, że wygoda i bezpieczeństwo są cechami sobie przeciwstawnymi. Sztuką jest znalezienie takiego punktu, gdzie wygoda użytkowania spełnia oczekiwania użytkownika, a z drugiej strony bezpieczeństwo całego rozwiązania wciąż pozostaje na akceptowalnym poziomie.
Dla omawianego tematu istotna jest wygoda interakcji użytkownika z aplikacja na urządzeniu mobilnym. W przypadku tradycyjnej klawiatury komputerowej wpisanie długiego, złożonego hasła nie jest dla przeciętnego użytkownika (technicznym) problemem. Wpisanie tego samego hasła na urządzeniu mobilnym może być już przeżyciem traumatycznym, czego miałem okazję wielokrotnie sam doświadczyć. Należy oczekiwać więc, że hasła wybierane przez użytkowników będą jeszcze prostsze, niż w przypadku tych używanych na komputerach. Inna sprawa, że analiza haseł wykonywana po głośnych przypadkach wycieków haseł pokazuje, że nawet na komputerze użytkownicy wybierają hasła, o których można powiedzieć wiele, ale na pewno nie to, że są bezpieczne.
Oddzielnym tematem jest użycie alternatywnych metod wprowadzania hasła, takich jak na przykład znane już "logowanie kropkami", gdzie użytkownik łącząc kropki tworzy unikalny wzorek, który służy do jego uwierzytelnienia. Ten sposób interakcji może być wygodniejszy dla użytkownika, ale złożoność takiego hasła wcale nie musi być wyższa, niż hasła "tradycyjnego". W efekcie taki sposób uwierzytelnienia moglibyśmy uznać za wystarczająco bezpieczny, jeśli atakujący dysponuje ograniczoną ilością prób odgadnięcia hasła. W przypadku, gdy może dane hasło weryfikować nieskończoną ilość razy, takie hasło można łamać w sposób bardzo efektywny ze względu na przestrzeń możliwych haseł i ograniczoną ich entropię. To jest temat na kolejne, dłuższe rozważania, ale wystarczy powiedzieć, że ilość wzorków składających się z N punktów jest wyraźnie niższa, niż ilość haseł o tej samej długości, nawet jeśli to hasło składa się wyłącznie z cyfr.
Nasza ofiara
Podsumowując, obiektem naszego zainteresowania jest mobilna aplikacja bankowa, która pozwala na odczyt informacji z banku oraz na wykonywanie dowolnych operacji bankowych, przy czym wykonanie operacji wymaga autoryzacji. Aplikacja ta może przechowywać pewne dane lokalnie, na przykład w pamięci podręcznej. Z naszego punktu widzenia kluczowe jest to, że aplikacja, jako całość, wymaga uwierzytelnienia.
Nasze cele
Naszym celem jest uzyskanie dostępu do konta ofiary, w szczególności:
- uzyskanie możliwości odczytu danych z banku w kontekście ofiary,
- uzyskanie możliwości wykonywania operacji bankowych w kontekście ofiary,
Głównym mechanizmem bezpieczeństwa, który atakujemy, jest mechanizm uwierzytelnienia użytkownika. Jeśli przy okazji uda nam się zaatakować mechanizm autoryzacji operacji, tym lepiej.
Nasze możliwości
Kolejnym krokiem jest określenie naszych możliwości, jako atakujących. To bardzo istotny krok również przy projektowaniu i testowaniu zabezpieczeń. Musimy wiedzieć przed kim staramy się bronić, a jakie scenariusze usuwamy z naszego modelu.
Po pierwsze zakładamy, że mamy pełną wiedzę o aplikacji, którą chcemy zaatakować. Założenie to jest jak najbardziej uzasadnione. Aplikacje są zwykle powszechnie dostępne, więc każdy, kto dysponuje odpowiednią wiedzą i odpowiednią motywacją może je przeanalizować.
Drugie założenie to dostęp do urządzenia i przechowywanych na nim danych. Może to być na przykład bezpośredni fizyczny dostęp do urządzenia, lub możliwość uruchomienia swojego kodu na urządzeniu ofiary, który to kod przejmie kontrolę nad tym urządzeniem i da nam pożądany poziom dostępu. Jeśli chodzi o bezpośredni fizyczny dostęp, możemy go uzyskać na przykład wówczas, gdy przypadkiem znajdziemy czyjeś urządzenie mobilne, można oczywiście również rozważyć celową kradzież urządzenia konkretnego użytkownika.
Tematem na zupełnie oddzielną dyskusję jest to, jak bardzo prawdopodobna jest sytuacja, w której zgubiony telefon trafia w ręce osoby, która będzie chciała wykorzystać okazję i zaatakować aplikacje zainstalowane na urządzeniu. Z drugiej strony w przypadku kradzieży telefonu jestem sobie w stanie wyobrazić sytuację, w której celem kradzieży jest właśnie wykorzystanie zapisanych na tym urządzeniu danych. Tu pewną analogią mogą być przypadki, w których złodzieje wyciągają z portfela swoich ofiar tylko karty kredytowe wychodząc z założenia, że potencjalny zysk z karty może być większy, niż z gotówki w portfelu. Portfel oczywiście zostaje na miejscu, by ofiara jak najdłużej nie zorientowała się, że została okradziona.
Teraz nałóżmy pewne ograniczenia, czego atakujący nie może? Po pierwsze załóżmy, że nie może on śledzić działania użytkownika. Gdyby miał taką możliwość, to atakowanie mechanizmu uwierzytelnienie aplikacji z użyciem znajdujących się na urządzeniu danych byłoby sztuką dla sztuki. Po prostu atakujący mógłby zaczekać, aż ofiara skorzysta z aplikacji i sama poda pożądane dane. Ten sam efekt można również osiągnąć podstawiając fałszywą aplikację w miejsce oryginalnej.
Ograniczenia te są jak najbardziej uzasadnione w przypadku scenariusza ze znalezionym/skradzionym urządzeniem. W przypadku scenariusza z wrogim kodem załóżmy po prostu, że nie chce nam się czekać na działanie użytkownika i chcemy dostać się do jego konta szybciej. Pamiętajmy jednak, że jeśli nasza aplikacja nie zawiera żadnego błędu z omawianych tutaj kategorii, to wcale nie znaczy, że jest odporna również na atak z wykorzystaniem bardziej zaawansowanego malware.
Strategie uwierzytelnienia
W dalszej części skupię się na trzech strategiach uwierzytelnienia, z którymi spotkałem się w aplikacjach mobilnych. Ta lista nie jest listą kompletną, można prawdopodobnie znaleźć aplikację, w której mechanizm uwierzytelnienia nie będzie pasował do żadnych z przedstawionych tutaj strategii.
Pierwsza strategia uwierzytelnienia to uwierzytelnienie lokalne. Mam tu na myśli sytuację, w której to aplikacja na urządzeniu mobilnym użytkownika weryfikuje jego hasło i jeśli jest ono prawidłowe, nawiązuje połączenie z serwerem. Oczywiście przy połączeniu z serwerem konieczna jest jakaś kolejna forma uwierzytelnienia, jednak to ten pierwszy krok, lokalne uwierzytelnienie użytkownika, jest krokiem kluczowym.
Druga strategia jest zdecydowanie bardziej sensowna, niż pierwsza. W tym przypadku dane uwierzytelniające są weryfikowane przez serwer. Ta strategia uwierzytelnienia ma szansę być być zarówno bezpieczna, jak i wygodna dla użytkownika. Niestety, w wyniku różnych błędów może okazać się, że atakujący w łatwy sposób będzie w stanie ustalić dane uwierzytelniające ofiary.
Trzecią strategią uwierzytelnienia jest wykorzystanie kodów jednorazowych generowanych przez token, przy czym token jest częścią aplikacji, lub jest to oddzielna aplikacja zainstalowana na urządzeniu. Nie chodzi mi tutaj o sytuację, gdy token jest oddzielnym, dodatkowym urządzeniem. Warto zwrócić uwagę, że w przypadku, gdy aplikacja wykorzystuje do uwierzytelnienia użytkownika wskazanie tokenu, z dużym prawdopodobieństwem token jest również wykorzystywany do autoryzacji transakcji. Uzyskanie możliwości generowania prawidłowych wskazań tokenu daje atakującemu możliwość nie tylko uwierzytelnienia się w aplikacji, ale również autoryzacji operacji bankowych.
Uwierzytelnienie lokalne
Proces inicjowania aplikacji
W tym przypadku dość typowym podejściem jest inicjowanie aplikacji przy pierwszym uruchomieniu i powiązaniu instancji aplikacji uruchomionej na konkretnym urządzeniu z kontem użytkownika.
Proces inicjalizacji aplikacji często polega na ustaleniu pewnego sekretu, przy pomocy którego aplikacja będzie uwierzytelniać się do systemu. W wielu przypadkach ten wygenerowany sekret zawiera pewne dane identyfikujące urządzenie, co w założeniu ma chronić przed skopiowaniem aplikacji wraz z danymi i uruchomieniem jej na innym urządzeniu. Sam sekret może być wyliczany dynamicznie, na przykład w oparciu o pewien seed ustalany w procesie uwierzytelnienia aplikacji, dane identyfikujące urządzenie i hasło użytkownika. Sekret (seed) może być przechowywany na urządzeniu w formie szyfrowanej.
Kolejnym krokiem inicjalizacji aplikacji jest ustawienie hasła użytkownika do aplikacji, które przechowywane jest lokalnie na urządzeniu. Często zamiast hasła stosowany jest po prostu PIN, ewentualnie jeśli wykorzystywane jest hasło, nie ma żadnych wymagań odnośnie jego złożoności. Dzięki temu użytkownik może odbierać proces uwierzytelnienia w aplikacji jako łatwy i wygodny.
Takie podejście do pewnego stopnia ma sens, użytkownik wpisuje proste hasło na swoim urządzeniu, samo uwierzytelnienie w systemie odbywa się jednak przy pomocy skomplikowanego sekretu, którego łamanie przy pomocy bruteforce ma znikome szanse na powodzenie.
Uwierzytelnienie w systemie
Proces uwierzytelnienia użytkownika w tym przypadku składa się z dwóch kroków, przy czym oczywiście użytkownik nie musi być tego świadomy. Aplikacja przy uruchomieniu prosi użytkownika o hasło, podane hasło jest weryfikowane lokalnie przez aplikację. Jeśli hasło jest poprawne, następuje "odblokowanie" sekretu, czyli na przykład jego odszyfrowanie lub wyliczenie w oparciu o seed i dane urządzenia. Tak wyliczany sekret jest przesyłany do serwera i następuje uwierzytelnienie użytkownika.
Warto zwrócić uwagę, że sam schemat uwierzytelnienia aplikacji względem serwera może być bardziej złożonym procesem, na przykład może mieć formę challenge-response. Jeśli jednak atakujący ustali prawidłową wartość sekretu, będzie w stanie to uwierzytelnienie wykonać.
Jeśli wpisane przez użytkownika hasło jest nieprawidłowe, to po kilku nieudanych próbach uwierzytelnienia aplikacja może usunąć lokalnie przechowywane dane, w tym sekret lub seed i zmusić użytkownika do ponownej inicjalizacji aplikacji.
Co jest nie tak?
Ta strategia uwierzytelnienia może być uznana za bezpieczną, pod warunkiem, że w modelu zagrożeń nie znajduje się atakujący z dostępem do urządzenia i danych na nim przechowywanych. W chwili, gdy wprowadzamy takiego atakującego, bezpieczeństwo tej metody wali się jak domek z kart.
Jeśli sekret lub seed wykorzystywany do jego tworzenia jest przechowywany w formie jawnej, można go wykorzystać bezpośrednio, a cała sytuacja jest identyczna z przechowywaniem hasła użytkownika w formie jawnej. Równie kiepskim pomysłem jest przechowywanie sekretu w postaci zaszyfrowanej, ale wykorzystanie do szyfrowania klucza, który też jest zapisany na urządzeniu, lub generowania klucza szyfrowania w oparciu o dane urządzenia. Rozważany przez nas atakujący ma dostęp do takich kluczy szyfrowania i bez problemu może sekret odzyskać.
Dokładnie tak samo wygląda sytuacja, w której sekret jest wyliczany dynamicznie na podstawie unikalnego dla każdej instancji aplikacji seedu i danych identyfikujących urządzenie. Atakujący może ponowić ten proces i odzyskać potrzebny mu sekret.
Całej sytuacji nie ratuje również wykorzystanie hasła użytkownika w procesie generowania sekretu. Problem polega na tym, że na urządzeniu przechowywany jest hash tego hasła i atakujący ma możliwość łamania go off-line. Nie jest w żaden sposób ograniczony przez wbudowany w aplikację limit prób zgadywania hasła, bo po prostu przy łamaniu hasła nie korzysta on z aplikacji. Nawet jeśli z niej korzysta, to i tak limit prób jest bezużyteczny, atakujący może po prostu skopiować dane aplikacji i odtwarzać je po każdym przekroczeniu limitu. Może też zmodyfikować aplikację i po prostu tę funkcję wyłączyć.
Warto dodatkowo zwrócić uwagę, że nie jest specjalnie istotne jaki algorytm przechowywania haseł jest wykorzystywany w aplikacji. Nawet jeśli jest wykorzystany algorytm typu bcrypt, to atakujący może w sposób efektywny łamać hasło, bo przestrzeń haseł do przeszukania jest prawdopodobnie niewielka. Na przykład 10 000 możliwych czterocyfrowych kodów PIN.
Przykład: Google Wallet
Ten przykład dotyczy aplikacji, która niezbyt dokładnie pasuje do opisu naszej ofiary, jednak warto o tej sytuacji pamiętać. PIN użytkownika do aplikacji Google Wallet był przechowywany lokalnie na urządzeniu, choć oczywiście nie w postaci jawnej. Nie zmienia to faktu, że ustalenie właściwego kodu PIN na podstawie przechowywanych na urządzeniu danych było zadaniem trywialnym (patrz: Google Wallet Pin Vulnerable To Brute Forcing).
Właściwym rozwiązaniem problemu jest wykorzystanie tak zwanego secure element, który chroni istotne dane. Taki komponent sprzętowy ma szansę skutecznie kontrolować dostęp do danych, nawet jeśli działa w kontrolowanym przez atakującego środowisku. Dokładnie tak samo jak chip na karcie albo TPM w komputerze. Pamiętajmy jednak, że nawet TPM można skutecznie atakować, jeśli mamy do niego bezpośredni fizyczny dostęp, choć oczywiście stopień złożoności całego przedsięwzięcia drastycznie rośnie.
Weryfikacja hasła po stronie serwera
Główną słabością wcześniejszej strategii uwierzytelnienia było to, że potencjalny atakujący był w stanie weryfikować poprawność hasła bez kontaktu z serwerem. Mógł to zrobić, ponieważ lokalnie na urządzeniu przechowywany był hash hasła użytkownika. W tym wypadku proces uwierzytelnienia zmodyfikowany został w taki sposób, by do sprawdzenia poprawności hasła konieczny był kontakt z serwerem. A przynajmniej takie są założenia.
Na czym polega różnica
W tym schemacie uwierzytelnienia do serwera uwierzytelnia się użytkownik, a nie aplikacja, proces inicjalizacji aplikacji i kojarzenia jej z konkretnym użytkownikiem nie jest niezbędny, może być jednak wykonywany.
Hasło użytkownika może być wysyłane bezpośrednio do serwera, który je weryfikuje. Może też być wykorzystywane do wyliczenia sekretu z użyciem jakiegoś seeda i danych identyfikujących urządzenia, w sposób analogiczny, jak wcześniej. Różnica jest taka, że taki sekret jest wyliczany niezależnie od tego, czy wpisane hasło jest prawidłowe czy też nie i wysyłany do serwera. W założeniu tylko serwer może stwierdzić, czy otrzymane dane, a więc i hasło wpisane przez użytkownika i użyte do ich wyliczenia, są prawidłowe. Podobnie jak wcześniej, zamiast użycia statycznego sekretu do uwierzytelnienia, możliwe jest stworzenie bardziej skomplikowanych schematów wykorzystujących na przykład challenge-response.
Dlaczego jest lepiej
Ponieważ aplikacja lokalna w żaden sposób nie weryfikuje hasła wpisanego przez użytkownika, a cały proces wyliczania sekretu czy odpowiedzi w przypadku challenge-response wygląda tak samo, niezależnie czy wpisane zostało prawidłowe czy nieprawidłowe hasło, atakujący może zweryfikować poprawność hasła wyłącznie wysyłając go do serwera, a serwer może zablokować konto użytkownika w przypadku zbyt dużej błędnej ilości prób uwierzytelnienia.
W takim przypadku nawet stosunkowo słabe hasło, choćby wspominany wcześniej PIN, może oferować wystarczający poziom bezpieczeństwa. Na przykład większość kart bankomatowych jest chroniona przez PIN złożony z czterech cyfr. Atakujący poza skopiowaniem paska karty starają się sfilmować wpisywanie PIN przez użytkownika, bo ilość prób, którymi dysponują, nie pozwala im na skuteczne odgadnięcie kodu.
Znamy już założenia. Jeśli są one spełnione, ta strategia uwierzytelnienia może być uznana za bezpieczną. Co można w niej zepsuć?
Logi
Pierwsza sprawa - logi. Programiści lubią zapisywać do logów dużo informacji, wszystko po to, by w łatwy sposób móc ustalić, czy aplikacja działa prawidłowo, co się dzieje i ewentualnie zdiagnozować problem. Takie podejście jest akceptowalne w przypadku tworzenia aplikacji oraz jej testów, ale gdy aplikacja trafia na produkcję, logi powinny być wyłączone. Niestety, tak się nie dzieje, a ilość logowanych informacji powala. Aplikacja może na przykład logować całą komunikację między klientem i serwerem.
Analizując logi aplikacji można często znaleźć tam prawidłową wartość sekretu użytą przy uwierzytelnieniu użytkownika. Jeśli jest to sekret statyczny, to w tej chwili atakujący może już bezpośrednio go użyć do uwierzytelnienia w aplikacji.
Jeśli mamy do czynienia z uwierzytelnieniem wykorzystującym mechanizm challenge-response, to w logach możemy znaleźć challenge i odpowiadającą mu odpowiedź aplikacji. Ponieważ wiemy w jaki sposób response jest generowany przez aplikację, mogliśmy przeanalizować wcześniej atakowaną aplikację, jesteśmy w stanie użyć tej informacji, by złamać hasło użytkownika. W jaki sposób? Dysponujemy prawdopodobnie wszystkimi informacjami, które trzeba przekazać do algorytmu generowania odpowiedzi, jedyną niewiadomą będzie hasło użytkownika. Wystarczy sprawdzić wszystkie możliwości, a ponieważ hasło prawdopodobnie nie będzie zbyt skomplikowane, nie zajmie to zbyt dużo czasu.
Hasło wykorzystane do czegoś jeszcze
Jeśli logów nie ma, lub w logach nie znajdują się żadne informacje, które mogłyby pomóc w złamaniu hasła użytkownika, warto sprawdzić, czy hasło nie jest wykorzystywane do czegoś jeszcze poza uwierzytelnieniem.
Typowym problemem w przypadku aplikacji mobilnych jest bezpieczne przechowywanie danych lokalnie na urządzeniu. Oczywiście dane te można zaszyfrować, wówczas pojawia się jednak problem gdzie przechowywać klucz. Rozwiązaniem tego problemu może być generowanie klucza na podstawie danych przekazanych przez użytkownika, co sprowadza się do generowania klucza szyfrowania na podstawie hasła wpisanego przez użytkownika.
Jeśli przyjęte zostaje takie rozwiązanie, istnieje spora szansa, że atakujący ponownie uzyska możliwość łamania hasła użytkownika bez kontaktu z serwerem. W tym wypadku wystarczy wygenerować klucze dla wszystkich możliwych haseł, a następnie z ich użyciem spróbować rozszyfrować dane przechowywane w cache. Ponieważ te dane z reguły mają jakąś łatwą do zidentyfikowania strukturę, wystarczy poszukać jej w rozszyfrowanych danych.
Tu warto wspomnieć o jednym dość ciekawym sposobie generowania klucza na podstawie hasła użytkownika. Programiści zamiast użyć przeznaczonych do tego funkcji (PBKDF2), wymyślili własny sposób. Wykorzystali do tego klasę SecureRandom oraz metodę setSeed. PIN wpisany przez użytkownika był wykorzystywany jako seed, w związku z czym SecureRandom generował powtarzalny ciąg danych. Całe rozwiązanie działało dlatego, że urządzenie wykorzystywało konkretną implementację SecureRandom (konkretnego providera), w której takie specyficzne zachowanie występowało. Jeśli wykorzystywany byłby inny provider, mogłoby się okazać, że za każdym razem generowany jest inny klucz i całe rozwiązanie nie działa. Patrz też: Co z tego, że Secure?
Wykorzystanie tego sposobu generowania kluczy kryptograficznych niosło za sobą pewne utrudnienie przy ataku. Po prostu ten sam sposób generowania klucza w oparciu o ten sam PIN na różnych platformach powodował wygenerowanie różnych ciągów danych. Wynikało to z różnicy w implementacji tego providera dla różnych systemów. Oczywiście nie był to problem nie do obejścia, wystarczyło sprawdzać/generować klucze na platformie, która odpowiadała platformie atakowanej aplikacji.
W tym konkretnym przypadku błędów było więcej. Między innymi aplikacja pozwalała na dostęp do lokalnie przechowywanych danych bez kontaktu z serwerem. W tym celu dane musiały być rozszyfrowane, a aplikacja weryfikowała poprawność hasła użytkownika nie poprzez sprawdzenie rezultatu operacji odszyfrowywania danych, ale poprzez porównanie go z lokalnie zapisanym hashem. Innymi słowy choć samo uwierzytelnienie użytkownika do systemu odbywało się po stronie serwera, to w wyniku niezbyt przemyślanego użycia hasła użytkownika do innych celów, z punktu widzenia atakującego aplikacja nie różniła się istotnie od tej, która uwierzytelnia użytkownika lokalnie.
Kody jednorazowe generowane przez token
Kolejna przypadek to wykorzystanie kodów jednorazowych generowanych przez token w celu uwierzytelnienia użytkownika. Warto zwrócić uwagę, że w tym przypadku najprawdopodobniej ten sam mechanizm będzie wykorzystywany do autoryzacji transakcji. Jeśli atakujący uzyska możliwość generowania prawidłowych kodów jednorazowych, uzyska nie tylko możliwość uwierzytelnienia się w aplikacji, ale również wykonania w niej dowolnej operacji.
Ogólny mechanizm działania
W ogólnym przypadku mechanizm działania tego rozwiązania jest prosty. Na początku token musi zostać zainicjowany. Inicjalizacja tokenu polega na ustaleniu współdzielonego z serwerem sekretu, który będzie wykorzystywany do generowania kolejnych wskazań tokenu.
Wskazanie tokenu jest wyliczane na podstawie unikalnego dla konkretnego tokenu seeda. Prócz tego uwzględniony jest licznik (kolejny numer generowanego kodu) lub czas (czas generowania konkretnego kodu), w którym dane wskazanie jest generowane. Możliwe są implementacje, w których token działa w trybie challenge-response, a więc w którym odpowiedź tokenu zależy dodatkowo od challenge przekazanego do niego, a otrzymanego wcześniej od serwera.
Wyliczone przez token wskazanie jest przesyłane do serwera. Serwer powtarza wyliczenia realizowane przez token i jeśli otrzymuje ten sam rezultat, wskazanie jest uznawane za prawidłowe. Jeśli wskazanie jest inne, to jest ono odrzucane. Po stronie serwera może być zaimplementowana dodatkowa logika, która ma za zadanie obsłużyć utratę synchronizacji między tokenem i serwerem. Jeśli klient prześle wskazanie, które jest prawidłowe w "niewielkiej" przyszłości/przeszłości, może ono zostać zaakceptowane, a serwer odnotuje sobie informację o występującym w danym przypadku odchyleniu. Jeśli jednak odchylenie jest zbyt duże, takie wskazanie również zostanie odrzucone.
Inicjalizacja tokenu
Najważniejszym elementem inicjalizacji tokenu jest ustalenie unikalnego dla niego seed. Może on być wyliczany przez aplikację, na przykład na podstawie hasła startowego, albo na podstawie danych odczytanych z kodu QR. W innych rozwiązaniach seed jest generowany losowo przez token lub przez serwer i przesyłany w procesie inicjalizacji. Gdy seed zostanie ustalony, można rozpocząć generowanie kodów.
Dodatkowa ochrona sekretu (seeda)
W przypadku, gdy atakujący uzyskuje dostęp do seeda tokenu, jest on w stanie sklonować token i generować kolejne wskazania. By tego uniknąć, seed tokenu może być dodatkowo chroniony przez hasło/PIN użytkownika.
Zwykle realizowane jest to na jeden z dwóch sposobów:
- PIN użytkownika jest uwzględniany przy wyliczaniu odpowiedzi,
- seed jest szyfrowany przy pomocy klucza generowanego na podstawie kodu PIN użytkownika,
Jeśli kod PIN jest uwzględniany przy generowaniu wskazania tokenu, serwer musi znać aktualny PIN użytkownika, by otrzymane wskazanie zweryfikować. W takim przypadku przy zmianie kodu PIN nowa wartość jest wysyłana do serwera.
Łatwiejszym rozwiązaniem jest szyfrowanie seedu tokenu. W przypadku zmiany kodu PIN seed jest po prostu szyfrowany z użyciem nowego klucza generowanego na podstawie nowego PIN. Oczywiście seed jest rozszyfrowywany każdorazowo przed wygenerowaniem wskazania, a samo wskazanie tokenu nie zależy od tego, jaki PIN został aktualnie ustawiony. Takie podejście jest łatwiejsze w implementacji, nie ma konieczności przekazywania nowego kodu do serwera w trakcie zmiany PINu.
Typowe przypadki użycia
W typowym scenariuszu wykorzystania użytkownik jest proszony o podanie PINu do tokenu. Wskazanie tokenu jest przesyłane do serwera i serwer weryfikuje poprawność otrzymanego kodu. Token zawsze generuje wskazanie, niezależnie od tego jaki PIN został wprowadzony, po stronie klienta nie powinno być żadnej możliwości rozróżnienia czy dany PIN jest czy też nie jest prawidłowy.
Poza wykorzystaniem tokenu do uwierzytelnienia się w aplikacji, może on być wykorzystywany również do autoryzacji operacji. W tym przypadku wskazanie tokenu zależy dodatkowo od parametrów autoryzowanej operacji. Dzięki temu serwer może zweryfikować nie tylko samą chęć wykonania operacji przez klienta, ale również parametry operacji, którą klient chce wykonać.
Jeśli przy uwierzytelnieniu lub próbie autoryzacji transakcji klient wielokrotnie prześle nieprawidłowe wskazanie tokenu, konto użytkownika zostanie zablokowane. Ponieważ ilość dopuszczalnych błędnych prób jest niewielka, nawet w przypadku krótkiego PINu szansa na odgadnięcie prawidłowej kombinacji jest niewielka.
Dlaczego jest dobrze?
Ponownie - atakujący nie ma żadnej możliwości ustalenia, czy określony kod PIN jest prawidłowy, czy też nie. Token zawsze generuje odpowiedź, w celu jej zweryfikowania konieczny jest kontakt z serwerem. To, że przy uwierzytelnieniu wykorzystywane są unikalne wskazania tokenu powoduje, że atakujący nie może ponownie wykorzystać przechwyconych danych by uwierzytelnić się w systemie po raz kolejny. Dodatkowo przekazanie do tokenu istotnych danych operacji pozwala użytkownikowi zweryfikować, czy autoryzuję on tę operację, którą chciał wykonać.
Co w takim przypadku można zepsuć?
Ponownie logi
Podobnie jak w przypadku poprzednio omawianej metody uwierzytelnienia, istotne parametry mogą zostać zapisane do logów. W szczególności z logów można odzyskać prawidłowe wskazanie tokenu oraz wszystkie parametry, z wyjątkiem hasła użytkownika, wymagane do jego wyliczenia. Dodatkowo na urządzeniu dostępny jest seed tokenu oraz ewentualnie stan licznika, więc atakującemu nie pozostaje nic innego, jak sprawdzić wszystkie możliwe wartości kodu PIN użytkownika dla określonych parametrów wyjściowych i sprawdzić, dla jakiego PINu wyliczona wartość jest taka sama, jak zapisana w logach.
Struktura zaszyfrowanych danych
Jeśli seed tokenu jest szyfrowany z użyciem hasła generowanego w oparciu o PIN użytkownika, to powinna istnieć możliwość rozszyfrowania go innym kluczem. Oczywiście wówczas wartość seed będzie nieprawidłowa i token będzie generował nieprawidłowe wskazania, ale by to zweryfikować trzeba będzie wysłać wskazanie do serwera.
Problem pojawia się wtedy, gdy szyfrowane dane mają pewną strukturę. Jednym z błędów było zaszyfrowanie seedu tokenu, kompletnie losowych 512 bitów, z użyciem AES w trybie CBC z użyciem paddingu PKCS#5. W tym przypadku do szyfrowanych danych dodawany jest pewne wypełnienie, którego wartość możemy łatwo przewidzieć.
Przy próbie odszyfrowania zaszyfrowanych w ten sposób danych przy użyciu nieodpowiedniego klucza, otrzymany padding będzie nieprawidłowy. Z tego powodu w kodzie tokenu przy funkcji rozszyfrowującej padding nie był uwzględniany, a rezultat odszyfrowania był ucinany do oczekiwanej wartości seedu tokenu i wykorzystany w dalszych obliczeniach. Z punktu widzenia użytkownika token zachowywał się identycznie, niezależnie od tego, czy podany PIN był prawidłowy, czy też nie.
Problem w tym, że atakujący nie musiał korzystać z aplikacji i zamiast tego mógł sam próbować rozszyfrować zaszyfrowaną wartość seeda z użyciem wszystkich możliwych kluczy. Tylko w jednym przypadku otrzymana po rozszyfrowaniu wartość zawierała prawidłowy padding, co wprost wskazywało na właściwy kod PIN. W ten prosty sposób atakujący ponownie uzyskiwał możliwość łamania kodu PIN tokenu off-line, bez kontaktu z serwerem. Gdyby w procedurze szyfrowania nie użyto paddingu PKCS#5, problemu by nie było.
Zmiana kodu PIN
W tym przypadku wykorzystywany jest fakt, że seed tokenu jest szyfrowany przy pomocy klucza generowanego w oparciu o PIN użytkownika. Zmiana PIN oznacza wygenerowanie nowego klucza, rozszyfrowanie seedu starym kluczem, a następnie zapisanie nowej wartości zaszyfrowanej nowym kluczem.
Tutaj możliwy jest atak meet-in-the-middle. Do jego przeprowadzenia konieczne jest posiadanie dwóch wersji tego samego seedu zaszyfrowanego przy pomocy dwóch różnych kluczy uzyskanych z dwóch różnych kodów PIN użytkownika.
W pierwszym kroku pierwszą wersję sekretu rozszyfrowujemy przy pomocy wszystkich możliwych kluczy, a rezultat zapisujemy w tablicy, razem z kodem PIN, przy pomocy którego dana wartość została uzyskana. Rozmiar tej tablicy jest uzależniony od przestrzeni haseł, jaką mamy przeszukać. W drugim kroku wykonujemy tą samą operację na drugiej wersji sekretu, tylko że zamiast zapisywać wynik, szukamy we wcześniej stworzonej tablicy wartości uzyskanej po odszyfrowaniu sekretu. Jeśli taka wartość znajdzie się w tablicy, to jest to wartość sekretu w formie jawnej, znamy również kody PIN ustawione przez użytkownika w obu przypadkach.
Jak często zmieniany jest PIN?
To, jak często zmieniany jest PIN zależy od użytkownika. W wielu przypadkach PIN prawdopodobnie nie jest zmieniany nigdy i pozostaje ustawiona ta wartość, która została ustalona przy inicjalizacji tokenu. Jeśli dokładniej przyjrzeć się sposobowi inicjowania tokenu może się jednak okazać, że w jego trakcie operacja zmiany PIN jest wykonywana.
W jednej z testowanych aplikacji wartość sekretu dla tokenu była generowana po stronie serwera i przekazywana do klienta. Wartość ta nie była przekazywana w formie jawnej, lecz zaszyfrowana przy pomocy losowo wygenerowanego PINu, który przekazywany był do użytkownika w wiadomości SMS. Podczas inicjowania tokenu użytkownik musiał przepisać kod otrzymany przez SMS a następnie ustalić własny PIN. Efektywnie była to już zmiana PINu i na urządzeniu pojawiały się dwie wersje tego samego sekretu szyfrowane różnymi kluczami. Jedyne co pozostaje atakującemu, to te dwie wersje odzyskać.
Jak wyciekły wartości?
Jak atakujący mógł w tym przypadku uzyskać dostęp do potrzebnych mu do ataku zaszyfrowanych wartości seedu? Sposobów mogło być i było kilka.
Po pierwsze z pomocą znów mogą przyjść logi. W aplikacji aktywny było logowanie, które spowodowało zapisanie do logów zaszyfrowanych wartości zapisywanych do bazy. Dodatkowo jeśli użytkownik nie usunie wiadomości SMS z hasłem użytym w procesie inicjalizacji tokenu, wystarczy w logach odnaleźć pierwszą zapisywaną wartość sekretu i odszyfrować ją z użyciem tego hasła. Jeśli hasło nie było dostępne, wystarczyło w logach znaleźć dwie wersje sekretu i przeprowadzić opisany atak.
Scenariusz z wykorzystaniem danych zapisanych w logach ma tylko jeden słaby punkt - logi na urządzeniach mobilnych mają zwykle ograniczoną pojemność i jeśli od inicjalizacji tokenu minęło wystarczająco dużo czasu, ta informacja z logów była usuwana. Na szczęście dla atakującego logi nie muszą być jedynym źródłem poszukiwanych informacji.
Aplikacje mobilne intensywnie wykorzystują bazę sqlite do przechowywania danych aplikacji. Problem w tym, że usunięcie rekordu w bazie danych nie powoduje usunięcie danych z pliku. W zależności od tego jak baza jest wykorzystywana i jakie operacje są użyte do aktualizacji danych, może się okazać, że w pliku bazy danych zostaje poprzednia wersja sekretu. Atakującemu pozostaje jedną wersję sekretu odzyskać z pliku, drugą odczytać bezpośrednio z bazy i przeprowadzić na tych danych wcześniej opisany atak.
Podsumowanie
Uwierzytelnienie użytkownika jest jednym z podstawowych mechanizmów bezpieczeństwa. Wykorzystany sposób uwierzytelnienia użytkownika musi zapewniać oczekiwany poziom bezpieczeństwa. Nie jest jednak tak, że zawsze bardziej wymyślny sposób uwierzytelnienia jest jednocześnie sposobem bezpieczniejszym. Często okazuje się, że przy implementacji tych bardziej zaawansowanych mechanizmów uwierzytelnienia popełniane są błędy, które poważnie osłabiają skuteczność uwierzytelnienia.
Przy implementacji mechanizmu uwierzytelnienia warto zwracać uwagę na kilka podstawowych zasad. Przede wszystkim nie należy przechowywać hashy haseł lokalnie, chyba że urządzenie dysponuje dodatkowym komponentem sprzętowym, w którym te dane mogą być przechowywane, i który to komponent skutecznie broni się przed próbami odgadywania hasła.
W aplikacji produkcyjnej nie należy prowadzić logów, których zawartość może być przydatna atakującemu. W szczególności nie należy przechowywać logów komunikacji z serwerem, ponieważ informacje tam zawarte mogą ujawnić atakującemu hasło lub sekret używany do uwierzytelnienia, mogą również zawierać informacje, które atakujący może wykorzystać do łamania hasła użytkownika off-line, bez kontaktu z serwerem. Pamiętajmy, że nie tylko hashe hasła możemy łamać. Zawsze zastanówmy się czy jakaś inna funkcja i inne zapisane dane nie mogą posłużyć atakującemu do tego samego celu.
W ostatnim przypadku ten sam mechanizm był wykorzystany do uwierzytelnienia użytkownika, oraz do autoryzacji operacji. Atakujący klonując token, uzyskał możliwość nie tylko uwierzytelnienia się w systemie, ale również wykonania dowolnej operacji. Stara zasada o tym, by nie wkładać wszystkich jajek do jednego koszyczka nadal pozostaje aktualna!
Swoja droga to całkiem dobry pomysł na takie opisanie swojej każdej prezentacji (choć zdaje sobie sprawę, ze mogło Ci to kawal czasu zająć)...
PS. Super było Cię wreszcie na żywo poznać
Tak, napisanie takiego tekstu zajmuje trochę czasu, ale z drugiej strony pozwala trochę sobie poukładać temat w jakąś historię. Tym razem z rozmiarem mnie poniosło, to prawda
Panie, esencja, za dużo treści, za mało konkretów.
Sam często mam problemy przy czytaniu niektórych dłuższych postów (np. na http://blog.cryptographyengineering.com/), a tu mi wyszedł taki mamut...
Fajne przykłady z atakiem przez padding, logami aplikacji i sposobem usuwania danych z bazy sqlite. Warto o tych opcjach pamiętać.
Z innej beczki - zgłosiłeś hipotetyczne odkrycia hipotetycznemu bankowi? Był jakiś hipotetyczny odzew?
Jeśli chodzi o zgłaszanie, to te tematy, które pojawiły się w trakcie pracy były oczywiście zgłaszane. Sposób ich obsługi zależał od ryzyka związanego z podatnością. Na razie wciąż dużo łatwiej jest stracić pieniądze przez malware na stacji, niż przez zgubioną/skradzioną/przejętą komórkę.
Takie rzeczy jak pisanie zbyt dużych logów, hash hasła na urządzeniu czy dane uwierzytelniające w formie jawnej są poprawiane w pierwszej kolejności. Bardziej wydumane przypadki mają niższy priorytet.
A jeśli chodzi o sqlite (i ogólnie - usuwanie danych z bazy danych) to dane mogą pocieknąć w większą ilość miejsc (journal, pliki tymczasowe).