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.

Powierzchnia ataku hiperwizora

https://chacker.pl/

Wcześniej dowiedziałeś się, jak instrukcje można klasyfikować w różnych grupach: nieszkodliwe, wrażliwe itd. Aby zbadać większość funkcjonalności narażonych na hiperwizor, powinniśmy skupić się na instrukcjach, które zostaną uwięzione w VMM; jak już wiemy, większość z nich to instrukcje uprzywilejowane. Oznacza to, że musimy być w stanie wykonać dowolny kod gościa w Ring-0. To jest początkowy poziom dostępu, który przyjmiemy na potrzeby naszych badań. Stos wirtualizacji składa się z wielu komponentów, z których każdy działa na różnych poziomach uprawnień. Wpływ luki w zabezpieczeniach będzie zależał od dotkniętego komponentu. W najlepszym przypadku możemy naruszyć VMM (w trybie root VMX) bezpośrednio z nieuprzywilejowanego gościa. Alternatywnie możemy dążyć do wykonania w trybie jądra w hoście lub uprzywilejowanym gościu (partycja root/dom0), a w najgorszym przypadku nadal mamy stos w trybie użytkownika. Komponenty oddziałują na siebie, więc naruszenie mniej uprzywilejowanego komponentu może poszerzyć powierzchnię ataku, która może być dalej eksploatowana w łańcuchu błędów w bardziej uprzywilejowanych komponentach. Poniższe ilustracje to przegląd powierzchni ataku ujawnianej przez różne komponenty hiperwizora typu 2 i hiperwizora typu 1.

Ogólnie rzecz biorąc (w przypadku nowoczesnych, wspomaganych sprzętowo hiperwizorów) naszym punktem wyjścia do eksploracji powierzchni ataku będzie przyjrzenie się warunkom powodującym różne kody przyczyn wyjścia. Po wyjściu z maszyny wirtualnej procesor wznawia wykonywanie w trybie głównym VMX ze wskaźnika instrukcji przechowywanego w obszarze stanu hosta struktury sterowania maszyną wirtualną (VMCS). Wskazuje to na kod hiperwizora, który (po zapisaniu pewnego stanu) sprawdza pole przyczyny wyjścia VMCS i decyduje, jakie działania podjąć. Listę definicji możliwych przyczyn wyjścia można znaleźć w źródłach jądra Linux: https://github.com/torvalds/linux/blob/master/tools/arch/x86/include/uapi/as m/vmx.h.  Niektóre z tych warunków wyjścia są poza naszym zasięgiem (spowodowane zdarzeniami zewnętrznymi lub niedostępne w zależności od konkretnych funkcji procesora lub konfiguracji VMCS), ale wiele z nich można wywołać, wykonując określone instrukcje w gościu.

UWAGA: Pod koniec napiszemy kilka fuzzerów, które badają EXIT_REASON_IO_INSTRUCTION, EXIT_REASON_MSR_READ i EXIT_REASON_MSR_WRITE.

Oprócz warunków wyjścia z maszyny wirtualnej, powierzchnia ataku może być również ujawniona przez mechanizmy komunikacji oparte na pamięci współdzielonej — na przykład bezpośredni dostęp do pamięci (DMA) w emulowanych urządzeniach sprzętowych lub bufory używane przez VMBus1 lub VIRTIO.2 Niestety, nie zajmiemy się tym tematem w tym rozdziale. Wreszcie, hiperwizory nie polegające na wirtualizacji wspomaganej sprzętowo ujawniają większą powierzchnię ataku, ale nie są to obecnie powszechne cele.

Tworzenie struktury badawczej

https://chacker.pl/

Zaczynamy krótką dyskusją na temat powierzchni ataku hypervisora, a następnie obejmuje opracowanie struktury, której będziemy używać do celów badawczych dotyczących podatności. Aby śledzić laboratoria w tym rozdziale, będziesz potrzebować zaawansowanej wiedzy na temat języków C i Python.

Plik Dockerfile zapewnia środowisko programistyczne niezbędne do zbudowania i uruchomienia kodu:

Hiperwizor, z którym będziemy pracować w tym rozdziale to KVM (domyślny Linux), który musi zostać zainstalowany na hoście. Aby użyć go z kontenera, musisz przekierować urządzenie /dev/kvm, tak jak tutaj:

Po wejściu do kontenera Docker cały kod można znaleźć w katalogu /labs.

Podsumowanie

https://chacker.pl/

Przyjrzeliśmy się ogólnym koncepcjom wirtualizacji — przechodząc od modeli teoretycznych do konkretnych implementacji w architekturze x86. Omówiliśmy niektóre z początkowych ograniczeń architektury x86 i techniki stosowane w celu ich przezwyciężenia. Na koniec omówiliśmy zarówno ewolucję oprogramowania hypervisora, jak i samą architekturę x86, wraz z wprowadzeniem rozszerzeń wirtualizacji sprzętowej. W następnym rozdziale wykorzystamy tę wiedzę do mapowania powierzchni ataku dla szerokiego zakresu implementacji hypervisora: od tych obsługujących stare modele x86, po implementacje wykorzystujące obecne możliwości sprzętowe i te obejmujące obsługę parawirtualizacji.

EPT

https://chacker.pl/

Widzieliśmy, jak można wdrożyć stronicowanie w tle na istniejącym mechanizmie stronicowania, aby wirtualizować MMU. Stronicowanie w tle jest złożone, a aktualizacje tablic stron są kosztowne; dlatego, aby poprawić tę sytuację, opracowano nową technologię wspomaganą sprzętowo, znaną jako Second Level Address Translation (SLAT). Intel implementuje SLAT z Extended Page Tables (EPT29). W skrócie, EPT działa jak tablice stron; różnica polega na tym, że podczas gdy tablice stron tłumaczą VA na PA, EPT tłumaczy GPA na SPA. VMM działający w trybie głównym VMX musi skonfigurować i utrzymywać zestaw wielopoziomowych tablic stron EPT, które są używane do tłumaczenia GPA na SPA. Wskaźnik EPT najwyższego poziomu (EPTP30) jest przechowywany w jednym z pól sterujących VMCS. Z perspektywy gościa nadal używamy tablic stron jak zwykle, aby tłumaczyć VA na PA; w rzeczywistości są to GVA i GPA. Aby uzyskać dostęp do strony, procesor najpierw przechodzi przez tabele stron gościa, aby uzyskać GPA z jego GVA; następnie przechodzi przez tabele EPT (które są niewidoczne dla gościa), aby uzyskać SPA z GPA. Jeśli przypomnimy sobie sprzętowy wirtualizator Goldberga, moglibyśmy zobaczyć tabele stron jako mapę ϕ, a EPT jako mapę f. Tłumaczenie GVA na SPA jest wykonywane przez złożoną mapę „f ° ϕ”. Należy pamiętać, że gdy procesor przechodzi przez wielopoziomowe tabele stron gościa, każdy poziom (w trybie długim: PML4, PDPT, PD, PT) wskazuje na następny przez swój GPA, co oznacza, że ​​każdy poziom tabeli stron musi zostać przetłumaczony przez mechanizm EPT. Jest to znane jako „dwuwymiarowe przejście strony”, a w najgorszym przypadku (gdy każdy krok tłumaczenia powoduje brak pamięci podręcznej) do przetłumaczenia GVA potrzeba 24 obciążeń pamięci. Nawet jeśli translacja adresów może być droższa niż w przypadku stronicowania w tle, największą zaletą EPT jest to, że aktualizacje tablicy stron są bezpośrednie, co zmniejsza liczbę pułapek i upraszcza implementację VMM.

Podobnie jak w przypadku tabel stron, EPT umożliwia mapowanie adresu na nieistniejącą stronę fizyczną. Próba dostępu w tym przypadku spowoduje wyjście maszyny wirtualnej. Podstawowym powodem wyjścia dla tego rodzaju pułapki jest naruszenie EPT.32 Ponadto pola kwalifikacji wyjścia i adresu fizycznego gościa są ustawione. W niektórych przypadkach EPT może nie być całkowicie niewidoczne dla gości; w celu poprawy wydajności niektóre funkcje związane z EPT mogą być udostępnione maszynom wirtualnym. Jedną z tych funkcji jest funkcja przełączania EPT (wywoływana za pomocą instrukcji VMFUNC), która umożliwia gościowi jawne przełączanie swojego EPTP z listy wartości ustalonych przez VMM. Inną ważną funkcją są wyjątki wirtualizacji (#VE34). Jak sama nazwa wskazuje, funkcja ta może być używana do dostarczania wyjątków związanych z wirtualizacją do gościa za pośrednictwem wektora IDT 20. Ta funkcja może być używana razem z naruszeniami konwertowalnego EPT, więc gdy dochodzi do naruszenia EPT, nie powoduje to wyjścia maszyny wirtualnej. Zamiast tego gość otrzymuje komunikat #VE, który go obsługuje, dzięki czemu unika się pułapek w VMM.