Interakcja ze sterownikiem

https://chacker.pl/

Teraz, gdy wykonaliśmy statyczną inżynierię wsteczną kodu i zidentyfikowaliśmy ścieżkę do dowolnego memmove, napiszmy kod do interakcji ze sterownikiem. Analiza dynamiczna jest bardzo potężnym narzędziem w inżynierii wstecznej i procesie rozwoju exploitów, więc będziemy wywoływać sterownik za pomocą kodu i używać debugera, aby obserwować, co się stanie. Podłącz debuger jądra i uzyskaj przesunięcie funkcji memmove, umieszczając kursor na funkcji i uruchamiając get_screen_ea() – idaapi.get_imagebase() w IDAPython. Spowoduje to przesunięcie od podstawy sterownika do funkcji, którą chcesz debugować. Następnie ustaw punkt przerwania dla funkcji w WinDbg, wydając polecenie bp z nazwą sterownika i względnym przesunięciem: bp dbutil_2_3+0x5294. Jeśli WinDbg zgłasza, że ​​nie może rozwiązać wyrażenia, upewnij się, że sterownik jest załadowany i spróbuj wydać polecenie .reload do debugera, a następnie sprawdź, czy punkt przerwania jest ustawiony poprawnie za pomocą bl. Po ustawieniu punktu przerwania potrzebujemy kodu, aby go faktycznie uruchomić. Aby nieco zmienić tempo, użyjemy Rust do napisania tego narzędzia. Na docelowej maszynie wirtualnej pobierz i zainstaluj albo Visual Studio Community, albo Build Tools for Visual Studio. Zainstalowane narzędzia są wymagane przez łańcuch narzędzi Rust Windows MSVC do kompilowania i łączenia programów. Zainstaluj Rust na maszynie docelowej, pobierając rustup-init.exe z https://rustup.rs i używając go do zainstalowania łańcucha narzędzi x86_64-pc-windows-msvc. Utwórz nowy projekt za pomocą cargo new –lib dbutil. Napiszemy narzędzie, które pozwoli nam określić IOCTL i bufor do przekazania do sterownika DBUtil za pośrednictwem funkcji DeviceIoControl. Dodaj następujące wiersze do pliku Cargo.toml w obszarze [dependencies]:

„Crate” w Rust to pakiet. Skrzynia winapi zapewnia powiązania interfejsu funkcji obcych (FFI) z interfejsem API systemu Windows, umożliwiając nam interakcję z interfejsem API systemu Windows bez konieczności ręcznego deklarowania wszystkich typów systemu Windows i prototypów funkcji, których potrzebujemy. Możesz również spróbować użyć oficjalnej skrzyni Microsoft Rust Windows-rs. Ta deklaracja włączy wszystkie funkcje, których potrzebujemy zarówno dla skryptu wywołującego IOCTL, jak i exploita. Moduł hex pozwoli nam zamienić ciąg heksadecymalny na bajty, które zostaną przekazane do sterownika. Najpierw musimy móc otworzyć uchwyt urządzenia za pomocą CreateFileA. Dodaj poniższe do pliku lib.rs w katalogu src projektu:

Importy u góry (1) zostaną wykorzystane do wszystkich funkcji zarówno wywołującego IOCTL, jak i exploita, więc nie martw się o nieużywane ostrzeżenia podczas kompilacji. Funkcja open_dev otworzy uchwyt do sterownika DBUtil za pośrednictwem jego nazwy łącza symbolicznego, którą widzieliśmy podczas statycznego inżynierii wstecznej sterownika. Prefiks \\\\.\\ to współczesny sposób na powiedzenie „DosDevices”, który reprezentuje globalną przestrzeń nazw. Do ciągu dodano \0, ponieważ funkcja oczekuje ciągu C zakończonego znakiem NULL.(2) Funkcja null_mut() jest odpowiednikiem przekazania wartości NULL jako argumentu(3). Następnie napiszmy inną funkcję w lib.rs, aby wywołać kontrolkę IO urządzenia za pośrednictwem DeviceIoControl:

Ta funkcja przyjmuje HANDLE do urządzenia DBUtil (1), kod IOCTL (2), bufor wejścia/wyjścia (3) i rozmiar bufora wejścia/wyjścia (4). Pamiętaj, że sterownik oczekuje, że rozmiary wejścia i wyjścia zawsze będą takie same podczas wywoływania IOCTL, więc po prostu przekażemy ten sam bufor i długość dla argumentów bufora wejścia i wyjścia oraz rozmiaru. Funkcja zwraca wartość logiczną reprezentującą, czy się powiedzie (5). Teraz, gdy mamy dwie funkcje, musimy wejść w interakcję ze sterownikiem, utwórz folder w katalogu src projektu o nazwie bin, a następnie utwórz w nim plik o nazwie ioctlcall.rs. Wypełnij plik następującą zawartością:

Główna funkcja binarnego pliku ioctlcall otworzy urządzenie (1), pobierze argumenty programu jako wektor ciągów znaków (2), przeanalizuje numer IOCTL z pierwszego argumentu (3), zdekoduje dane wejściowe heksadecymalne z drugiego argumentu (4), wywoła określony IOCTL (5), a następnie wydrukuje bufor wejścia/wyjścia (3). Spróbujmy osiągnąć punkt przerwania, uruchamiając cargo run –bin ioctlcall 0x9B0C1EC4 112233445566778899101112131415161718192021222324. Pamiętaj, że określamy dane wejściowe w formacie heksadecymalnym, więc potrzeba 48 znaków, aby przedstawić 24 bajty. Punkt przerwania powinien zostać osiągnięty! Ale dlaczego 24 bajty? Przypomnij sobie funkcję memmove w sterowniku: oczekuje ona co najmniej 0x18 (24) bajtów dla bufora wejściowego i wyjściowego. Sprawdź zawartość pierwszego argumentu tej funkcji (rcx) za pomocą polecenia dqs @rcx. Powinieneś zobaczyć 8-bajtowy wskaźnik jądra, a następnie 0000000000000018. Jak odkryliśmy poprzez analizę statyczną, pierwszym argumentem tej funkcji jest wskaźnik do DeviceExtension. Przypomnij sobie początek kodu obsługi IOCTL: bufor wejściowy/wyjściowy (_IRP->AssociatedIrp.SystemBuffer) został przeniesiony do DeviceExtension przy przesunięciu 0, a rozmiar bufora wejściowego (_IO_STACK_LOCATION- >Parameters.DeviceIoControl.InputBufferLength) został przeniesiony do DeviceExtension przy przesunięciu 8. Zanotuj adres rozszerzenia SystemBuffer na później! Pojedynczy krok 26 razy (p 0n26) do wywołania memmove i zbadaj argumenty memmove w rejestrach rcx, rdx i r8: r rcx,rdx,r8. Od razu powinieneś zauważyć, że kontrolujemy część rdx; górne 4 bajty (16151413) wydają się pasować do bajtów 13–16 naszego wejścia (pamiętaj: little endian). Użyjmy WinDbg, aby zobaczyć różnicę między wartością, którą widzimy w rdx, a naszym rzeczywistym wejściem w tej lokalizacji w buforze:

Główna funkcja binarnego pliku ioctlcall otworzy urządzenie (1), pobierze argumenty programu jako wektor ciągów znaków (2), przeanalizuje numer IOCTL z pierwszego argumentu (3), zdekoduje dane wejściowe heksadecymalne z drugiego argumentu (4), wywoła określony IOCTL (5), a następnie wydrukuje bufor wejścia/wyjścia (3). Spróbujmy osiągnąć punkt przerwania, uruchamiając cargo run –bin ioctlcall 0x9B0C1EC4 112233445566778899101112131415161718192021222324. Pamiętaj, że określamy dane wejściowe w formacie heksadecymalnym, więc potrzeba 48 znaków, aby przedstawić 24 bajty. Punkt przerwania powinien zostać osiągnięty! Ale dlaczego 24 bajty? Przypomnij sobie funkcję memmove w sterowniku: oczekuje ona co najmniej 0x18 (24) bajtów dla bufora wejściowego i wyjściowego. Sprawdź zawartość pierwszego argumentu tej funkcji (rcx) za pomocą polecenia dqs @rcx. Powinieneś zobaczyć 8-bajtowy wskaźnik jądra, a następnie 0000000000000018. Jak odkryliśmy poprzez analizę statyczną, pierwszym argumentem tej funkcji jest wskaźnik do DeviceExtension. Przypomnij sobie początek kodu obsługi IOCTL: bufor wejściowy/wyjściowy (_IRP->AssociatedIrp.SystemBuffer) został przeniesiony do DeviceExtension przy przesunięciu 0, a rozmiar bufora wejściowego (_IO_STACK_LOCATION- >Parameters.DeviceIoControl.InputBufferLength) został przeniesiony do DeviceExtension przy przesunięciu 8. Zanotuj adres rozszerzenia SystemBuffer na później! Pojedynczy krok 26 razy (p 0n26) do wywołania memmove i zbadaj argumenty memmove w rejestrach rcx, rdx i r8: r rcx,rdx,r8. Od razu powinieneś zauważyć, że kontrolujemy część rdx; górne 4 bajty (16151413) wydają się pasować do bajtów 13–16 naszego wejścia (pamiętaj: little endian). Użyjmy WinDbg, aby zobaczyć różnicę między wartością, którą widzimy w rdx, a naszym rzeczywistym wejściem w tej lokalizacji w buforze:

Przejdź do debugera jądra i pobierz bazę jądra za pomocą polecenia ? nt. Ustaw bajty 9–16 bufora wejścia/wyjścia programu ioctlcall na bazę jądra w little-endian, bajty 17–20 na 0, a następnie upewnij się, że bufor ma co najmniej 24 + 8 (32) bajtów długości. Ustaw pozostałe bajty na 0. Na przykład, jeśli podstawa jądra znajduje się w 0xfffff80142000000, bufor wejściowy powinien wynosić

000000000000000000000004201f8ffff0000000000000000000000000000000000 00.

Spójrz na bufor wyjściowy wydrukowany jako wynik, a następnie porównaj ostatnie 8 bajtów bufora z pierwszymi 8 bajtami podstawy jądra:

Powinny pasować! Znaki MZ powinny być Ci znane, ponieważ stanowią pierwsze 2 bajty pliku PE (podobnie jak jądro). Jako dodatkowe ćwiczenie przetestuj w podobny sposób możliwość dowolnego zapisu. Nie będziesz w stanie zapisywać do bazy jądra, ponieważ znajduje się ona w pamięci tylko do odczytu, ale możesz znaleźć inne miejsca do zapisu, w tym w trybie użytkownika. Po potwierdzeniu, że możemy pomyślnie kontrolować parametry memmove, aby uzyskać dowolny odczyt i zapis, możemy przetłumaczyć strukturę na kod. Wznów dodawanie kodu do pliku lib.rs:

Tag repr(C) mówi kompilatorowi Rust, aby wyrównał strukturę jak strukturę C. Tag derive(Default) mówi, że wszystkie typy w tej strukturze implementują cechę core::default::Default, więc cała struktura również. Domyślna wartość dla liczb całkowitych to po prostu 0. Możesz się zastanawiać, dlaczego element ptr jest usize, a nie LPVOID (*mut c_void); usize zawsze ma rozmiar wskaźnika, a rzutowanie z dowolnych typów wskaźników na usize jest łatwiejsze niż rzutowanie z dowolnych typów wskaźników na LPVOID. Teraz, gdy mamy pojęcie, jak wygląda oczekiwana struktura i jak wywołać memmove z dowolnymi parametrami, możemy zacząć pisać exploit, aby podnieść się do poziomu SYSTEM. Zanim jednak napiszesz więcej kodu, musisz zrozumieć, co tak naprawdę oznacza „podniesienie się do poziomu SYSTEM”.