Jak trzymać hasła w pamięci i dlaczego cstringi są czasem lepsze od std::stringów?

“Poznaj sekret cstringów. Std::stringi ich za to nienawidzą”! A na serio, nie zgapiając od pomponików, będzie trochę na temat trzymania wrażliwych danych w pamięci RAM i o zapobieganiu ich zapisowi na dysk.

Na początek krótkie ćwiczenie: napisałeś program do przechowywania haseł. Taki keepass w wersji DIY. Po uruchomieniu wczytuje zaszyfrowany plik do pamięci, pyta Cię o hasło, odszyfrowuje i pokazuje wybrane hasło. Działa dopóki go nie zamkniesz, na wszelki wypadek, jakbyś potrzebował jeszcze innego hasła. Niby bezpieczne, bo zaszyfrowane, na hasło… ale nie do końca.

Co może pójść nie tak?

Trzymasz odszyfrowany pęk kluczy w pamięci – to pierwszy problem. Dowolne nadpisanie/odczytanie takiej pamięci, np. przez /dev/mem albo /proc/X/mem może skompromitować wszystkie hasła z Twojego pęku – dowolna osoba z uprawnieniami root’a będzie mogła wczytać treść z Twojej pamięci. To może odszyfrowywać tylko gdy tego potrzebujemy? Ok, wtedy trzymamy w pamięci jedynie nasze główne hasło i zaszyfrowane przez prawie cały czas dane.

Osoba atakująca będzie mogła wtedy dość łatwo poznać hasło, którego możesz też przecież używać gdzieś indziej (tak, są tacy ludzie i jest ich dość dużo 🙂 ). Popatrzmy na inny przykład: Linux Kernel posiada wbudowany system szyfrowania dysków – LUKS. Do odszyfrowania jednego dysku można przypisać kilka różnych haseł dla różnych osób. Dzięki temu można uniknąć przekazywania ich i zapisywania na karteczkach. Każdy może użyć wtedy swojego ulubionego hasła 🙂 Jeśli natomiast je zgubi, można zmienić tylko to jedno hasło do dysku, bez zmiany innych haseł. Co więcej zmiana dowolnego hasła nie wymusza re-szyfrowania całego dysku. Jak to działa? Nie ma za tym jakiejś specjalnej magii i matematyki. Każdy z użytkowników, którzy mają swoje hasła tak na prawdę deszyfruje główny klucz do dysku. Dopiero za pomocą tego klucza jest przeprowadzane pełne (de)szyfrowanie.

Podobny mechanizm możesz też zastosować w Twoim pęku kluczy lub podobnej aplikacji. Za pomocą hasła użytkownik może odszyfrować tak na prawdę klucz symetryczny, który posłuży do odczytania pozostałej części danych. Przechowywanie go w pamięci, jeśli to konieczne, jest wtedy nieco bardziej bezpiecznym rozwiązaniem niż ciągłe przechowywanie hasła, lub co gorsze pełnego pęku kluczy.

Gdzie zapisać tymczasowe hasło?

Mimo wszystko Twoje hasło lub klucz deszyfrujący musi być tak czy tak chociaż na chwilę wczytane do pamięci. Jak zabezpieczyć się przed jego wyciekiem? Oprócz możliwości odczytania hasła bezpośrednio z pamięci jest jeszcze jedna możliwość. Atakujący może zaalokować tak dużo pamięci w systemie, że w końcu Twój DIY Keepass zostanie przeniesiony do swapu. A stąd już prosta droga do wyciągnięcia i przeanalizowania danych. Jeśli nie jako dump pamięci z poziomu roota, to śrubokrętem, przenosząc dysk do innego komputera lub bootując komputer z live cd.

Jak się przed tym obronić? Linux posiada trzy funkcje pozwalające Twojej aplikacji określić jak ma się zachowywać jej wirtualna pamięć. Pierwsza z nich to mprotect. Podając adres pamięci (np. blok zwrócony przez malloc) możesz sprawić, że dany obszar będzie dostępny tylko do odczytu, do zapisu lub w cale. Flaga PROT_WRITE jest zwykle stosowana do zabezpieczenia obszaru pamięci, w którym znajduje się Twój plik wykonywalny, aby przypadkowe (lub złośliwe) nadpisanie kodu instrukcji Twojej aplikacji nie było możliwe. PROT_EXEC jest natomiast stosowane do zabezpieczenia pamięci przed wykonywaniem jej kodu, np. w obszarach pamięci przechwujących zmienne. Zapobiega do przypadkowemu uruchomieniu takiego kodu. Wbrew pozorom bardzo użyteczną flagą może być PROT_NONE. Takiego obszaru nie da się ani odczytać, ani zmodyfikować ani wykonać. Jedną stronę takiej pamięci możemy ustawić np. za obszarem, do którego będą zapisywane dane od użytkownika, o nieznanym rozmiarze. Jeśli nasze programowe zabezpieczenia zawiodą i na przykład zaczniemy zapisywać do takiej tablicy bez opamiętania (bez if’a 🙂 to finalnie program spróbuje zapisać coś na stronie pamięci oznaczonym PROT_NONE. W takiej sytuacji zadziała już sprzętowe zabezpieczenie i nasza aplikacja otrzyma stosowny, zwykle bolesny sygnał od systemu operacyjnego.

Drugą funkcją służącą do modyfikowania zachowania pamięci jest madvise. Podobnie jak mprotect przyjmuje ona adres i rozmiar obszaru pamięci, który chcemy zmodyfikować. Za jej pomocą możemy powiedzieć systemowi (doradzić) jak będziemy korzystać z tego obszaru. Czy na przykład będą to losowe odczyty i cache’owanie na wyrost nie ma sensu, czy też podczas forka procesu ten obszar pamięci ma pozostać nieskopiowany. Flag jest sporo, warto przeglądnąć je w przyjaznym manualu.

Ostatnia funkcja, warta wspomnienia to mlock. Ponownie, przyjmuje jako pierwsze parametry adres i rozmiar obszaru pamięci. Zapobiega ona przenoszeniu stron pamięci na swap. Oznaczając w ten sposób obszar gdzie trzymamy klucz deszyfrujący oraz sam keychain, możemy zabezpieczyć się przed wspomnianym wcześniej wymuszeniem zrzutu pamięci na swap.

Korzystając z wszystkich trzech powyższych funkcji możemy wymusić na systemie operacyjnym nieco lepsze potraktowanie naszych danych przesiadujących w pamięci. Za pomocą mprotect możemy przez większość czasu zapobiegać dostępowi do pamięci, ustawiając jej flagę PROT_NONE, i “odbezpieczając” tylko gdy użytkownik potrzebuje hasła. Za pomocą madvise mona zapobiec skopiowaniu tej pamięci przez fork (i prawdopodobnie zabezpieczyć się też na kilka innych okoliczności), a z mlock zapobiegniemy wyrzuceniu na swap.

Cstring vs. std::string

Przy okazji wychodzi czemu czasami zarządzanie pamięcią przez malloc i standardowe stringi z C są bardziej użyteczne. Korzystając z std::string nie mamy tak dokładnej kontroli nad tym, gdzie będą trzymane dane ze stringa, jak również nie mamy gwarancji że po dopisaniu kolejnych znaków nie zostanie on w całości przeniesiony w inne miejsce. Trudniejsze staje się też zabezpieczenie strony tuż za stringiem. Nie jest to jednak niemożliwe, jednak wymaga dodatkowej pracy i dogłębnej wiedzy jak jest alokowana w tym przypadku pamięć. Dodatkowo, gdy std::string zwalnia pamięć, musimy też ręcznie posprzątać po nim, aby inny obiekt nie dostał obszaru z PROT_NONE.

W przypadku c stringów, wszystkie operacje przenoszenia, zabezpieczania i odbezpieczania musimy zrobić tak czy tak ręcznie, jednak z punktu widzenia czytelności kodu i jego obsługi jest to znacznie czytelniejsze.

Swap też jest szyfrowany

Tak, od jakiegoś czasu dystrybucje wspierają to domyślnie. Strony pamięci zrzucane do swapu są w locie szyfrowane, także odczytanie ich po wyłaczeniu komputera nieco się komplikuje. Przykładowy wpis z Arch wiki:

swap      /dev/sdX#    /dev/urandom   swap,cipher=aes-xts-plain64,size=256

Przy każdym starcie system generuje losowy klucz do zaszyfrowania swapu z /dev/urandom. Nawet jeśli coś się na swapie pojawi, to będą to tylko losowe znaki dla osoby, która nie zna klucza. Problem pojawia się jednak przy wsparciu dla disk suspend – uśpienia komputera i zrzucenia stanu pamięci na dysk. W tym przypadku sprawy się nieco komplikują, ale to historia na inny wpis.

Wirtualne maszyny

O ile wszystkie powyższe sposoby mają się całkiem dobrze na “gołym” PC, to już korzystając z wirtualizacji nie mamy gwarancji, że ktoś nie zrobi zrzutu pamięci oraz dysku w locie i nie będzie próbował analizować tego offline. W takim wypadku jedynym rozwiązaniem minimalizującym ryzyko przechwycenia cennych danych jest tylko chwilowe odszyfrowywanie ich. Nie gwarantuje to jednak pełnego bezpieczeństwa. Na własnych systemach można zapobiegać temu odpowiednimi opcjami dla wirtualnych maszyn, dzięki którym gospodarz nie będzie przenosił ich stron na swap. Służy do tego m.in. opcja locked w libvircie. Jednak w publicznych chmurach nie mamy takiej gwarancji.

Bezpieczeństwo różnych algorytmów

Na koniec warto wspomnieć o bezpieczeństwie samych algorytmów szyfrujących. Co, jeśli któryś okaże się podatny na ataki? Często aplikacje oferujące szyfrowanie pozwalają na złożenie kilku algorytmów na raz. O ile Twoje hasło pozostaje prawdopodobnie najsłabszym punktem, to szansa na skompromitowanie dwóch algorytmów szyfrujących na raz znacznie spada. Pomyśl, że lata temu używałeś MD5 do potwierdzania integralności plików. Algorytm się “zepsuł”. Wykonanie SHA1(MD5(…)) nieco ratuje sprawę. W ten sposób zabezpieczają się np. adresy portfeli z Ethereum. Skompromitowanie jednego algorytmu (co powodowałoby że łatwo wygenerować inny portfel z tym samym adresem) znacznie maleje.

Leave a Reply