IO-Ports Fuzzer

https://chacker.pl/

Czas napisać nasz pierwszy fuzzer! Celem jest nauczenie się, jak różne części struktury pasują do siebie. W tym przypadku skupimy się na prostocie, a nie na użyteczności. Ten fuzzer będzie naiwnym generatorem losowych instrukcji IN/OUT:

Do tej nowej podklasy Fuzzer dodano dwie właściwości: listę odkrytych portów (1) (początkowo pustą) i czarną listę (2), która została zainicjowana portami używanymi przez port szeregowy, dzięki czemu możemy uniknąć bałaganu w transporcie protokołu. Pierwszą rzeczą, którą robi fuzz, jest sprawdzenie, czy odpowiedź ostatniej iteracji zawiera dane wynikające z poprzedniego wykonania instrukcji IN. Jeśli te dane nie mają ustawionych wszystkich bitów (3), oznacza to, że odczytaliśmy prawidłowy port, więc dodajemy go do listy odkrytych portów. Porty docelowe to losowo generowane 16-bitowe liczby całkowite (4) lub są pobierane z elementów listy odkrytych portów (5), ale nie mogą znajdować się na czarnej liście. Instrukcje IN lub OUT są wybierane losowo. IN zawiera dodatkowy kod do wysłania odpowiedzi, w tym numer portu i wartość operandu docelowego (6). OUT przyjmuje losową wartość (7) w swoim operandie źródłowym i odsyła pustą odpowiedź. Wywoływany jest fuzzer, który przekazuje argument wartości początkowej:

Fuzzing

https://chacker.pl/

Dzięki naszym obecnym ramom jesteśmy teraz w stanie uruchomić maszynę wirtualną i wykonać dowolny kod w Ring-0. Wszystko to zaledwie w kilku linijkach Pythona. Teraz użyjemy go do fuzzowania hiperwizorów!

Klasa bazowa fuzzera

Pisanie fuzzerów obejmuje pewne powtarzalne zadania, które możemy abstrahować do klasy bazowej:

Obiekty fuzzera (lub dowolnej klasy pochodnej) są tworzone (1) z wartości początkowej używanej do inicjalizacji pseudolosowego stanu fuzzera. Faktyczne fuzzowanie jest wykonywane przez metodę run (2), która uruchamia gościa i obsługuje przychodzące wiadomości. Pętla przetwarzania wiadomości ustawia alarm, więc jeśli fuzzer utknie na kilka sekund, zgłaszany jest wyjątek i fuzzer uruchamia się ponownie z nowym gościem. Wiadomości odpowiedzi są wysyłane do metody fuzz (3), która musi zostać zaimplementowana przez podklasy. Przychodzące wiadomości rozruchowe mogą być obsługiwane przez podklasy, które przeciążają on_boot (4). Gdy on_boot nie jest przeciążony, ta metoda po prostu wywołuje fuzz, przekazując puste ciało. Na koniec mamy kilka wygodnych metod generowania kodu (code, context_save i context_restore)

Wstrzykiwanie kodu (Python)

https://chacker.pl/

Zanim wprowadzimy niezbędne zmiany w klasie Guest, będziemy potrzebować kilku klas pomocniczych:

  • RemoteMemory Celem tej klasy jest zapewnienie interfejsu alloc/free do pamięci gościa. Będzie ona instancjonowana z informacji memorymap komunikatów rozruchowych.

• Code Ta klasa abstrahuje wywołanie asemblera w celu wygenerowania kodu binarnego z ciągu zawierającego asembler.

Naszą implementację RemoteMemory oparliśmy na module porcji 9. Metody add_region (1) i del_region (2) będą używane wyłącznie na etapie inicjalizacji. Gdy obiekt zostanie w pełni zainicjowany, pamięć może zostać zażądana za pomocą alloc (3), wykorzystując żenująco nieefektywną strategię alokacji, ale będzie to wystarczające dla naszych potrzeb.

Kod (1) jest tworzony z ciągu kodu asemblera i słownika symboli. Definicje tych symboli są dodawane do kodu asemblera razem z dyrektywą „include macros.asm”, gdzie możemy dodawać nasze niestandardowe makra. Jedyną metodą tej klasy jest build (2), która wywołuje asembler i kompiluje kod pod określonym adresem bazowym, zwracając wynikowy plik binarny. Teraz możemy przejść do modyfikacji klasy Guest:

Pierwsza zmiana dotyczy metody messages, więc teraz wywołujemy _init_boot_info (1), gdy nadejdzie komunikat rozruchowy, aby zainicjować dwie właściwości: symbole i pamięć (instancję RemoteMemory). Zakresy adresów opisujące obszary dostępnej pamięci są dodawane do obiektu memory, a zakres adresu od zera do końca jądra jest usuwany z dostępnej pamięci. Zaimplementowano nowe metody w celu zbudowania operacji, które składają się na komunikat żądania:

  • op_write (2) Pobiera instancję Code (i opcjonalnie adres bazowy), buduje kod i koduje wynikowy plik binarny w operacji zapisu, która jest następnie dodawana do listy operacji żądania.
  • ​​op_exec (3) Pobiera adres i koduje go w operacji wykonania, dodając go do listy operacji.
  • op_commit (4) Pobiera zawartość z listy operacji w celu zbudowania i wysłania komunikatu żądania.

Te metody zapewniają API niskiego poziomu, ale zaimplementowaliśmy metodę execute (5) dla najczęstszego przypadku użycia, wymagając jedynie instancji Code. Przetestujmy tę nową funkcjonalność, wykonując kod w gościu:

Gdy nadejdzie komunikat rozruchowy, skrypt wstrzykuje fragment kodu do komputera gościa, aby wysłać komunikat „hello world!” (1)

UWAGA: Makra OOB_PRINT i REPLY_EMPTY są zdefiniowane w pliku „macros.asm”, który został pominięty w kodzie ze względu na zwięzłość.

Możemy zobaczyć komunikat „hello world!” i pustą odpowiedź wygenerowaną przez wstrzyknięty kod!

Protokół komunikacyjny (Python)

https://chacker.pl/

Do implementacji protokołu użyjemy Construct,6 modułu Pythona do pisania parserów binarnych. Zamiast używać serii wywołań pack/unpack, Construct umożliwia nam pisanie kodu w stylu deklaratywnym, co generalnie prowadzi do bardziej zwięzłej implementacji. Inne moduły, które zaimportujemy, to fixedint,7, aby zastąpić typ całkowity „bignum” Pythona, oraz crc32c,8, aby użyć tej samej implementacji CRC32-C, co jądro.

WSKAZÓWKA : Zanim przejdziesz dalej, zaleca się zapoznanie się z dokumentacją Construct, aby się z nią zapoznać.

Zacznijmy od zdefiniowania nagłówka wiadomości:

Ten kod wygląda podobnie do swojego odpowiednika w C, ale w tym przypadku oddzieliliśmy pole hdr_csum od reszty nagłówka, który jest zawinięty w RawCopy (1), aby uzyskać do niego dostęp jako do binarnego blobu za pomocą c.this.hdr.data. W ten sposób możemy obliczyć jego CRC32-C(2). Innym ważnym rozróżnieniem jest wprowadzenie syntetycznego pola o nazwie _csum_offset (3), które służy do przechowywania bieżącej pozycji strumienia. Później użyjemy tego pola, aby uzyskać dostęp do pola sumy kontrolnej (4) podczas obliczania CRC32-C treści wiadomości. Postępując zgodnie z tą samą kolejnością, co implementacja w C, zdefiniujemy wartości TP i prymitywy (liczby całkowite):

IntPrefixes (1) to grupa wartości TP odpowiadająca typom prymitywnym, a IntConstructs (2) to powiązana z nią grupa konstrukcji. Zamiast używać dużych liczb całkowitych Pythona, chcemy pracować z wartościami o stałym rozmiarze. W tym celu utworzyliśmy listę adapterów zwanych IntAdapters (3). Na koniec mapujemy wartości TP na ich odpowiednie adaptery w IntAlist (4). Typy złożone wymagają więcej pracy, głównie ze względu na implementację adapterów w celu ich konwersji na standardowe kolekcje:

Zaczynamy od CompAlist (1), listy asocjacyjnej wartości TP reprezentujących typy złożone i ich odpowiednie konstrukcje. Pierwszym elementem tej listy jest typ tablicy (2), w którym definiujemy konstrukcję dla nagłówka Array_t, po którym następuje pole „v” do przechowywania elementów tablicy. Konstrukcja jest opakowana przez ArrayAdapter (3), który konwertuje wynikowy obiekt na krotkę Pythona. Następnym elementem jest typ ciągu (4), który jest bezpośrednio powiązany z konstrukcją CString. Na koniec mamy typ listy (5). W tym przypadku wiążemy konstrukcję z symbolem List (6), abyśmy mogli odwoływać się do niego rekurencyjnie. Możemy zobaczyć, że konstrukcja odwołuje się również do symbolu o nazwie Body (7), którego jeszcze nie zdefiniowaliśmy. Aby umożliwić pracę z deklaracjami forward, używamy LazyBound. Konstrukcja jest opakowana przez ListAdapter (8), aby przekonwertować wynikowy obiekt na listę Pythona. Body analizuje prefiks TP i szuka powiązanej z nim konstrukcji zarówno w IntAlist, jak i CompAlist. Ta konstrukcja może analizować (lub budować) dowolną wartość TP, więc odwołujemy się do niej podczas analizowania elementów listy, ale także podczas analizowania treści wiadomości. Owijamy ją za pomocą BodyAdapter (9), aby usunąć teraz zbędny prefiks TP podczas konwersji obiektu na kolekcję Pythona. Aby ukończyć implementację, potrzebujemy konstrukcji dla całych wiadomości:

Message (1) łączy konstrukcje MsgHdr i Body, i oblicza CRC32-C tej ostatniej, która jest stosowana do pola sumy kontrolnej w nagłówku. Osiąga się to poprzez przekazanie wartości z _csum_offset do Pointer. (2) Ostateczny interfejs jest udostępniany przez funkcje recv (3) i send (4). Biorąc pod uwagę obiekt czytelnika, recv deserializuje wiadomość, zwracając krotkę z typem wiadomości i jej treścią. W przypadku send przyjmuje obiekt pisarza i treść żądania (jako standardową kolekcję Pythona), serializuje wiadomość i zapisuje ją do obiektu.

Gość (1) to menedżer kontekstu, w którym zarządzanym przez nas zasobem jest instancja maszyny wirtualnej. Obecnie używamy QEMU/KVM, ale moglibyśmy utworzyć podklasę, aby współpracować z innymi celami. Generator wiadomości (2) odbiera i analizuje przychodzące wiadomości wysyłane przez jądro. Napiszmy prosty skrypt testowy, aby uruchomić maszynę wirtualną i wydrukować otrzymane wiadomości:

Skrypt generuje następujące dane wyjściowe:

Klient (Python)

https://chacker.pl/

Klient to aplikacja działająca poza maszyną wirtualną, która wchodzi w interakcję z jądrem, wysyłając żądania zawierające kod binarny wygenerowany w określonych celach. Kod ten jest wykonywany przez jądro, a wyniki są odsyłane do aplikacji w celu dalszego przetwarzania. Klienci mogą różnić się rodzajem generowanego kodu, ale wszyscy muszą przestrzegać tego samego protokołu komunikacyjnego. W tej sekcji zaimplementujemy wszystkie te funkcje za pomocą języka Python.

Obsługa żądań

https://chacker.pl/

Wykonywanie dowolnego kodu obejmuje dwie operacje: zapisywanie kodu binarnego w pamięci gościa i przekierowywanie przepływu wykonywania do tego obszaru pamięci. Zdefiniujemy żądanie jako listę operacji dowolnego z tych dwóch rodzajów. Jądro przetworzy żądanie, iterując tę ​​listę i stosując każdą operację sekwencyjnie.

Każda operacja pobiera dwa lub więcej elementów z listy: typ operacji i parametry operacji

Po zainicjowaniu komunikacji zaczynamy odbierać żądania zawierające listę operacji. Ważne jest, aby zauważyć, że nie rozróżniamy kompilacji debugowania i wersji wydania, więc wyrażenia wewnątrz assert (1) są zawsze wykonywane. Zaczynamy przetwarzać elementy listy, weryfikując, czy zaczynają się od UInt32 zawierającego prawidłowy OpType (2). Jeśli jest to operacja OpWrite, wywoływana jest funkcja op_write (3). Ta funkcja zużywa dwa kolejne elementy z listy: adres pamięci UInt64 i tablicę. Następnie kopiuje zawartość tablicy do adresu pamięci. W przypadku operacji OpExec wywoływana jest funkcja op_exec (4). Ta funkcja zużywa element UInt64 z listy. Wartość z tego elementu jest rzutowana na wskaźnik funkcji i zostaje wywołana. Gdy którakolwiek z funkcji zwróci wartość, pętla zużywa następną operację i tak dalej, aż do końca listy.

Implementacja komunikatu rozruchowego

https://chacker.pl/

Jednym z naszych wymagań protokołu jest to, że komunikacja musi być inicjowana przez jądro, wysyłając informacje o swoim środowisku wykonawczym do klienta. Spełnimy ten wymóg, implementując „komunikat rozruchowy”, który dostarcza następujących informacji:

  • Przestrzeń adresów fizycznych (z punktu widzenia jądra uruchomionego

na maszynie wirtualnej).

  • Adresy jądra (symbole). Służy to dwóm celom. Pierwszym jest poinformowanie klienta, gdzie załadowane jest jądro, aby nie nadpisało go przypadkowo podczas wstrzykiwania kodu. Drugim jest dostarczenie adresów znanych funkcji jądra, które mogą być używane przez kod zewnętrzny.

Informacje z komunikatu rozruchowego zostaną zakodowane na liście asocjacyjnej, gdzie pierwszy element każdej pary jest ciągiem identyfikacyjnym zawartości drugiego elementu. Układ drugiego elementu jest specyficzny dla rodzaju dostarczanych informacji (zwykle kodowanych jako podlista). W tym przypadku użyjemy ciągów „symbols” i „mmap” do oznaczania informacji. Konstruowanie „symboli” jest proste; po prostu tworzymy z nich listę asocjacyjną nazw symboli i ich adresów:

W tym przypadku podajemy adresy nagłówka ELF jądra, końca segmentu BSS oraz funkcji put_va i send_msg. Aby skonstruować „mmap”, zmodyfikujemy kod bootstrap, aby wykorzystać obszar multiboot info (MBI) dostarczany przez GRUB:

Definicja kmain musi zostać dostosowana, aby przyjąć MBI jako argument. Dodamy również kod, który konstruuje i wysyła komunikat rozruchowy:

Ostatnim elementem jest funkcja put_mbi służąca do analizowania MBI i konstruowania „mmap”:

UWAGA Aby przejść przez MBI, potrzebujemy definicji z pliku „multiboot2.h” dostarczonego przez GRUB.

Funkcja put_mbi (1) wyszukuje MULTIBOOT_TAG_TYPE_MMAP (2) w MBI, aby znaleźć strukturę multiboot_tag_mmap. Ta struktura zawiera informacje o przestrzeni adresowej w serii wpisów iterowanych przez put_mmap (3) w celu wygenerowania „mmap”. Każdy z tych wpisów reprezentuje zakres pamięci i zawiera jego adres bazowy, długość i typ pamięci. Jak dotąd to wszystko, czego potrzebujemy do komunikatu rozruchowego.

Protokół komunikacyjny

https://chacker.pl/

Teraz możemy zacząć pracę nad protokołem do komunikacji z narzędziami zewnętrznymi (od teraz będziemy je nazywać klientami). Zacznijmy od krótkiego omówienia wymagań protokołu:

  • Jądro musi przetwarzać żądania od klienta, aby przechowywać i wykonywać dowolny kod.
  • Jądro musi wysyłać odpowiedź z wynikami wykonania dowolnego kodu.
  • Komunikacja jest inicjowana przez jądro. Musi ono poinformować klienta, że ​​jest gotowe do przetwarzania żądań i powinno dostarczyć informacji

o swoim środowisku wykonawczym.

  • Jądro może wysyłać wiadomości poza pasmem (OOB) w celu

debugowania.

  • ​​Integralność wiadomości musi zostać zweryfikowana.

Na podstawie tych wymagań będziemy oznaczać następujące typy wiadomości: żądanie, odpowiedź, wiadomości rozruchowe i wiadomości OOB. Wiadomości będą składać się z nagłówka o stałym rozmiarze i treści o zmiennym rozmiarze. Nagłówek będzie wskazywał typ wiadomości i długość treści oraz będzie zawierał sumy kontrolne integralności dla treści wiadomości i samego nagłówka:

Wyliczenie MT (1) reprezentuje różne typy wiadomości, które można zakodować w polu typu nagłówka wiadomości. Pozostała część nagłówka zawiera długość (2), w bajtach, treści (mniej niż MAX_MSGSZ), sumę kontrolną zawartości treści (3) i sumę kontrolną treści nagłówka, z wyłączeniem zawartości samego pola hdr_csum (4). Format treści wiadomości musi być wystarczająco elastyczny, aby zakodować najczęstsze struktury danych, a jednocześnie procesy serializacji i deserializacji muszą być proste. Musi być łatwo generować dane z dowolnego kodu, a klient powinien również łatwo je konsumować i pracować z tymi danymi. Aby spełnić te potrzeby, zdefiniujemy kodowanie obejmujące wartości następujących typów: liczby całkowite, tablice, ciągi znaków i listy.

Każda wartość zaczyna się od 32-bitowego prefiksu zdefiniowanego w wyliczeniu TP (1). Prymitywy mogą być dowolnymi typami całkowitymi zdefiniowanymi w unii Primitive_t (2). Są one kodowane jako prefiks TP (mniejszy lub równy PrimitiveMax (3)), po którym następuje wartość w jego natywnym kodowaniu. Typy złożone mogą zawierać promienie, ciągi znaków i listy. Tablice zaczynają się od prefiksu Array (4), po którym następuje nagłówek Array_t:

Ten nagłówek wskazuje liczbę elementów i ich podtyp, który jest ograniczony do typów pierwotnych. Po nagłówku znajdują się wartości elementów w ich natywnym kodowaniu. Ciągi składają się z prefiksu CString (5), po którym następuje zmienna liczba bajtów innych niż null i są ograniczone sufiksem bajtu null. Listy zaczynają się od prefiksu List (6), po którym następuje zmienna liczba węzłów. Każdy węzeł jest parą, w której jego pierwszy element może być dowolną wartością z prefiksem TP (w tym inne listy), a jego drugi element jest następnym węzłem listy. Węzeł z prefiksem Nil (7) oznacza koniec listy. Mając już definicje wiadomości, możemy rozpocząć pracę nad jej wdrożeniem:

Najpierw potrzebujemy bufora (send_buf (1)) do skonstruowania treści wiadomości przed jej wysłaniem, a także bufora dla wiadomości przychodzących (2). Definiujemy również dodatkowy bufor (3) wyłącznie dla wiadomości OOB, więc jeśli wyślemy wiadomości debugowania w trakcie konstruowania wiadomości, nie usuniemy zawartości send_buf. Definiujemy kilka makr do kopiowania danych do i z buforów: GET kopiuje wartość z bufora odbiorczego, podczas gdy PUT kopiuje wartość do bufora docelowego wskazanego przez jego pierwszy parametr (przekażemy albo send_buf, albo oob_buf). Makro PUT wykonuje kilka dodatkowych kroków obejmujących podwójne użycie typeof w celu zdefiniowania zmiennej tmp (4). Należy zauważyć, że typeof akceptuje albo zmienną, albo wyrażenie na zewnętrznym typeof. Używamy tego drugiego: rzutowania zmiennej na jej własny typ. Powodem jest to, że wynikiem wyrażenia jest rvalue, więc jeśli oryginalna zmienna ma kwalifikator const, zostanie ona usunięta. W ten sposób przypisanie w (5) będzie sprawdzać typ, gdy przekażemy zmienną const do PUT. Teraz możemy zacząć pisać funkcje „put” i „get” dla każdej z wartości TP, które zdefiniowaliśmy:

UWAGA Funkcje „get” zostały pominięte w liście kodu ze względu na zwięzłość.

Pierwszy argument dla wszystkich funkcji „put” wskazuje, czy dane powinny zostać zapisane do send_buf czy do oob_buf. Dane są kodowane zgodnie ze schematem opisanym podczas definiowania różnych wartości TP. Dla wygody zaimplementowaliśmy również funkcję wariadyczną (1), która łączy wiele wartości różnych typów w jednym wywołaniu. Teraz potrzebujemy funkcji do wysyłania i odbierania wiadomości:

Funkcja send_msg (1) przyjmuje typ wiadomości jako argument, który jest najpierw używany do wybrania właściwego bufora do odczytania treści wiadomości. Oblicza sumy kontrolne treści i nagłówka (CRC32) i wysyła wiadomość przez port szeregowy. Na koniec resetuje przesunięcie bufora, aby można było utworzyć następną wiadomość. Aby pobrać wiadomość, recv_msg (2) odczytuje nagłówek wiadomości z portu szeregowego. Przed przejściem dalej wykonuje kontrole poprawności pól typu, długości i sumy kontrolnej nagłówka. Po przejściu tych kontroli odczytuje treść wiadomości i weryfikuje jej sumę kontrolną. Zakładamy, że klient nigdy nie wyśle ​​nieprawidłowo sformatowanych wiadomości, więc jeśli kontrola poprawności się nie powiedzie, jest to konsekwencją uszkodzenia stanu jądra, co jest stanem nieodwracalnym i musimy ponownie uruchomić komputer.

UWAGA „Listy kodów dla funkcji crc32 i assert zostały pominięte ze względu na zwięzłość. Funkcja crc32 implementuje CRC32-C (wielomian 0x11EDC6F41), podczas gdy implementacja assert wysyła komunikat OOB i wykonuje twarde resety poprzez potrójne generowanie błędów.

Przetestujmy implementację protokołu, konstruując i wysyłając komunikat OOB:

Wyliczenie OOBType (1) definiuje dwa typy komunikatów OOB:

OOBPrint i OOBAssert. Treść komunikatu OOB to lista, gdzie pierwszym elementem jest OOBType. Makro OOB_PRINT (2) buduje tę listę na podstawie przekazanych mu argumentów, poprzedzając wartość OOBPrint i dodając prefiks CString do pierwszego argumentu odpowiadającego ciągowi formatującemu. Na koniec makro wysyła komunikat OOB przez port szeregowy. Należy zauważyć, że to makro nie może wywnioskować typów z ciągu formatującego, więc musimy przekazać wartości TP jako argumenty. Teraz możemy zastąpić nasz komunikat „Hello world” wywołaniem OOB_PRINT:

Przyjrzyjmy się danym wiadomości, przekierowując dane wyjściowe do hexdump (nie zapomnij uruchomić make, aby zbudować jądro w każdym laboratorium):

Oczywistym faktem, jaki możemy zobaczyć na tym wyjściu, jest to, że OOB_PRINT nie wykonuje formatowania ciągu. Nasze jądro nie wykona żadnej pracy, którą mógłby wykonać klient!

Rozruch i komunikacja

https://chacker.pl/

Aby uruchomić jądro, użyjemy GRUB, aby uniknąć kłopotów z pisaniem własnego bootloadera. Zazwyczaj hypervisory obsługują rozruch BIOS-u i/lub UEFI. Na szczęście narzędzie grub-mkrescue4 pozwala nam wygenerować nośnik ISO do rozruchu z obu. Nasz obraz jądra będzie plikiem ELF z nagłówkiem Multiboot25 umieszczonym na początku sekcji kodu. Gdy GRUB uruchamia obraz, środowisko, w którym nas pozostawia, to 32-bitowy tryb chroniony; chcemy używać wszystkich dostępnych funkcji procesora, więc musimy przełączyć się na tryb długi. Zacznijmy naszą implementację od kodu bootstrap, który emituje nagłówek Multiboot2, a następnie przełącza się na tryb długi:

Sekcja .text zaczyna się od dyrektyw, aby wyemitować minimalny nagłówek Multiboot2 (1). W punkcie wejścia (2) ustawiany jest stos 8 KB i tworzone jest mapowanie 1:1 pierwszych 4 GB pamięci (3). Kolejne kroki obejmują ustawienie flagi Long Mode Enable w rejestrze EFER (4), załadowanie 64-bitowego GDT (5), włączenie stronicowania (6) i przejście do trybu długiego (7). Kod kończy się wywołaniem niezaimplementowanej jeszcze funkcji kmain. Do komunikacji między naszym jądrem a światem zewnętrznym możemy użyć prostego i powszechnie dostępnego urządzenia: portu szeregowego. Większość hiperwizorów implementuje emulację portu szeregowego i zwykle pozwalają one na przekazywanie jej do mechanizmów IPC w hoście, takich jak gniazda lub potoki. Weźmy się do roboty i dodajmy podstawową obsługę portu szeregowego do naszego jądra:

Najpierw piszemy parę wrapperów dla OUTB (1) i INB (2), potrzebnych reszcie kodu. Funkcji setup_serial (3) można użyć do zainicjowania portu szeregowego przy typowej prędkości transmisji 115200 bodów. Implementujemy write_serial (4), aby przesłać strumień danych z pamięci, i implementujemy read_serial (5), aby go odebrać. Nasza implementacja ma pewne braki, takie jak spinwait na gotowość portu szeregowego, ale zachowajmy prostotę. Teraz możemy zaimplementować kmain, aby przetestować nasze jądro:

Po zbudowaniu jądra przeprowadzimy testy w QEMU/KVM. Jeśli wszystko pójdzie dobrze, powinniśmy zobaczyć komunikat „Hello world!”:

Unikernel

https://chacker.pl/

Jak wspomniano wcześniej, musimy być w stanie wykonać dowolny kod Ring-0 z gościnnej maszyny wirtualnej. Jednym ze sposobów, aby to zrobić, jest zaimplementowanie sterownika jądra w celu wykonania dowolnego kodu w uniwersalnym systemie operacyjnym wdrożonym na maszynie wirtualnej. To podejście ma jednak kilka problemów. Po pierwsze, pełny system operacyjny jest powolny i rozdęty. Po drugie, niedeterminizm jest wprowadzany do naszego środowiska testowego przez wiele zadań, które są wykonywane jednocześnie. Aby uniknąć tych problemów, zaimplementujemy nasz własny unikernel3 z następującymi wymaganiami:

  • Prostota Powinien mieć mały rozmiar i być wydajny.
  • Szybkie (ponowne) uruchamianie Często dochodzi do stanu nieodwracalnego, więc musimy ponownie uruchomić.
  • Odporność Jądro musi próbować odzyskać się z nieprawidłowego stanu i działać tak długo, jak to możliwe. Jeśli nie jest to możliwe, musimy ponownie uruchomić.
  • Determinizm Osiągnięcie całkowitego determinizmu nie jest możliwe, ale musimy się do niego jak najbardziej zbliżyć. Jest to ważne dla celów reprodukcji błędów i minimalizacji przypadków rozmycia.
  • ​​Przenośność Powinien działać na większości implementacji hypervisora ​​bez większych modyfikacji.

Nasz unikernel będzie komunikował się z narzędziami zewnętrznymi, umożliwiając im wstrzykiwanie i wykonywanie dowolnego kodu w maszynie wirtualnej w Ring-0. Musimy być w stanie zbierać wyniki wykonania i odsyłać je do narzędzi. W tych sekcjach omawiamy proces rozwoju tego kernela.