Dumping procesów jest to operacja zrzucenia pamięci operacyjnej danego procesu do pliku, lub plików. Podejście to stosuje się w przeróżnych przypadkach, lecz cechą charakterystyczną, łączącą wszystkie, jest to, że analizujący może określić stan programu z okresu zrzucenia jego pamięci, czyli np. może być to odczytanie hasła z pamięci komunikatora, czy sporządzenie listy haseł z twojego ulubionego managera haseł :).
Wstęp
Bez względu jednak na cel, dla którego ktoś chciałby zrzucić pamięć procesu, postaram się pokrótce opisać, w jaki sposób to zrobić na systemie Linux. Nie jest to trudne i całą logikę programu można zmieścić w kilkudziesięciu linijkach programu pisanego w C++, ale po drodze można napotkać kilka ciekawych konceptów które mogą zainteresować tych bardziej ciekawskich.
Jeśli masz doświadczenie z odczytywaniem pamięci procesów na Windows, możesz od razu przejść do rozdziału „System plików /proc”. Jeśli z kolei masz doświadczenie z dumpowaniem procesów na Linuxie i szukasz być może poszerzenia swojej wiedzy w tym zakresie, to musisz wiedzieć, że opisywana przeze mnie metoda opiera się na plikach mem, maps i syscallu ptrace, tak więc jeśli znasz już te mechanizmy, poszukaj szczęścia gdzie indziej ;).
Wspomniane wcześniej aktywności w stylu wykradania haseł z komunikatorów, czy kopiowanie listy haseł z managera haseł oczywiście są w wielu przypadkach wykonalne, ale tylko w takich, co do których autorzy aplikacji nie podjęli żadnych kroków w celu ochrony aplikacji przed zrzutem pamięci. Jeśli chodzi o techniki obrony przed zrzutem, zapraszam pod koniec tego posta. Oglądając dzisiejsze programy można jednak dojść do wniosku, że raczej niewielki procent (promil?) programów posiada takie mechanizmy anti-dump, a te, które posiadają, w większości „odziedziczyli” te bonusy dzięki packerowi/protectorowi którym są „pojechane”. Większość programów opera się na zaciemnianiu swoich wrażliwych danych przeróżnymi metodami wykorzystywania algorytmów kryptograficznych, lub też obfuscowaniem kodu wykonywalnego, utrudniając jego śledzenie. Metody oczywiście przydają się w obronie, ponieważ nawet gdy program korzysta z kernelowych funkcji ochrony pamięci, zawsze trzeba mieć świadomość, że obronę tą znieść może ręcznie napisany sterownik, który nie będzie miał większych problemów z obejściem ochrony zastawionej przez kernel. Ale do rzeczy ;).
Jak zrzucić pamięć procesu?
Aby zrzucić pamięć procesu, należy zadać sobie pytanie, czym dokładnie jest proces. Dla wielu osób wystarczy stwierdzenie, że procesem jest jeden wpis w wyjściu programu ps ;). Jeden proces ma przydzielony jeden identyfikator, zbiór właściwości, które definiują środowisko tego procesu, oraz swoją pamięć. Poniżej postaram się napisać kilka słów o każdej z tych trzech składowych, dlatego jeśli nudzi Cię ten tekst, możesz przeskoczyć niżej, do rozdziału "System plików /proc".
Identyfikator procesu jest to pewna liczba, która jednoznacznie określa dany proces w systemie. Nazywa się ona pid (skrót od process id) i polityką systemu operacyjnego jest to, jakie watości nadawane są nowym procesom. Oto dwa przykłady przezentujące system nadawania nowych identyfikatorów procesów.
Przykładowy zrzut procesów w dość wiekowym kernelu z patchem grsecurity:
- nobody 4134 0.0 2.2 15876 8756 ? S Oct14 0:00 /usr/sbin/httpd
- nobody 18739 0.0 2.2 15876 8756 ? S Oct14 0:00 /usr/sbin/httpd
- root 3321 0.0 0.3 2532 1436 ? Ss Oct15 0:00 dhcpd
- mysql 30028 0.0 4.1 53628 15872 ? S 10:23 0:00 mysqld [...]
- mysql 9746 0.0 4.1 53628 15872 ? S 10:23 0:00 mysqld [...]
- root 3513 0.1 0.4 6228 1860 ? Ss 17:45 0:00 sshd: antonone
- antonone 21366 0.5 0.5 6244 1932 ? S 17:45 0:00 sshd: antonone
Widać dość losowe przydzielanie nowych identyfikatorów nowym procesom. Dzięki temu problem przewidywania nowych pidów dla nowych procesów jest w tym przypadku dość mocno zminimalizowany.
Przykładowy zrzut procesów w dość świeżym kernelu, z dość świeżo wydanej, dość znanej dystrybucji linuxa ;)
- root 18620 0.0 0.0 24548 1188 ? S 15:09 0:02 hald-addon-input
- root 18622 0.0 0.0 24540 1192 ? S 15:09 0:00 hald-addon-rf[...]
- root 18633 0.0 0.0 24540 1188 ? S 15:09 0:00 hald-addon-ge[...]
- root 18640 0.0 0.0 24544 1188 ? S 15:09 0:02 hald-addon-storage
- root 18641 0.0 0.0 24556 1192 ? S 15:09 0:00 hald-addon-cpufreq
- 114 18642 0.0 0.0 26360 1260 ? S 15:09 0:00 hald-addon-acpi
- antek 18648 0.0 0.5 470092 21820 ? Sl 15:09 0:00 kglobalaccel
- antek 18660 0.0 0.3 161856 12280 ? S 15:09 0:00 [...]kio_htt[...]
Identyfikatory zostają nanoszone w bardzo przewidywalny sposób. Nie jest to wielki problem, jednak może przysłużyć się w przyszłości jako hint dla przeróżnej maści exploitów, czyhających na nieostrożnego użytkownika niczym ZUS na obywatela.
Natomiast jeśli chodzi o środowisko, jest to zbiór różnych zmiennych, obiektów, liczników, mutexów, blokad, etc, które pomagają w komunikacji procesu z systemem operacyjnym, ustawieniami użytkownika, stanem komputera, itp. Najprostszy przykład zmiennej zapisanej w środowisku, to linia poleceń danego programu – wykonując jakieś polecenie przez zapisanie linii poleceń w konsoli, np.: cat readme.txt, system tworzy proces, nadaje mu jakiś identyfikator, a tworząc środowisko, zapisuje w nim linię poleceń, która wygląda tak, jak wywołaliśmy program, czyli „cat readme.txt”. Środowiskiem są też informacje o limitach zasobów, ustalane przez polecenia typu ulimit, lub funkcje typu setrlimit(). Najbardziej jednak znanym elementem środowiska procesu jest lista zmiennych, nazywanych zmiennymi środowiskowymi, pobieranych przez funkcje typu getenv().
Więcej informacji na temat środowiska znajdziesz poniżej, w rozdziale „System plików /proc”.
Pamięć procesu jest to miejsce, gdzie program posiada swój kod, czyli logikę działania, oraz swoje dane, którymi posługuje się w tej logice. To, w jaki sposób pamięć jest przydzielana procesowi, zmieniała się przez ostatnie X lat i ewoluowała do postaci, w której system przydziela pewne obszary dla odpowiednich części procesu. Po uruchomieniu programu, system tworzy jego proces, kopiując odpowiednie części pliku z programem do pamięci, w różne obszary. To, która część pliku zostanie skopiowana w który obszar, zależy od tego, co zapisane jest w tym pliku. Struktura ELF będąca standardowym formatem zapisu plików wykonywalnych na Linuxie (i innych) ściśle opisuje rejony pliku które mają być skopiowane w ściśle opisane obszary pamięci. Dodam jeszcze, że program ładujący pliki wykonywalne nazywa się loaderem.
Wizualnie, w dużym uproszczeniu, po załadowaniu programu do pamięci przez loader, sytuacja wygląda w ten sposób (obrazek pomija takie rzeczy jak np. środowisko):
Widać tutaj trzy obszary, dla kodu, danych i obszar zarezerwowany na specyficzne dla systemu rzeczy. „Kod” znajduje się w obszarze pod offsetem 0x0 i zajmuje 0x10 bajtów – znaczy to, że podczas odczytu danych z procesu spod adresów od 0 do 10, będziemy odczytywać dane z obszaru kodu. Kolejny obszar, „Dane”, znajduje się w obszarze 0x10-0x110 oraz „System” w obszarze 0xF000-0xF100. Wszystkie obszary podlegają takim samym prawom. W przypadku, gdy zechcemy odczytać coś z adresu pamięci, który nie należy do żadnego obszaru, funkcja odczytująca pamięć po prostu zwróci błąd. Oddzielnego komentarza wymaga obszar „System”, który jest uproszczonym symbolem wszystkich obszarów w których system umieszcza swoje kontrolne informacje na temat danego procesu. Mogą być to np. obszary oznaczone jako VDSO, czyli Virtual Dynamic Shared Object, lub tablica auxiliary vector.
Zrzut pamięci procesu polega na odczycie każdego obszaru pamięci i zapisaniu go na dysk. Łatwo zauważyć, że z uwagi na wyżej opisaną naturę organizacji pamięci procesu, łatwiej jest zapisać pamięć programu do wielu plików, niż do jednego pliku (każdy obszar zapisując do oddzielnego pliku).
Zrzucanie pamięci na Linuxach trochę różni się od sposobu znanego z Windowsa. Przede wszystkim, nie otwiera się procesu który chcemy zrzucić, tylko operuje się na samym process id (pid). Poniżej przedstawię mechanizmy pozwalające nam dobrać się do jelit procesu ;).
System plików /proc
Jest to dynamiczny system plików, który przedstawia aktualną sytuację procesów w systemie. Każdy proces ma swój podkatalog, o nazwie takiej, jaki identyfikator procesu ma proces – czyli np. gdy nasz proces ma pid równy 22438, można wejść do katalogu /proc/22438 i zerknąć na to, co się tam znajduje.
W tym momencie użytkownikom systemu Windows może zapalić się czerwona lampka z pytaniem, w jaki sposób odczytywanie informacji z systemu plików może być bezpieczne, wydajne, lub warte zaufania? Korzystając przez cały czas z funkcji API typu OpenProcess(), ReadProcessMemory(), itp. użytkownik przyzwyczajony jest do tego, że bez napisania odpowiedniego programu w języku natywnym nie można uzyskać informacji o procesie. Nadaje to jednocześnie poczucie pewnej elitarności używanego przez nas podejścia i poczucia, że odczytujemy informacje systemowe niedostępne dla osoby która nie jest zaznajomiona z programowaniem WinAPI. Tutaj natomiast informacja dostępna jest przy pomocy tak podstawowych narzędzi jak ls i cat, co powoduje sytuację, że każdy może je odczytać. Czy informacje z katalogu /proc rzeczywiście są godne zaufania? Tak, co więcej, jest to standardowa metoda odczytywania tego typu informacji, stosowana z powodzeniem w wielu istniejących już programach (na Linuxie; na — przykładowo — FreeBSD, można korzystać z biblioteki libkvm i funkcji kvm_getprocs). Zapytania do systemu plików procfs (który zamontowany jest pod katalog /proc) kierowane są do odpowiedniego sterownika, który przemierzając struktury danych w jądrze tworzy ciągi znaków ASCII i wysyła je do naszego narzędzia poprzez standardowy mechanizm odczytu I/O (np. funkcja read()). Ciągi znaków, które pojawiają się na ekranie tworzone są w kernelu (np. zerknij na funkcję show_map_vma() w pliku /fs/proc/task_mmu.c), nie w programie cat, więc informacje pobrane w ten sposób mają jeszcze mniejszą drogę do pokonania niż te odczytane przez np. EnumProcesses() z PSAPI, lub CreateToolhelp32Snapshot() z Toolhelp API.
Pierwszym interesującym nas plikiem będzie plik cmdline. Nie będzie on wykorzystany w dumperze, jednak warto go poznać, ponieważ po prostu wiedza o nim jest dość przydatna.
- antek@tranquility:/proc/22438$ cat cmdline
- sshantek@192.168.0.2antek@tranquility:/proc/22438$
Znajduje się tutaj informacja o linii poleceń programu, z jaką został uruchomiony. Wiem, że powyższy „zrzut ekranu” jest popsuty, ale to wina pliku, nie mojego niedopatrzenia ;). Plik nie zawiera znaku końca linii, dlatego znak zachęty znajduje się w tym samej linii co treść pliku. Po odfiltrowaniu danych można znaleźć taki string:
- sshantek@192.168.0.2
Wydaje się on w porządku, gdyby nie to, że wygląda jakby brakowało w nim spacji. Tak też jest, ale nie do końca; zamiast spacji znajduje się bajt \x00. Można to zauważyć odczytując jeszcze raz ten plik, ale zamieniając jego wyjście na dump hexadecymalny:
- antek@tranquility:/proc/22438$ cat cmdline | xxd
- 0000000: 7373 6800 616e 7465 6b40 3139 322e 3136 ssh.antek@192.16
- 0000010: 382e 302e 3200 8.0.2.
Tak więc znajduje się tutaj nazwa programu jak też i lista argumentów, z jakimi program został uruchomiony. Teraz już wiesz, dlaczego wszędzie piszą, jak złe jest umieszczanie haseł w liście argumentów do jakiegoś programu :).
Polecam obejrzeć wszystkie pliki w tym katalogu, np. io, limits, status, a najlepiej zajrzeć do wszystkich. Nas jednak będą interesowały dwa pliki: maps i mem.
Plik maps zachowuje się jak zwykły plik, który można odczytać edytorem (lub programem w stylu cat).
- antek@tranquility:/proc/1800$ cat maps
- 00400000-00427000 r-xp 00000000 08:05 917949 /usr/bin/gnome-screensaver
- 00626000-00627000 r--p 00026000 08:05 917949 /usr/bin/gnome-screensaver
- 00627000-00628000 rw-p 00027000 08:05 917949 /usr/bin/gnome-screensaver
- 00628000-00629000 rw-p 00000000 00:00 0
- 02172000-0229f000 rw-p 00000000 00:00 0 [heap]
- 7fdb68000000-7fdb68021000 rw-p 00000000 00:00 0
- 7fdb68021000-7fdb6c000000 ---p 00000000 00:00 0
- 7fdb6f8ed000-7fdb6f8ee000 ---p 00000000 00:00 0
- 7fdb6f8ee000-7fdb700ee000 rw-p 00000000 00:00 0
- 7fdb700ee000-7fdb700f7000 r-xp 00000000 08:05 924439 libpixmap.so
- 7fdb700f7000-7fdb702f7000 ---p 00009000 08:05 924439 libpixmap.so
Informacje przedstawione w tym pliku opisują obszary pamięci w naszym procesie (pamiętasz obrazek z „Kodem”, „Danymi” i „Systemem”?). Każda linia przedstawia inny obszar pamięci. Pierwsza linia opisuje obszar spod adresu 0x400000 do adresu 0x427000, czyli o długości 0x27000 bajtów. Ciąg znaków „r-xp” oznacza prawa dostępu do tego obszaru pamięci. „r-xp” oznacza mniej więcej tyle, że proces pozwala na odczyt z tego obszaru (ponieważ w ciągu znaków znajduje się litera „r”). Drugi znak, myślnik, jest zastępnikiem litery w, która oznacza możliwość zapisu do obszaru. W tym obszarze litery w jednak nie ma (jest myślnik), więc obszar nie pozwala na zapisywanie czegokolwiek. X oznacza możliwość wykonywania kodu w danym obszarze, a litera p oznacza pamięć prywatną, która zostaje zduplikowana przy zapisie tworząc dwie kopie pamięci – oryginalną i zmodyfikowaną. Ten proceder nazywa się metodą copy-on-write i nie będzie związany z naszym celem. Czasami pojawia się też litera s, oznaczająca pamięć współdzieloną, ona też będzie jednak tutaj po cichu pominięta, ponieważ nie wniesie żadnych informacji z punktu widzenia tematu tego posta ;).
Kolejna liczba, w pierwszej linii wynosząca 0x00000000, jest to offset w [najczęściej] pliku, który jest skopiowany pod podany obszar (czyli 0x400000-0x427000).
08:05 to identyfikator urządzenia hostującego plik, z którego skopiowano dane do pamięci tego regionu, a 917949 to inode który lokalizuje ten plik na tym urządzeniu („08:05”). Na końcu znajduje się ścieżka do pliku. Warto zauważyć, że jeśli inode jest zerem, lub też brakuje ścieżki do pliku (zwykle obie cechy występują w tych samych przypadkach), dany obszar alokowany jest ręcznie przez aplikację, lub wyrzucany z pamięci po załadowaniu programu.
Nie wszystkie obszary które można znaleźć w pliku maps należą do naszego programu. Loader ładując plik wykonywalny odczytuje również wszystkie biblioteki dynamicznego łączenia (shared objects), które wpisane są do pliku wykonywalnego jako biblioteka zależna. Program polega na tych bibliotekach i w swoim kodzie zawiera liczne odwołania do kodu z tych bibliotek, więc kod ten również musi być wprowadzony jako obszar w pamięci procesu. Z bibliotekami dynamicznego łączenia przychodzi pewne pytanie, mianowicie czy dumper musi zrzucać również te rejony, które są zarezerwowane dla tych bibliotek? Odpowiedzią jest tak lub nie, w zależności od tego, jakie dane dumper ma zrzucać. Czasami poszukiwane przez nas dane mogą znajdować się w obszarach zajętych przez biblioteki, czasami przez obszary utworzone przez nasz program. Najbezpieczniejszym wyjściem jest zapisywanie wszystkich obszarów, które posiadają prawa zapisu; ponieważ obszary bez tych praw nie mogą pełnić roli pamięci „podręcznej” z uwagi na to, że system nie pozwoli na żadne operacje zapisywania.
Kolejnym interesującym z punktu widzenia dumpera plikiem jest plik mem. Jest to plik specjalny, który nie pozwala na odczytanie się przez użycie strumieniowego czytania implementowanego przez programy typu cat. Aby poprawnie wykorzystać plik mem, trzeba go najpierw otworzyć, a następnie ustawić wskaźnik odczytu pliku na interesujący nas adres (np. funkcją lseek()), po czym rozpocząc odczyt przez funkcję I/O (np. read()) uważając jednocześnie, aby nie przekroczyć dozwolonego zakresu. Listę dozwolonych zakresów definiuje plik maps, opisany kilka paragrafów wcześniej. Nie można też odczytywać obszarów które nie mają praw odczytu (czyli bez litery r), oraz – najważniejsza cecha charakterystyczna pliku mem – odczyt możemy wykonać jedynie wtedy, gdy proces odczytujący jest właścicielem procesu, którego pamięć chcemy odczytać przez czytanie z pliku mem. Jeśli tak nie jest, zostanie zwrócony błąd errno EPROC podczas otwierania tego pliku. Co to znaczy? Oto kilka przykładów przypadków użycia:
Załóżmy, że uruchamiamy program odczytujący pamięć z innego procesu. Program dostaje pid o wartości 2280. Pid procesu, który chcemy odczytać, to 1020 i istnieje w systemie już od dawna. W takiej sytuacji nie można odczytać pamięci przez wykorzystanie pliku mem, ponieważ program z pidem 2280 nie ma nic wspólnego z programem o pidzie 1020.
Uruchamiamy program odczytujący pamięć z innego procesu, który dostaje pid 2280. Program ten uruchamia inny program (przez spawn(), fork(), etc), który dostaje pid 3000. W tej sytuacji można odczytać pamięć z pliku mem. Program, który „stworzył” inny program, posiada więcej praw do niego, niż reszta procesów w systemie. Między tymi prawami znajduje się oczywiście prawo do odczytu pliku mem.
Co w przypadku, gdy chcemy odczytać pamięć z procesu, który został uruchomiony PRZED naszym programem? Trzeba zarejestrować nasz program jako program debugujący interesujący nas proces. Innymi słowy, musimy napisać prosty debugger, doczepiający się pod proces identyfikowany przez inny pid.
Wiąże się to z kolejną serią zależności i reguł, ale jest konieczne do odczytania pamięci procesu. Na szczęście lista funkcji API do stworzenia takiego mini-debuggera jest naprawdę minimalna i ogranicza się do jednej funkcji o stu zastosowaniach – ptrace.
Funkcja ptrace()
ptrace to funkcja-behemot. Jej pełna definicja wraz z listą wszystkich możliwych argumentów znajduje się w dokumentacji, więc nie widzę sensu opisywać ją w pełni. Postaram się poruszyć jedynie te aspekty działania tej funkcji, które mogą przysłużyć się napisaniu własnego dumpera.
Schemat użycia funkcji ptrace() jest w zasadzie bardzo prosty, lecz łatwo zirytować się jego nieprawidłowym działaniem po nieuważnym przestudiowaniu dokumentacji - część wywołań ptrace() jest asynchroniczna i wymaga synchronizacji funkcją wait() lub waitpid(), ale o tym za chwilę.
Przed jakąkolwiek akcją związaną z dumpowaniem (zalicza się też do tego czytanie map/obszarów pamięci), należy użyć funkcji ptrace() z argumentem PTRACE_ATTACH. Spowoduje to podłączenie naszego procesu do wybranego pid'a w roli procesu nadrzędnego, lub nasłuchującego. Nasz proces będzie mógł nasłuchiwać różnego rodzaju powiadomień wysyłanych przez proces potomny. Jednocześnie, program ps będzie od tej chwili zgłaszał informacje o tym, że nasz proces jest procesem nadrzędnym do procesu identyfikowanego przez podany pid.
Po attachowaniu procesu ważne jest też to, że proces potomny dostanie sygnał SIGSTOP, co spowoduje wstrzymanie wykonywania tego procesu. Rzecz podobna jest do wywołania funkcji SuspendThread() na systemie Windows. Jest to zachowanie bardzo przez nas pożądane, ponieważ uniemożliwi to wprowadzeniu jakichkolwiek zmian podczas gdy będziemy po kolei zrzucać wszystkie obszary pamięci programu na dysk, a to, w zależności od programu, może potrwać nawet kilka sekund (na starszych komputerach nawet dłużej).
Po jednorazowym wywołaniu PTRACE_ATTACH i sprawdzeniu, czy funkcja ptrace() zwraca sukces (wartość 0), będziemy mieli prawo otworzyć plik mem. Pamiętaj o tym, że funkcja ta jest asynchroniczna; nawet jeśli zwróci sukces, faktyczne doczepienie się do innego procesu może potrwać nieco dłużej, niż czas powrotu z funkcji. Zaraz po sprawdzeniu sukcesu należy użyć funkcji waitpid(pid), aby odczekać na pierwsze powiadomienie będące sukcesem doczepiania się do procesu. Niektórzy używają do tego celu funkcji sleep(), ale jest to bardzo złe podejście, nie dość, że czekające zbyt długi czas, to przy bardziej skomplikowanych projektach powodujące zakleszczenia (deadlocki). Po jego otwarciu, przechodzimy pod konkretny offset będący początkiem interesującego nas obszaru poleceniem lseek64(), i rozpoczynamy odczyt danych z pamięci przy pomocy zwykłego read(). To jest oczywiście przykład – te dwie funkcje można zamienić na jedno wywołanie pread64() (który w czwartym argumencie przyjmuje offset, od którego ma rozpocząć odczyt, zupełnie jakby sama chciała w sobie wywołać funkcję lseek64(), lub podobną), jak jest w przykładzie poniżej. Teraz, dla każdego obszaru należy wywoływać w pętli lseek64() i read(), aby móc odczytać wszystkie regiony.
Czytanie danych nie zawsze kończy się powodzeniem. Jeśli tak jest w Twoim przypadku, sprawdź, czy nie starasz się odczytać obszaru pamięci który nie daje praw odczytu, lub próbujesz odczytać specjalny obszar vsyscall.
Po zakończeniu dumpowania pamięci, należy teraz poprawnie zdeinicjalizować naszą modyfikację struktury drzewa procesów, czyli zostawić proces „ofiarę” w spokoju, przywracając jego działanie. Do tego służy argument PTRACE_DETACH ptrace'a. Po tym wywołaniu można spokojnie zakończyć program, lub też zacząć analizować zebrane dane.
Implementacja
Proponowana przeze mnie implementacja będzie wykorzystywała język C++ i bibliotekę Glib, z uwagi na to, że aktualnie poznaję to, co ma do zaoferowania i podoba mi się co znajduję :).
Funkcja main znajduje się w pliku Dumper.cpp:
- int main(int argc, char *argv[], char *envp[]) {
Na początku następuje porównanie wielkości zmiennej size_t z liczbą 8. Z praktycznego punktu widzenia jest to możliwe tylko na architekturze x64, głównie dlatego, że nie testowałem go na architekturze x86. Teoretycznie rzecz ujmując, zamiana wszystkich size_t w programie na uint64_t i poprawki błędów konwersji typów odpowiednimi rzutowaniami powinny umożliwić programowi działanie na 32-bitach.
- assert(sizeof(size_t) == 8);
Następuje parsowanie argumentów, czyli numeru PID.
- if(argc != 2)
- return syntax(argv[0]);
- else {
Jeśli argument został podany, stwórz obiekt Dumper, który zawiera mechanikę dumpowania regionów i powiedz mu, że chodzi nam o konkretny pid (podany w argumencie).
- Dumper dumper(atoi(argv[1]));
Rozpocznij dumpowanie. Funkcja start_dumping() wykona wywołanie funkcji ptrace() z argumentem PT_ATTACH, zatrzymując proces o podanym PID'zie.
- if(! dumper.start_dumping()) {
- printf("Nie moge rozpoczac dumpowania. Brak praw?\n");
- return 1;
- }
Zbierz regiony pamięci danego procesu. Funkcja get_regions() otworzy plik /proc/
- vector<MemoryRegion> regions = dumper.get_regions();
Wejście do pętli iterującej po każdym odczytanym regionie.
- for(vector<MemoryRegion>::iterator i = regions.begin(), e = regions.end(); i != e; ++i) {
- MemoryRegion& mr = * i;
Inicjowanie zmiennych, do których przekazane zostaną informacje o odczytanych danych z pamięci.
- uint8_t *memory = NULL;
- size_t size = 0;
Przejdź dalej tylko w przypadku, jeśli region którym aktualnie się zajmujemy nadaje się do odczytu (w przeciwnym wypadku i tak nie moglibyśmy go odczytać), jak też i pozwają na zapisywanie do siebie (czyli interesują nas dane, które program sam stworzył podczas swojego działania; dane tylko zmapowane z pliku do pamięci i nie zmienione w żadnen sposób mamy cały czas na dysku w plikach określonych kolumną „path” w pliku maps, więc nie ma sensu ich dumpować).
- if(mr.can_read() && mr.can_write()) {
Wyświetla na ekranie podany region.
- mr.dump_info();
Zrzuca pamięć procesu do zmiennej „memory”. Zmienna ta dostaje adres zaalokowanej przez dump() pamięci, którą należy zwolnić. „size” dostaje rozmiar tej pamięci.
- if(! dumper.dump(mr, memory, size)) {
- printf("Dump regionu nie powiodl sie.\n");
- }
Zapisuje podany region do pliku. Funkcja przyjmuje argument w postaci MemoryRegion po to, aby na jego podstawie stworzyć unikalną nazwę pliku, po nic więcej.
- if(! dumper.save(mr, memory, size)) {
- printf("Zapis dumpa nie powiodl sie.\n");
- }
Następuje zwolnienie zaalokowanej pamięci.
- delete[] memory;
- }
- }
Zakończenie dumpowania procesu. Funkcja end_dumping() wywoła funkcję ptrace() z argumentem PT_DETACH, aby wznowić działanie procesu i odłączyć proces aktualny od procesu-ofiary.
- if(! dumper.end_dumping()) {
- printf("Nie moge poprawnie zakonczyc dumpowania :(\n");
- }
- }
- return 0;
- }
Tyle, jeśli chodzi o główną logikę dumpera. Po szczegóły implementacji odsyłam do plików źródłowych, do których link znajduje się na końcu tego posta. Kod nie posiada komentarzy, ale wszystkie informacje potrzebne do zrozumienia kodu powinieneś mieć w treści tej notki. Pominąłem jedynie komentowanie parsowania kolejnych linii pliku maps, ale ponieważ można zrobić to na co najmniej sto różnych sposobów ;), nie widzę sensu aby pisać kolejny (zapewne) tutorial o wykorzystaniu wyrażeń regularnych przez bibliotekę Glib ;).
Obrona przed zrzutami pamięci
Jest wiele metod ochrony przed dumpowaniem danych. Najbardziej popularnym na systemach Linux jest ręczne uniemożliwienie attachowania do siebie innego procesu przez wykorzystanie funkcji prctl() z argumentem PR_SET_DUMPABLE wynoszącym 0. Spowoduje to sytuację, w której kod sprawdzający możliwość dołączania się ptrace'a do procesu zwróci błąd polegający na braku uprawnień (/kernel/ptrace.c, funkcja __ptrace_may_access). Taką metodę stosuje np. ssh-agent, który uniemożliwia kradzież danych ze swojej przestrzeni pamięci.
Inna metoda to oznaczenie obszarów pamięci jako nie-do-odczytu w odpowiednich momentach czasowych poprzez funkcję mprotect(). Funkcję tą można wywoływać w celu odblokowania strony pamięci, odczytania interesujących nas danych, a następnie ponownej jej blokady, aby niemożliwe było zrzucenie strony na dysk przy pomocy jakiekogolwiek dumpera ring 3.
Dodatkowo, Ubuntu 10.10 wprowadziło do domyślnej instalacji mechanizm blokujący attachowanie ptrace() do innych procesów. Zmiana ta oparta jest na patchu grsecurity, i można ją wyłączyć modyfikując właściwość /proc/sys/kernel/yama/ptrace_scope, który może przyjąć wartości 0 lub 1. Możesz sprawdzić, czy Twój kernel też wspiera mechanizm Yama LSM grepując źródła kernela w poszukiwaniu symboli CONFIG_SECURITY_YAMA. Trzeba jednak zauważyć, że pamięć nadal da się zrzucić po zyskaniu uprawnień administratora.
Na innych dystrybucjach lub innych wersjach Ubuntu dobrym zwyczajem jest uruchamianie programu zawierającego wrażliwe dane z innych kont użytkownika. Inne konto musi charakteryzować się innym uid'em, aby ptrace() zwrócił błąd uprawnień. Inny użytkownik nie musi być rootem, wystarczy, że będzie miał inny uid od uid'a z którego uruchamiany jest dumper.
Inne aplikacje, takie jak np. KeePass, który co prawda dostępny jest jedynie na systemie Windows (wersja multiplatformowa nazywa się keepassx i jest oddzielnym programem), ale stosuje ciekawą technikę wykorzystania zewnętrznego sterownika do przetrzymywania wrażliwych danych. Odczyty ze sterownika są zawężone do jednego procesu (sterownik nie akceptuje zapytań pochodzących z innego procesu) i dane, które są odczytane, zostają usunięte z pamięci po wykonaniu odpowiedniej akcji (ukrycie hasła gwiazdkami, upłynięcie dziesięciu sekund czasu od ostatniego skopiowania hasła do schowka, itp.). Nie jest możliwa wtedy kradzież danych przy pomocy aplikacji uruchamianej w trybie ring 3. Mimo tego, że KeePass istnieje tylko na Windows, nie widzę przeszkód aby podobną technikę użyć na systemach Linuxowych. Oczywisty problem jawi się w momencie instalacji sterownika na system kliencki, ale i to da się w miarę poprawnie rozwiązać – panowie tworzący VirtualBox świecą przykładem ;). Linuxowa wersja KeePass'a niestety nie ma takich zaawansowanych metod ochrony danych, a jedynie przetrzymuje je w pamięci w postaci zaszyfrowanej algorytmem RC4. Pytanie brzmi dlaczego jeszcze nie pojawił się proof of concept narzędzia do wykradania haseł? ;)
Ciekawym anty-przykładem jest program Pidgin, który zapisuje w pamięci hasła w plain text. Wystarczy zdumpować jego pamięć, odfiltrować dane binarne przy pomocy narzędzia strings, a hasło będzie jednym z wypisanych ciągów znaków. Muszę się przyznać że właśnie ten element programu Pidgin stał się swojego rodzaju inspiracją do napisania powyższej notki, która, swoją drogą, urosła do większych od oczekiwań rozmiarów. To dowodzi jedynie faktu, że sam problem jest dość ciekawy i że warto zwrócić uwagę użytkownika do problemu wycieków pamięci innych aplikacji. Polecam samemu przetestować, które programy (np. komunikatory internetowe, przeglądarki) zapisują hasła w postaci plain text. Po wykryciu takich okazów polecam spatchować źródła dodając funkcje prctl() i od tego samego dnia używać własnego utwardzonego forka programu. A w przypadku programów nie open-source, cóż... tough luck!
Źródła dumpera z powyższego artykułu można znaleźć tutaj.


