[RElog] Dlaczego nie umieszczać haseł w kodzie źródłowym

Oraz po części o tym jak poprawnie(j) zrealizować logowanie do np. bazy danych

Hej, jakiś czas temu poruszałem podobny temat w [RElogu], dzisiaj chciałbym spojrzeć na niego jeszcze raz, ale tym razem od nieco innej strony.

Scenariusz: jesteśmy programistą, który ma program używający globalnej bazy danych napisany w języku kompilowanym do postaci binarnej. Chcielibyśmy udostępnić ten program swojemu złośliwemu znajomemu, jednak nie wiemy na ile jest to bezpieczne.

Poniżej przedstawię kilka „implementacji”, gdzie przekazujemy dane potrzebne do zalogowania, a następnie przedstawię słabości danych rozwiązań.

W poniższych przykładach korzystam z kompilatora GCC i debuggera GDB (z pluginem peda).

Naiwnie


Ktoś kiedyś powiedział, że „prostota jest szczytem wyrafinowania” (~Leonardo da Vinci), więc zgodnie z tym cytatem nie przemęczajmy się i zrealizujmy całość w najprostszy możliwy sposób:

Pseudo-logowanie używające stringów zapisanych w różnej postaci. Program po prostu wyświetla słowo „Connecting…”

 

Debug vs Release

(dzięki @maly za zwrócenie uwagi) Większość z Was pewnie korzysta z dużych środowisk programistycznych i zauważyła, że zazwyczaj dostępne są 2 tryby: Debug oraz Release. Tryb Debug zawiera dodatkowe symbole pomagające przy debugowaniu programu, które mogą być też potencjalnie przydatne przy jego analizie, jak np w pełni zrozumiałe nazwy funkcji, mogą się też zdarzyć dodatkowe komunikaty etc

Poprzedni kod jako „Release”

Co jeżeli skompilujemy program bez dodatkowych symboli, czy kompilator nie zamieni czasem wszystkiego w „binarne krzaczki”, przekonajmy się:

Powyższy przykład pokazuje kompilację w trybie „Release” oraz dodatkowo został „strip’nięty” z symboli. Czyli mamy czystą binarkę, a mimo to w liniach 100-114 jesteśmy w stanie zauważyć dane logowania.

Oczywiście wraz ze wzrostem binarki, tak ręczne wyszukiwanie stringów w takiej postaci bywa uciążliwe, to razem z kompilatorem gcc powinniśmy otrzymać narzędzie strings:

W tym przypadku widzimy, że trzymanie string’ów jako plain-text mija się z celem i musimy zastosować inną strategię.

 

Lepsze podejście – obfuskacja


Innym rozwiązaniem jest częściowe lub całkowite zaciemnienie tekstu, czy to przy użyciu zewnętrznych narzędzi, czy też napisanemu własnemu kodowi.

Obfuskacja stringów

W ramach przykładu posłużę się gotową binarką z zadania Over the Wire – Leviathan (Level3), plik binarny możecie pobrać [stąd]. Co prawda mamy jakieś symbole debugowe, ale nie mamy ani kodu źródłowego, ani hasła zapisanego jako plain text (przynajmniej nic nie pasuje nam na hasło).

W powyższej sekcji do znalezienia hasła wystarczyły jedynie 3 linie:

  • 0x080486a9 <+171>: call 0x804854d <do_stuff> wejście do funkcji w której pobierane jest hasło od użytkownika i jest sprawdzana jego poprawność;
  • 0x080485b7 <+106>: call 0x80483d0 <strcmp@plt> funkcja porównująca stringi, jako miejsce w którym należy szukać poprawnego hasła w pamięci;
  • 0004| 0xffffd354 --> 0xffffd361 ("snlprintf\n") zrzut stosu, gdzie było przygotowane hasło.

Zanim przedstawię wnioski chciałbym pokazać jeszcze jedno podejście.

 

Obfuskacja – hasła jako liczby

Powyższy przykład pokazał, że dzisiejsze narzędzia są „mądre” i potrafią zamienić liczby na tekst ASCII. A co w przypadku gdybyśmy zamienili litery na nie-drukowalne znaki, niezwiązane z żadnym kodowaniem.

Sprawa wydaje się wyglądać nieco lepiej, ponieważ takie wartości na pewno nie pojawią się po użyciu narzędzia strings, a i zaawansowane narzędzia do wyszukiwania tekstu będą miały problem i nie obędzie się bez ręcznej analizy.

Za trywialny przykład może posłużyć nam kod:

Śledzenie wartości liczbowych jest znacznie trudniejsze niż tekstowych, a sama metoda ich wyliczenia może być trudna do analizy. Powyższy przykład wykonuje fake’owe operacja które mają dodatkowo utrudnić analizę kodu. Mimo, że na pierwszy rzut oka może być nie widać jaka jest poprawna wartość, to akurat składowych jest na tyle mało można do tego dość szybko dojść (równanie z jedną niewiadomą):

  1. Wiemy, że passkey na końcu musi być równe 0, bo przed zwróceniem wartości jest robiona negacja tej wartości, a jedyna wartość dla jakiej negacja jest równa true to 0
  2. Wiemy też że x & y = y oraz x ^ b = c <=> c ^b = a
  3. Ostatecznie passkey = 0xdeadbeef

 

Wnioski

Na podstawie powyższych przykładów jesteśmy w stanie stwierdzić, że nie możemy ufać środowisku na którym uruchamiamy nasz program, bo to użytkownik jest u siebie gospodarzem, a my gośćmi. On ma pełnię władzy, a my tylko pewne przywileje.

Zauważmy też, że obfuskacja jest jedynie odroczeniem momentu gdy atakujący zdobędzie to czego szuka, może zastosowane metody spowolnią go na godzin, dni, miesięcy, ale ostatecznie on i tak „wygra”.

Skoro nie możemy ufać środowisku na którym uruchamiamy program, to gdzie należy trzymać dane logowania / informacje, których nie chcemy mu udostępnić? Odpowiedź jest prosta: na maszynie której ufamy – naszej.

 

Jeszcze lepsze podejście


Uwaga! Poniższy scenariusz wciąż nie jest najlepszy, ani uniwersalny! Ma za zadanie jedynie przedstawić ideę rozwiązania.

Skoro nie możemy zaufać użytkownikowi, to nie wyposażajmy go w narzędzia które mogą nas skrzywdzić. Przede wszystkim musimy zmienić sposób myślenia:

  • oczywistością jest to, że nie może dostać danych logowania do roota, niech używa konta które może tylko np dodawać wpisy;
  • idąc o krok dalej zauważamy, że apliakcja użytkownika wcale nie musi łączyć się z bazą danych! Niech do komunikacji z bazą używa jedynie gotowych metod;
  • będzie komunikował się z dodatkową warstwą w postaci serwera, który będzie wykonywał jego polecenia w przypadku poprawnej autoryzacji;
  • całą weryfikację zrzucimy na serwer.

 

Jak wygląda nowy schemat połączenia (opisowo):

  1. Podajemy swoje dane logowania do Programu (P)
  2. (P) hashuje hasło (+ ewentualnie login) i wysyła do Serwera (S) wraz z doklejonym loginem jako zwykły tekst
  3. (S) sprawdza poprawność poprzez pobranie hasha hasła danego użytkownika i porównanie obu hashy
    1. Jeżeli oba hashe są identyczne, tzn podane hasło jest poprawne to zwraca czasowy token (#), który będzie służył (P) do autoryzacji wykonywanych akcji
    2. Jeżeli się nie zgadzają to zostaje zwrócona informacja o złym loginie
  4. Do komunikacji z bazą danych (dodawania nowych rekordów, etc) używane są predefiniowane funkcje wraz z jednoczesnym podaniem (#). Tzn zostaje wysłany komunikat do (S), on sprawdza poprawność żądania i czy użytkownik może wykonać daną akcję poprzez weryfikację (#), następnie (P) wykonuje odpowiednią kwerendę na bazie.

Dodatkowo komunikację należałoby zanurzyć w jakimś algorytmie szyfrującym.

W ten sposób likwidujemy możliwość zdobycia danych logowania na samym etapie analizy kodu binarnego oraz likwidujemy potrzebę umieszczenia tych danych w samym pliku wykonywalnym.

 

Słowo końcowe


Kończąc ten artykuł należy zauważyć, że sam temat ma jeszcze wiele aspektów które należałoby poruszyć. Tutaj rzuciliśmy okiem na kilka podstawowych problemów oraz przedstawiliśmy sobie propozycję, która do pewnego stopnia likwiduje te problemy.

Zapraszam do dyskusji w komentarzach,

Code ON!