Po przebrnięciu przez podstawowe informacje na temat optymalizacji procesorów oraz sposobu działania bocznych kanałów pora na omówienie kontrowersyjnego Meltdowna. Odkrycie ataku kosztowało Intela miliony.
Autorami artykułu są Michał Leszczyński i Michał Purzyński, konsultacja merytoryczna: Jarosław Jedynak.
Poprzednie części cyklu:
- odcinek 1: optymalizacje
- odcinek 2: side channel
Czy wiecie skąd w nazwie adresu wirtualnego wzięło się słowo „wirtualny”? Po skompilowaniu takiego programu w trybie 64-bit:
#include <iostream> int main() { int *foo = new int; std::cout << &foo << std::endl; }
przykładowym wynikiem może być np. 0x79983ff00070
. To bardzo wysoka wartość. Przy założeniu, że adresy są nadawane od zera, nasz system powinien mieć co najmniej 124512 GB pamięci. Niezupełnie…
Tablica stron
Tak naprawdę „adres” w komputerach już od co najmniej 30 lat nie przekłada się bezpośrednio na to, gdzie w fizycznej kostce RAM znajdują się dane. Wskaźniki przechowują tak zwany „adres wirtualny”, który dopiero przy pomocy systemu operacyjnego jest przekształcany przez procesor w „adres fizyczny”. Sposób tłumaczenia adresów wirtualnych na adresy fizyczne opisują tzw. tablice stron (ang. page tables), którymi zarządza jądro systemu. Oprócz tego każde mapowanie zawiera informacje o poziomie dostępu w zakresie odczytu, zapisu i wykonywania. Każdy proces może zawierać swoje własne, indywidualne tablice.
Odczyt tablic stron wspomaga specjalna pamięć podręczna – Translation Lookaside Buffer (TLB). TLB istnieje jako fizyczny element procesora i jest zoptymalizowany pod kątem szybkiego wyszukiwania odpowiedniego wpisu na podstawie adresu wirtualnego. Nowoczesne komputery posiadają kilka różnych TLB o różnej wielkości, zlokalizowanych pomiędzy poszczególnymi warstwami hierarchii pamięci. TLB spełnia funkcję cache’a – gdyby go nie było, przy każdym odwołaniu do pamięci procesor musiałby ustalać, jaki jest adres fizyczny dla danego adresu wirtualnego. To złożony proces zajmujący nawet 100 cykli procesora. Trafność współczesnych TLB jest w granicach 99%.
Ze względów wydajnościowych w tablicach stron wszystkich procesów umieszcza się wpisy mapujące adresy jądra systemu, a uprawnienia ustawiane są w taki sposób, aby blokować do nich dostęp spoza kernel mode. Dzięki temu, że procesor może tam trzymać mapowania zarówno programu, jak i jądra, oraz że są rozróżniane poziomy uprawnień, podczas wywołań systemowych (ang. system call), nie jest konieczne czyszczenie TLB. To z kolei powoduje, że wywołania systemowe są szybsze, ponieważ nie ma konieczności sprawdzania wpisów w tablicy stron za każdym razem. Jest to niezwykle istotne, ponieważ standardowe procesy posiadają bardzo ograniczony dostęp do sprzętu, więc podstawowe czynności (np. zapis do pliku, wysyłanie pakietów TCP) muszą być realizowane za pośrednictwem wywołań systemowych, które z kolei obsługiwane są przez jądro systemu.
Kolejkowanie instrukcji
Nowoczesne procesory wykonują instrukcje w kolejności dostępności danych, a nie w kolejności ich zapisania w programie. Procesor kolejkuje instrukcje w taki sposób, aby jak najlepiej wykorzystać dostępne zasoby, ale musi to robić tak, aby nie zdestabilizować wykonania programu. Wyniki wykonywania instrukcji są aplikowane zgodnie z ich kolejnością i dopiero wtedy obsługiwane są wyjątki, o ile jakieś wystąpiły. Problemem Intela jest asynchroniczne sprawdzanie uprawnień podczas uzyskiwania dostępu do pamięci (co dawało też pewną przewagę wydajnościową, ponieważ inni producenci robili to synchronicznie). Może to spowodować, że efektywnie wyjątek zadziała „z opóźnieniem”, ponieważ kilka następnych instrukcji jest już zakolejkowanych do przetworzenia albo nawet częściowo wykonanych.
Proof of concept
Ten fakt zostaje wykorzystany do wykonania ataku Meltdown, który używa okna czasowego między niepoprawnym dostępem a wystąpieniem wyjątku, aby “wykraść” informacje z jądra systemu.
Atak rozpoczyna się od inicjalizacji bocznego kanału poprzez utworzenie dużej tablicy o rozmiarze 4096*256 bajtów, zwanej dalej „probe array”, oraz usunięcie jej z pamięci podręcznej procesora. To postępowanie zostało dokładnie wyjaśnione w poprzedniej części artykułu. Następnie przechodzimy do właściwego ataku (tzn. dokonania zapisu do bocznego kanału), który wygląda tak:
; rcx = kernel address ; rbx = probe array mov rax, 0 ; (1) retry: mov al, byte [rcx] ; (2) shl rax, 0xc ; (3) jz retry ; (4) mov rbx, qword [rbx + rax] ; (5)
Proof of concept exploita Meltdown z oficjalnej publikacji na ten temat
Zgodnie z kolejnością instrukcji:
- Ustawiamy rejestr rax na zero.
- Dokonujemy nieuprawnionego odczytu z przestrzeni jądra systemu do dolnego kawałka rejestru rax.
- Mnożymy odczytaną wartość przez 4096 (co realizujemy poprzez przesunięcie bitowe w lewo o 12 bitów).
- Wracamy do kroku 2, jeżeli wartość w rejestrze rax okazała się zerem (instrukcja jz etykieta oznacza tutaj: „skocz do etykieta, jeżeli wynikiem poprzedniej operacji było zero”). Może się tak zdarzyć, jeżeli procedura obsługi wyjątków zdążyła wyzerować rejestr.
- Odczytujemy zawartość probe_array[rax] do rejestru rbx, a to spowoduje załadowanie odpowiedniej strony (albo chociaż jej kawałka) do pamięci podręcznej.
Oczywiście niedługo później wystąpi wyjątek i instrukcje 2-5 zostaną wycofane. Na potrzeby wykonania instrukcji numer 5 procesor dokonał jednak odpowiedniego odczytu z probe array. Odczytany kawałek tablicy został umieszczony w pamięci podręcznej. W ten sposób dokonaliśmy zapisu informacji do bocznego kanału.
Dalej konieczne jest jedynie przesondowanie probe array i mierzenie czasu dostępu do każdej ze stron, aby ustalić wartość bajtu odczytanego z niedozwolonej lokacji. Proces odczytu wartości z bocznego kanału również został opisany w poprzedniej części artykułu.
Cały schemat ataku można także zapisać jako pseudo-kod w C:
char probe[256*4096]; char *kernel_addr = /* adres z kernela */; // ... przygotowanie, cache flush ... int data; do { data = (*kernel_addr) * 4096; } while (!data); // poniższa linia jest osiągana, ponieważ wyjątek uruchamia się "z opóźnieniem" // zapis danych do bocznego kanału x = probe[data]; // ... złapanie wyjątku ... // ... odczyt: sondujemy tablicę i szukamy już załadowanej strony ...
Pseudo-kod w C prezentujący sposób działania Meltdown, analogiczny do poprzedniego kodu
FAQ do powyższego przykładu
Q: Jeżeli odczytaną wartością jest zero, program może wpaść w nieskończoną pętlę i wtedy nie dokona żadnego zapisu do bocznego kanału. Co wtedy?
A: W takiej sytuacji atak jest powtarzany kilka razy i jeżeli ani razu nie uda się odczytać żadnej wartości, zakładamy, że tą wartością było zero.
Q: Jak dokończyć atak, skoro w trakcie przewidujemy wystąpienie wyjątku?
A: Należy zainstalować własny exception handler, który przechwyci wyjątek błędu segmentacji i pozwoli nam kontynuować pracę. Analogicznie działa konstrukcja try … catch w C++. Ponadto, jeżeli procesor obsługuje TSX (rozszerzenia wprowadzające pamięć transakcyjną), istnieje możliwość przeprowadzenia ataku wewnątrz transakcji. Wystąpienie wyjątku spowoduje cofnięcie transakcji i dzięki temu błąd segmentacji nie zostanie przekazany do programu.
Q: Jak poznać adres jądra, skoro stosuje się zabezpieczenie Kernel Adress Space Layout Randomization?
A: Istnieje kilka metod umożliwiających ominięcie randomizacji adresu kernela na niezałatanym systemie. Nie będziemy ich jednak szczegółowo omawiać, ponieważ jest to poza zasięgiem tego artykułu.
Mamy nadzieję, że teraz wszystko jest całkowicie jasne, a teraz pozostało jedynie omówienie sposobów, w jakie producenci sprzętu i oprogramowania poradzili sobie z atakiem Meltdown.
Patch: hard split
Autorzy publikacji o Meltdown sugerują wprowadzenie funkcji „hard split”. Wirtualna przestrzeń adresowa miałaby zostać podzielona na dwie połówki zgodnie z najwyższym bitem adresu, a w górnej połówce miałoby się znaleźć jądro systemu. Taka sytuacja znacznie ułatwia sprawdzanie uprawnień w sprzęcie, ponieważ wymagany poziom uprawnień można wydedukować już na podstawie samego adresu.
Sugerowane jest również wprowadzenie nowego bitu w rejestrze kontrolnym procesora, który określałby, czy obecnie działające jądro wspiera funkcję „hard split”. Dzięki temu zostanie utrzymana kompatybilność wsteczna ze starym oprogramowaniem. Tego typu poprawka nie powinna nieść ze sobą istotnych problemów wydajnościowych, ale nie da się jej wykonać bez wymiany istniejących procesorów. Ze względu na wysoką inercję w świecie producentów CPU, prognozuje się, że na rozwiązanie sprzętowe trzeba będzie czekać co najmniej rok.
Patch: KPTI
Deweloperzy jądra Linuxa podjęli szeroko zakrojone działania mające na celu wprowadzenie KPTI, czyli Kernel Page Table Isolation. Poprawka powoduje, że dla każdego procesu istnieją dwa warianty page table. Jeden z nich przeznaczony jest dla trybu użytkownika i zawiera tylko te mapowania jądra, które są niezbędne do prawidłowej pracy. Przykładowo: informacje niezbędne do wchodzenia i wychodzenia z wywołań systemowych, wektory przerwań i procedury obsługi wyjątków. Drugi wariant, przeznaczony dla kernel mode, zawiera pełne mapowania jądra oraz mapowania przestrzeni użytkownika zabezpieczone przez rozszerzenia SMAP i SMEP (zabezpieczenie jądra przed odczytem pamięci [SMAP] lub wykonaniem kodu [SMEP] z przestrzeni użytkownika). SMAP i SMEP nie zostały wprowadzone jako część mitygacji przed atakiem Meltdown, ale nieco wcześniej, żeby utrudnić pisanie exploitów na jądro systemu.
Ponieważ takie rozwiązanie wymaga podmiany tablicy stron podczas zmiany trybu pracy procesora, automatycznie czyszczony jest TLB, a to może istotnie wpływać na wydajność. Dokładne pomiary wydajności KPTI przeprowadził Brendan Gregg na swoim blogu:
Łatki KPTI łagodzące zagrożenie atakiem Meltdown mogą spowodować powstanie olbrzymich narzutów o jakiejkolwiek wartości pomiędzy 1% aż do 800%. Twój konkretny narzut będzie zależał od częstotliwości wywołań systemowych oraz błędów braku strony (ang. page fault), ze względu na dodatkowe zużycie cykli procesora. Narzut zależy też od rozmiaru obszaru roboczego pamięci, ze względu na konieczność czyszczenia TLB podczas wywołań systemowych i przełączania kontekstu.
W praktyce oczekuję, że systemy chmurowe u mojego pracodawcy (Netflix) doświadczą narzutu KPTI rzędu od 0,1% do 6% ze względu na naszą częstotliwość wywołań systemowych. Podejrzewam również, że uda nam się to zredukować do mniej niż 2% po dostrajaniu. (…)
To tylko jedno z czterech potencjalnych źródeł narzutów związanych z Meltdown/Spectre: są jeszcze zmiany w hypervisorach, zmiany mikrokodu Intela oraz zmiany w kompilatorach. Podane liczby nie są ostateczne, bo Linux wciąż jest rozwijany i udoskonalany.
Pomimo istotnych wpływów wydajnościowych takie rozwiązanie znacznie zmniejsza praktyczność ataku Meltdown. Nadal istnieje techniczna możliwość jego przeprowadzenia, ale odchudzone mapowania jądra nie zawierają żadnych interesujących informacji, które można by wykraść. Ponadto rozwiązanie zaprojektowano w taki sposób, aby niemożliwe było przeprowadzenie istniejących ataków na KASLR (losowanie bazowego adresu jądra), więc możliwe jest zyskanie w ten sposób dodatkowej warstwy ochrony.
Tweet Alexa Ionescu z 14 listopada 2017 roku pokazuje, że w Windowsie zastosowano analogiczne rozwiązania (kvashadow). Zaprezentowane przez niego zrzuty ekranu z narzędzia LKD (Local Kernel Debugger) udowadniają, że w nowszych wersjach jądra, w przestrzeni użytkownika zmapowany jest trap handler (zgodnie z oczekiwaniami), ale nie jest zmapowany kod funkcji NtCreateFile. Apple również zaimplementowało identyczną funkcję w OS X.
Co ciekawe, sam pomysł oddzielenia przestrzeni adresowej jądra od przestrzeni adresowej użytkownika nie jest nowy. Tak właśnie wyglądała mapa pamięci w OS X na procesorach PowerPC. Również Linux z patchami “4/4GB Linux kernel VM split” obsługiwał podobną funkcję, jednak narzut wydajnościowy był zbyt duży, by takie rozwiązanie zostało przyjęte. Historia lubi się powtarzać.
Optymalizacje wydajności
KPTI wymaga czyszczenia dTLB przy każdym przełączeniu się pomiędzy procesami, a także przy każdym przejściu z przestrzeni użytkownika do przestrzeni jądra. Nie mówimy tu tylko o wywołaniach systemowych, ale także o przerwaniach (a tych przy dużym ruchu sieciowym lub częstym zapisie i odczycie dysku może być dużo), obsłudze wyjątków etc.
Sama operacja czyszczenia jest wolna, do tego dochodzi narzut spowodowany brakiem informacji w TLB. Jak deweloperzy poradzili sobie z tym wyzwaniem?
Dawno temu, gdy Intel optymalizował szybkość działania maszyn wirtualnych, wprowadził on PCID. Jest to rozszerzenie TLB, dodatkowe 12 bitów opisujących “identyfikator aktualnej przestrzeni adresowej”. Dzięki temu, przełączając się z jednej przestrzeni adresowej (np. użytkownika – U) do innej przestrzeni adresowej (np. jądra – K), nie trzeba czyścic całego TLB, bo wpisy od U nie będą brane pod uwagę przez K.
Pomysł polegał na “tagowaniu” każdej VM (i Hypervisora), żeby przy przejściu z VM do Hypervisora i z powrotem nie trzeba było czyścić TLB (dwa razy!). Takie “przejścia” mają miejsce np. przy odbiorze pakietu sieciowego.
Sam pomysł pochodzi z mainframe’a IBM i procesorów Alpha.
Mechanizm ten został użyty jako optymalizacja patcha KPTI, ale jedynie na procesorach obsługujących instrukcję INVPCID – czyli od Haswell w górę, która to instrukcja umożliwia selektywne usuwanie informacji z TLB zamiast czyszczenia całości.
Optymalizacje te są obecne w systemach Linux i Windows.
Podsumowanie
Trzecia część cyklu “hakowania procesorów” poświęcona była podatności Meltdown w zakresie części wykonawczej ataku oraz omówieniu możliwych rozwiązań problemu po stronie sprzętu i oprogramowania. W następnej części przejdziemy do omówienia ataku Spectre, który daje możliwość doprowadzenia do wycieku danych poprzez manipulację spekulacyjnym wykonaniem instrukcji.
Komentarze
Bardziej mnie interesuje żebyście wyciągnęli od producentów smartfonów czy i kiedy połatali (połatają) swoje starsze urządzenia. Lenovo, Sony, LG, i inni.
Wyciągnęliśmy. Nigdy.
+1
Dlaczego to mnie nie dziwi? Producenci nie mają żadnego powodu, żeby dbać o aktualność starszych modeli. W końcu już wzięli kasę od klienta, a regularne aktualizacje starszych modeli to raz, koszty i dwa, przeszkoda w sprzedaży nowych, „lepszych” modeli. Stąd to podejście typu „Chcesz mieć aktualne oprogramowanie i załatane luki? Kupuj co roku nowy telefon.”
Przecież to działa nawet prościej. „Chcesz mieć nową baterię to kup nowy telefon”. A co dopiero mówić o bezpieczeństwie i łataniu luk. Dlatego ja mam stary telefon (10 lat!), a nie smartfon. Robię nim ładne zdjęcia jak muszę, dzwonię i smsuję, słucham radia i mp3, ale net mi w nim nie jest potrzebny. To klienci napędzają ten rynek, sam się nie napędza. Na co komu co roku nowy telefon? Żeby non stop ślepić w ekranik jak to obecnie ma miejsce?
Ludzkość czekała z zapartym tchem na spadające samoloty i niedziałające banki z powodu Millenium Bug, a tu proszę, kataklizm IT w tak „nieokragłej” dacie.
Podział pamięci wirtualnej na obszary miał już procesor VAX z 1977 roku – zob. https://pl.wikipedia.org/wiki/VAX#Architektura_procesora
Trzeba było 40-u lat aby współcześni producenci procesorów zrozumieli, że tak trzeba.