Studium przypadku hakowania hiperwizorów

https://chacker.pl/

Przeanalizujemy i wykorzystamy lukę CVE-2020-14364,1 autorstwa Xiao Wei i Ziminga Zhanga w kodzie emulacji USB QEMU. Jest to prosta i niezawodna luka, co czyni ją doskonałym studium przypadku. Hiperwizory, takie jak KVM i Xen, używają QEMU jako komponentu procesu roboczego, więc gdy zaatakujemy QEMU, przeprowadzimy eksploatację w trybie użytkownika. W tym rozdziale zakładamy, że na hoście używasz instalacji Linux z włączoną wirtualizacją KVM i że masz działającą instalację Dockera. Dockerfile zawiera środowisko i wszystkie narzędzia używane w tym rozdziale. Cały kod i przykłady w tym rozdziale powinny być wykonywane z poziomu kontenera Dockera. Urządzenie KVM na hoście musi zostać przekazane do kontenera Dockera:

Po umieszczeniu w kontenerze Docker kod można znaleźć w katalogu /labs.

Komunikacja Ring-Buffer

https://chacker.pl/

Mamy współdzielony bufor utworzony z GPADL, który został podzielony na dwa bufory pierścieniowe: pierwszy jest do nadawania, a drugi do odbierania. Nadający bufor pierścieniowy zaczyna się od pierwszego GPFN GPADL i kończy się na GPFN znajdującym się dalej, w pozycjach downstream_offset (jak podaliśmy w żądaniu „open channel”). Odbierający bufor pierścieniowy zaczyna się na końcu bufora nadawania i kończy się na ostatnim GPFN GPADL. Rzeczywiste dane do nadania (lub odebrania) zaczynają się na drugiej stronie każdego bufora pierścieniowego. Pierwsza strona każdego bufora pierścieniowego zawiera strukturę ze stanem bufora pierścieniowego:

Dodatkowe (zarezerwowane) pola mogą następować po polach z tej struktury, a następnie następują po nich bajty wypełniające, aby wypełnić stronę. Do podstawowego użytku musimy dbać tylko o write_index (1) i read_index(2); reszta struktury może pozostać zerowana. Oba indeksy reprezentują przesunięcie w bajtach od początku obszaru danych bufora pierścieniowego (4096 bajtów po stanie bufora pierścieniowego). Gdy dane są zapisywane do bufora pierścieniowego, write_index jest zwiększany o długość danych; jeśli zwiększenie jest większe niż rozmiar bufora pierścieniowego, indeks jest zawijany. Jeśli write_index jest większe niż read_index, przestrzeń pozostawiona w buforze pierścieniowym to rozmiar bufora pierścieniowego minus write_index, plus read_index. Jeśli write_index jest mniejsze niż read_index, przestrzeń pozostawiona to read_index minus write_index. Gdy dane są odczytywane z bufora pierścieniowego, read_index jest zwiększany w ten sam sposób. Jeśli read_index i write_index są równe, bufor pierścieniowy jest albo pusty, albo pełny, w zależności od sytuacji (read_index osiąga write_index lub write_index osiąga read_index). Gdy tak się stanie, powinniśmy powiadomić hosta, co można zrobić, wywołując HvCallSignalEvent przy użyciu pola identyfikatora połączenia oferty odpowiadającej urządzeniu, z którym się komunikujemy, i flagi zdarzenia równej zero. Dane są kapsułkowane w „pakietach” zawierających nagłówek z informacjami potrzebnymi do zidentyfikowania i odczytania całego pakietu, niezależnie od jego wewnętrznego układu:

Pole typu (1) jest jedną z wartości zdefiniowanych w PacketType (2); najczęściej jest to VM_PKT_DATA_INBAND (3). W offset8 (4) mamy przesunięcie (w blokach 8-bajtowych, od początku nagłówka) następnego nagłówka, a w len8 (5) mamy całkowity rozmiar pakietu (w blokach 8-bajtowych, wliczając nagłówek pakietu). Pole flag (6) zwykle wynosi zero, ale w niektórych przypadkach jest ustawione na jeden, aby wskazać, że odbiorca powinien wysłać VM_PKT_COMP (7). Identyfikator transakcji (8) jest wartością naszego wyboru, gdy wysyłamy żądanie; jeśli odpowiadamy na żądanie, powinniśmy ustawić taką samą wartość, jak w żądaniu. Pakiety są uzupełniane do granicy 8-bajtowej, a każdy pakiet kończy się 8-bajtowym zwiastunem (nieuwzględnionym w obliczeniach len8). Urządzenia VMBus implementują własne protokoły, ale wszystkie współdzielą ten sam podstawowy transport. Ze względu na ograniczenia miejsca nie będziemy omawiać różnych implementacji protokołu; jednak dołączono przykładowy skrypt (GHHv6/ch25/labs/time_sync.py), który łączy się ze składnikiem integracji Time Synch i wyświetla czas hosta. Skrypt wykorzystuje moduł GHHv6/ch25/labs/vmbus.py do otwarcia kanału i komunikacji przez bufory pierścieniowe.

Otwieranie kanału

https://chacker.pl/

Nawiązanie komunikacji z jednym z oferowanych urządzeń obejmuje dwa kroki. Po pierwsze, wysyłamy listę numerów ramek stron gościa (GPFN) opisujących zakres(y) pamięci, które będziemy udostępniać hostowi. Po drugie, dzielimy ten region na dwa bufory pierścieniowe: jeden do odbioru, a drugi do transmisji. Udostępnianie pamięci między gościem a hostem (lub dokładniej między partycją podrzędną a partycją nadrzędną) odbywa się poprzez utworzenie listy deskryptorów adresów fizycznych gościa (GPADL). Jeśli kiedykolwiek pracowałeś z listami deskryptorów pamięci systemu Windows (MDL),11 zasada jest taka sama: utwórz ciągły bufor z nieciągłej pamięci fizycznej. W przypadku GPADL wysyłamy GPFN (host przetłumaczy je na odpowiednie SPFN). Tworzymy GPADL z sekwencji „zakresów GPA”, a każdy zakres jest kodowany w następujący sposób:

Zakres GPA to struktura o zmiennej wielkości, zaczynająca się od rozmiaru zakresu w bajtach (1), po którym następuje przesunięcie (2) (w bajtach, względem pierwszej strony pamięci). Pozostała część struktury to lista GPFN (3) reprezentująca zakres pamięci. Liczba elementów listy powinna odpowiadać liczbie wymaganych stron, biorąc pod uwagę rozmiar zakresu i przesunięcie początkowe. Ponieważ nasza struktura używa modelu mapowania pamięci 1:1, będziemy po prostu używać fizycznie ciągłych stron. Biorąc pod uwagę adres bazowy i argumenty rozmiaru, funkcja gpa_range (4) zwraca zakres GPA. Aby utworzyć GPADL, wysyłamy żądanie „nagłówka GPADL” (msgtype 8) z listą zakresów GPA. Kodujemy tę wiadomość w następujący sposób:

Po nagłówku wiadomości mamy pole child_relid (1). Podajemy wartość uzyskaną z tego samego pola wiadomości oferty urządzenia, z którym chcemy się komunikować. Pole gpadl (2) jest ustawione na wybraną przez nas wartość; zostanie ona użyta do zidentyfikowania GPADL. Na końcu wiadomości mamy sekwencję zakresów GPA (3). Liczba elementów w tej sekwencji jest ustawiona w rangecount (4), a całkowity rozmiar (w bajtach) tej sekwencji w range_buflen (5). Funkcja gpa_range_size (6) oblicza ten rozmiar, kodując listę zakresów. Gdy bufor, który chcemy utworzyć, jest wystarczająco mały, zmieści się w pojedynczej wiadomości „nagłówek GPADL”; jednak może się zdarzyć, że liczba PFN i/lub zakresów wymaganych do reprezentowania większych buforów nie zmieści się w pojedynczej wiadomości (rozmiar wiadomości używany przez HvCallPostMessage jest ograniczony do 240 bajtów). W takich przypadkach dzielimy zawartość pola „range” na fragmenty, aby dopasować je do tego rozmiaru. Pierwszy fragment jest wysyłany z „GPADL header”, a pozostałe w serii wiadomości „GPADL body” (msgtype 9). Wiadomość „GPADL body” zawiera nagłówek, po którym następuje fragment. Kodowanie nagłówka jest następujące:

Pole msgnumber (1) identyfikuje wysyłany fragment (zwiększamy tę wartość dla każdego wysyłanego fragmentu), a pole gpadl (2) jest ustawione na tę samą wartość, której użyliśmy w wiadomości nagłówka GPADL. Po wysłaniu nagłówka GPADL i (opcjonalnie) jednej lub więcej wiadomości treści GPADL, jesteśmy powiadamiani o utworzeniu GPADL za pomocą odpowiedzi „GPADL created” (msgtype 10). Układ tej wiadomości jest następujący:

Pola child_relid (1) i gpadl (2) zawierają te same wartości, które podaliśmy, a creation_status (3) powinno wynosić zero. Na koniec, aby skonfigurować bufory pierścieniowe, wysyłamy żądanie „open channel” (msgtype 5). Układ tej wiadomości jest następujący:

Jak zwykle, child_relid (1) jest ustawione na tę samą wartość, co pole child_relid oferty. Ustawiamy openid (2) na wartość naszego wyboru i przekazujemy identyfikator naszego nowo utworzonego GPADL do ringbuffer_gpadl (3). W downstream_offset (4) przekazujemy przesunięcie (w stronach), które podzieli ten bufor na dwa bufory pierścieniowe. Ustawimy docelowy procesor wirtualny (5) i user_data (6) na zero. Jeśli żądanie się powiedzie, otrzymamy odpowiedź „open channel result” (msgtype 6):

Pola child_relid (1) i openid (2) zawierają te same wartości, które podaliśmy, a status (3) powinien wynosić zero. W tym momencie możemy komunikować się z urządzeniem za pośrednictwem dwóch buforów pierścieniowych.

Listing VMBus Devices

https://chacker.pl/

Moduł GHHv6/ch25/labs/vmbus.py implementuje wszystko, co zostało opisane do tej pory. Zaleca się uważne przeczytanie jego kodu, zwracając szczególną uwagę na każdy krok. Jeśli wywołamy go bezpośrednio, wydrukuje informacje o urządzeniu uzyskane z komunikatów ofertowych. Informacje te obejmują wartość child_relid, identyfikator UUID if_instance i if_type (przekształcony na opis urządzenia z identyfikatora UUID):

Żądanie ofert

https://chacker.pl/

Aby dowiedzieć się, które urządzenia są obecne na VMBus, wysyłamy wiadomość „żądanie ofert” (msgtype 3), która jest po prostu VmbusChannelMessageHeader. Po wysłaniu wiadomości otrzymamy wiele wiadomości „kanał ofert” (msgtype 1), kończąc na wiadomości „wszystkie oferty dostarczone” (msgtype 4). Układ „kanału ofert” jest następujący:

Informacje w tej wiadomości są specyficzne dla urządzenia i kanału. Pole child_relid (1) zawiera identyfikator kanału, który zostanie później użyty do skonfigurowania współdzielonego obszaru pamięci i nawiązania komunikacji z urządzeniem. Jeśli monitor_allocated (2) jest różne od zera, urządzenie korzysta z monitorowanych powiadomień, w którym to przypadku monitorid (3) zostanie użyty jako indeks do stron monitora (ze względu na ograniczenia miejsca nie będziemy omawiać ani używać stron monitora). Port zdarzeń skojarzony z connection_id (4) zostanie użyty do sygnalizowania zdarzeń do urządzenia (za pośrednictwem HvCallSignalEvent). W informacjach specyficznych dla urządzenia mamy if_type (5) zawierający UUID klasy urządzenia, podczas gdy if_instance (4) jest UUID konkretnego urządzenia (gdyby nasza maszyna wirtualna miała dwa urządzenia tego samego typu, zobaczylibyśmy dwie oferty z tym samym if_type, ale innym if_instance).

UWAGA: Uniwersalny unikatowy identyfikator (UUID) to standaryzowane 128-bitowe kodowanie etykiet identyfikatorów. W tym rozdziale będziemy odnosić się do UUID wyłącznie jako do wariantu little-endian.

Układ komunikatu „wszystkie oferty dostarczone” to VmbusChannelMessageHeader (msgtype 4).

Inicjacja

https://chacker.pl/

Aby zainicjować komunikację z VMBus, wysyłamy (poprzez HvCallPostMessage) żądanie „inicjacji kontaktu”. Ta wiadomość jest wysyłana do identyfikatora połączenia 4 (starsze wersje używają 1, ale my użyjemy 4). Układ tej wiadomości (przeniesionej do naszego frameworka z definicji znalezionych w jądrze Linux) jest następujący:

Wszystkie wiadomości VMBus zaczynają się od tego samego nagłówka wiadomości zawierającego typ wiadomości (1). W tym przypadku msgtype będzie wynosić 14. Następne pole zawiera wersję VMBus (2). Zasadniczo powinniśmy zacząć od najwyższej możliwej wersji i powtarzać, obniżając wersje (wysyłając wiele wiadomości inicjujących), aż do skutku. W naszym przypadku wyślemy pojedynczą wiadomość z prośbą o wersję, która powinna działać w naszej konfiguracji. Następnie mamy docelowy procesor wirtualny (3) (wiadomości są wysyłane do SynIC tego procesora) i SINTx (4) (użyjemy SINT2). Na koniec możemy podać GPA dwóch stron „monitora”. Mogą być one używane przez niektóre urządzenia do szybkich powiadomień; ustawimy je, ale nie będziemy ich używać. Pierwsza strona (5) jest używana do powiadomień typu child-to-parent (partycja główna), a druga (4) do powiadomień typu parent-to-child. Jeśli negocjacje zakończą się powodzeniem, otrzymamy wiadomość „version response” w slocie SIMP dostarczonego przez nas SINTx. Należy pamiętać, że jeśli nie ustawimy trybu sondowania SINTx, możemy otrzymać przerwanie dla wektora, który przypisaliśmy do niego podczas demaskowania SINTx (więc potrzebujemy odpowiedniego programu obsługi IDT). Wszystkie wiadomości wysyłane przez rodzica trafiają do SINTx dostarczonego w żądaniu „inicjowania kontaktu”. Układ „odpowiedzi wersji” wygląda następująco:

Interesuje nas pole ID połączenia (1). Zastąpimy nasze poprzednie ID połączenia (4) tym, które otrzymamy tutaj.

VMBus

https://chacker.pl/

VMBus to oparty na kanałach mechanizm komunikacji używany przez VSP i IC. Minimalne wymagania wstępne naszego gościa do korzystania z VMBus są następujące:

  • Obsługa wywoływania HvCallPostMessage i HvCallSignalEvent. Musimy zarejestrować HV_X64_MSR_GUEST_OS_ID i zamapować stronę hiperwywołania w HV_X64_MSR_HYPERCALL.
  • Włączyć SynIC.
  • Odmaskować co najmniej jeden SINTx (użyjemy HV_X64_MSR_SINT2).

• Zamapować stronę wiadomości (HV_X64_MSR_SIMP).

Hiperpołączenia: nagłówki o zmiennej wielkości

https://chacker.pl/

Widzieliśmy, że hiperpołączenia mają niejawny nagłówek o stałej wielkości, a hiperpołączenia „rep” mają listę elementów o zmiennej wielkości. Hiperpołączenia mogą również mieć dane o zmiennej wielkości. Przyjrzyjmy się jednemu z nich:

Wygląda to podobnie do HvCallFlushVirtualAddressSpace, ale ProcessorMask został zastąpiony przez ProcessorSet (1) , który jest kolekcją o zmiennym rozmiarze. W tym przypadku niejawny nagłówek o stałym rozmiarze odpowiada pierwszym dwóm argumentom (16 bajtów), a ProcessorSet jest nagłówkiem o zmiennym rozmiarze. W pamięci nagłówek o zmiennym rozmiarze musi zostać umieszczony po nagłówku o stałym rozmiarze, a jego rozmiar musi zostać zaokrąglony do granularności 8 bajtów. Rozmiar nagłówka zmiennej (w blokach 8-bajtowych) musi zostać zakodowany w bitach 25–17 rejestru RCX. Nasz ostatni przykład to hiperwywołanie „rep” z nagłówkami o zmiennym rozmiarze:

Argumenty są w tej samej kolejności, w jakiej powinniśmy je umieścić w pamięci: nagłówek o stałym rozmiarze, po którym następuje nagłówek o zmiennym rozmiarze, a na końcu lista „rep”.

Hiperwywołania: proste i rep

https://chacker.pl/

Hiperwywołania można podzielić na dwa typy: proste lub „rep” (powtórz). Proste hiperwywołania wykonują operację na pojedynczym argumencie, podczas gdy hiperwywołania „rep” działają na liście o zmiennej wielkości elementów o stałej wielkości. Przykładem prostego hiperwywołania jest HvCallFlushVirtualAddressSpace, który służy do wykonywania pełnego unieważnienia bufora tłumaczenia lookaside (TLB) gościa:

Dane wejściowe tego hiperwywołania tworzą blok o stałym rozmiarze 24 bajtów (każdy argument ma 8 bajtów). To jest „niejawny rozmiar nagłówka” hiperwywołania. Z drugiej strony HvCallFlushVirtualAddressList jest hiperwywołaniem „rep”, przyjmującym listę zakresów GVA do unieważnienia:

Tutaj pierwsze trzy argumenty są takie same jak w HvCallFlushVirtualAddressSpace i tworzą również 24-bajtowy nagłówek o stałym rozmiarze. Możemy zobaczyć argument GvaCount (1) zdefiniowany jako zarówno wejściowy, jak i wyjściowy; wewnętrznie ten argument jest zakodowany w polu „total rep count” rejestru RCX. Pole „start rep index” tego samego rejestru będzie początkowo zerowe, a następnie hiperwywołanie zwiększy je podczas przetwarzania elementów listy, więc ostatecznie GvaCount może zostać ustawione na jego wartość. GvaRangeList (2) to miejsce, w którym zaczyna się lista o zmiennym rozmiarze. W pamięci jest to zaraz po bloku 24-bajtowym. Każdy element musi mieć stały rozmiar (w tym przypadku 8 bajtów), a lista powinna zawierać liczbę elementów GvaCount.

WSKAZÓWKA: Moduł GHHv6/ch25/labs/hypercall.py dołączony do źródeł tego rozdziału zawiera implementację umożliwiającą wykorzystanie „wolnego” hiperwywołania (HvCallPostMessage) i „szybkiego” hiperwywołania (HvCallSignalEvent).

Ciekawą rzeczą w hiperwywołaniach „rep” jest to, że mogą one powrócić przed ukończeniem „całkowitej liczby powtórzeń” i mogą zostać ponownie wywołane (RIP nie jest zwiększany, a VMCALL jest ponownie wykonywany), w którym to przypadku będą nadal przetwarzać elementy listy od ostatniej wartości „indeksu początkowego powtórzenia”. Ten mechanizm jest znany jako kontynuacja hiperwywołania

UWAGA: Czas spędzony wewnątrz hiperwywołania musi być ograniczony, aby wirtualny procesor nie utknął na długie okresy czasu. Z tego powodu niektóre proste hiperwywołania również używają kontynuacji hiperwywołań.

Hiperwywołania: wolne i szybkie

https://chacker.pl/

Argumenty wejściowe i wyjściowe hiperwywołania mogą być przekazywane na trzy sposoby: w pamięci, w rejestrach ogólnego przeznaczenia i w rejestrach XMM. Hiperwywołania wykorzystujące podejście oparte na pamięci są znane jako „wolne”, natomiast te wykorzystujące podejście oparte na rejestrach są znane jako „szybkie”. Gdy chcemy użyć szybkiego hiperwywołania, musimy to wskazać, ustawiając bit na pozycji 16 rejestru RCX; w przeciwnym razie bit musi być czysty. Gdy przekazujemy argumenty oparte na pamięci, RDX zawiera GPA wejścia, podczas gdy R8 zawiera GPA wyjścia. Oba adresy powinny wskazywać na prawidłową pamięć gościa i nie powinny się nakładać. Powinny być wyrównane do 8 bajtów i nie mogą przekraczać granic strony. Nie mogą należeć do obszaru nakładki (przykładami nakładek są strona hiperwywołania, SIMP i SIEFP). Dostęp do odczytu jest wymagany dla GPA wskazywanego przez RDX, natomiast dostęp do zapisu jest wymagany dla adresu w R8. Tylko podzbiór dostępnych hiperwywołań może używać argumentów rejestru; wynika to z ograniczeń rozmiaru. W przypadku szybkich hiperwywołań wykorzystujących rejestry ogólnego przeznaczenia, rozmiar argumentu musi mieścić się w rejestrze 64-bitowym: RDX jest używany do wejścia, a R8 do wyjścia. Jeśli są dostępne, szybkie hiperwywołania XMM mogą być używane dla rozmiarów do 112 bajtów. W tym przypadku dane są przechowywane w zestawie rejestrów składających się z RDX, R8 oraz rejestrów XMM w zakresie od XMM0 do XMM5. Ten sam zestaw rejestrów może być współdzielony do wejścia i wyjścia; w drugim przypadku do przechowywania wyjścia zostaną użyte tylko rejestry, które nie zostały użyte do wejścia. Na koniec, zarówno w wolnych, jak i szybkich hiperwywołaniach, rejestr RAX jest używany do przechowywania wartości zwracanej hiperwywołania.