Postanowiłem przeczytać techniczny whitepaper ataku Meltdown o których kiedyś było głośno. Postanowiłem zrobić to w celu sprawdzenia jak dalece posunęła się (do tyłu) moja umiejętność czytania whitepaperów technicznych i jak dalece umiem tłumaczyć laikom trudne rzeczy. Więc, do dzieła.
Meltdown
Zastanówmy się na początku, jaki mechanizm procesora pozwala w ogóle na wyprowadzenie tego ataku. Mamy przecież obszary pamięci oznaczone jako pamięć procesu i pamięć jądra i proces nie ma prawa do czytania pamięci jądra. Ale jest jeden mechanizm, który pozwala tą zależność pokonać, tzw. wykonanie poza kolejnością (ang. out-of-order execution) która jest istotną cechą pozwalającą procesorom uzyskiwać dużą prędkość wykonywania. Kiedy jest ona wykorzystywana? Ano kiedy instrukcja programu musi czekać na jakieś dane z pamięci RAM (potrafi to trwać i do 250 cykli procesora), w tym czasie procesor spróbuje wykonać dalsze instrukcje, które nie potrzebują tej pamięci. W razie gdyby okazało się, że instrukcje w ogóle nie zostałyby wykonane, wyniki mogą być wyrzucone do kosza. Ale tak się tylko wydaje. Różnice w czasie dostępu późniejszego do tej samej danej rzutują światło na to, czy ta dana została umieszczona już w pamięci podręcznej, czy dalej siedzi w pamięci RAM. Jaki jest z tym główny problem? Pamięć podręczna w ogóle nie jest objęta mechanizmem zabezpieczenia danych.
Z perspektywy bezpieczeństwa jedna obserwacja jest dość istotna: procesory wykonujące zadania poza kolejnością pozwalają nieuprawnionemu procesowi na załadowanie sobie danych z pamięci o podwyższonym poziomie zabezpieczenia (czyli z pamięci jądra, lub z pamięci fizycznej) do tymczasowego rejestru procesora. Ba, procesor może potem na tej danej przeprowadzić obliczenia, np. uzyskać dostęp do jakiejś tabeli bazując na tym wyniku. Po prostu w pewnym momencie procesor zorientuje się, że te wyniki nie mogłyby zostać wykonane (bo np. programowi zabrakło dostępu do pamięci), po prostu wyrzuci wyniki tych obliczeń na śmietnik.
Pojawiła się jednak ciekawa obserwacja. Dostęp do pamięci poza kolejnością nie pozostaje bez wpływu na pamięć podręczną procesora, która może być wykryta przez atak kanałem bocznym. Jako wynik, napastnik może przeczytać całą zawartość pamięci jądra systemu odczytując pamięć do której dostępu jest nieuprawiony poprzez taki strumień wykonywania danych poza kolejnością i przesłać wynik takiego ulotnego stanu do świata zewnętrznego (pamiętamy że procesor z architektonicznego punktu wyrzuca takie obliczenia do kosza, więc teoretycznie wszystko jest bezpieczne).
Meltdown łamie więc wszystkie gwarancje bezpieczeństwa danych. Możemy czytać cudze hasła, a nawet cudze dane przetwarzane przypadkowo na tym samym serwerze w chmurze.
Wykonywanie poza kolejnością
Wykonywanie poza kolejnością (ang. out-of-order execution) to taka technika optymalizacji, która pozwala zmaksymalizować wykorzystanie wszystkich jednostek procesora. Wszak zależy nam na tym, żeby nasz procesor był tak dalece obciążony, jak to możliwe. Możliwa jest delikatna zmiana kolejności instrukcji programu, tak aby jego sens był taki sam, ba, możliwa jest nawet współbieżne wykonywanie instrukcji (które w klasycznym programie komputerowym są po kolei).
Możliwe jest nawet takie wykonywanie poza kolejnością, które dotyczy instrukcji, co do których nie ma nawet pewności czy są potrzebne i czy wykonanie ich będzie zasadne. Zwłaszcza interesujące są sekwencje instrukcji, po których następuje skok w inne miejsce programu – skok, do którego na pewno dojdzie, jednak w rozumieniu procesora sprawa nie jest taka pewna.
W 1967 opracowano algorytm który pozwala na dynamiczne (czyli w takiej kolejności w jakiej uzna to za słuszne procesor, nie programista). Procesor wyposażony jest w taką wyedukowaną zgadywarkę (tzw. branch predictor), które ma za zadanie odpowiedzieć na pytanie, czy dany skok w programie zostanie podjęty w momencie kiedy jeszcze dane do podjęcia tej decyzji nie są dostępne. Instrukcje, która leżą na tej ścieżce i nie mają zależności (czyli nie muszą czekać, aż wcześniejsze się wykonają) mogą zostać wykonane natychmiast i jeśli okaże się że zgadywarka się pomyliła, dane polecą do śmieci.
Żeby przyśpieszyć dostęp do pamięci RAM procesor ma małe ilości szybkiej pamięci cache poziomu L1 (szybsze) i L2 (wolniejsze, ale większe). Jeśli danych nie ma w pamięci, musimy zaczekać (w 2008 roku było to jakieś 250 cykli procesora), ale w tym momencie możemy tylko czekać i nie możemy nic robić. Dlatego właśnie procesor bawi się w to całe wykonywanie poza kolejnością, żeby się nie nudzić.
Tablice TLB
Każdy program ma swoją wirtualną przestrzeń adresową. Każdy program ma gdzieś adres 0, ale nie są to te same pamięci. Jądro systemu chcąc przydzielić programowi pamięć, przekazuje procesorowi informacje o tym, jaka pamięć rzeczywista będzie odpowiadać wirtualnej pamięci procesu. Takie pamięci przydzielane są najczęściej w ilościach 4 kB. Taki przydział zawiera również informacje, czy jest to pamięć do zapisu, czy można z niej wykonywać programy (na przykład zapobiega to atakowi powrotu do libc). Ale jest pewien szkopuł. Te wpisy również są umieszczane w pamięci RAM i również podlegają cachowaniu.
Ataki kanału bocznego na cache
Ataki kanału bocznego na cache wykorzystują różnice w czasie trwania operacji, która wprowadzają cache. Jako przykład można podać tutaj atak Flush+Reload, który polega na tym że napastnik często wykonuje instrukcję (procesora) CLFLUSH, która polega na tym że procesor nakazuje wszystkim rdzeniom wyrzucenie danego obszaru pamięci cache do pamięci RAM. Możliwa jest bowiem sytuacja, w której dane zostały zmodyfikowane w pamięci podręcznej, ale jeszcze nie w pamięci RAM. Spójność pamięci podręcznej jest jednym z dwóch trudnych problemów w informatyce (drugim jest nazywanie rzeczy). Ale wrócimy do instrukcji CLFLUSH. Jeśli zmierzymy ile zajęło przeładowanie danych, możemy wnioskować czy inny proces w międzyczasie uzyskał dostęp do tych danych (i w efekcie spowrotem poleciały one do cachu).
Przykład zabawkowy
Tutaj postaram się przedstawić przykład mówiący o tym że prosty kod może nadużyć mechanizmu wykonywania poza kolejnością w sposób który doprowadzi do wycieku informacji.
Rozważmy poniższy kod:
podnies_wyjatek(); // tutaj program się kończy
// linia poniższa nigdy nie zostanie osiągnięta
dostęp(pamięć_jądra[data * 4096]);
Jeśli wykonana instrukcja powoduje wyjątek, to musi przerwać wykonywanie programu (w sensie dalsza instrukcja nie może zostać wykonana). Jednak ze względu na wykonywanie poza kolejnością kolejne instrukcje mogły zostać już wykonane, jednak nie odniosły jeszcze efektu.
Nasz przykład zabawkowy nie może uzyskać dostępu do tablicy probe_array, ponieważ wyjątek momentalnie przerywa pracę programu i wysyła go w inne miejsce (albo do jądra systemu). Jednak, ze względu na wykonywanie poza kolejnością procesor mógł już zadecydować, że instrukcje uzyskujące dostęp do tablicy nie zależą od instrukcji wywołującej wyjątek. Ze względu na ten wyjątek instrukcje nie mają skutku architektonicznego.
Mimo jednak, że nie mają skutku architektonicznego, mają skutki mikroarchitektoniczne. Podczas wykonywania poza kolejnością pamięć do której odniósłby się proces jest przenoszona do pamięci cache. Skoro wyniki wykonywania poza kolejnością, które nie miały koniec końców skutku, są wyrzucane na śmietnik, to jednak kawałki pamięci RAM umieszczone w cache wciąż tak pozostają.
Bez wdawania się w większe trudności, taki stan mikroarchitektoniczny można w prosty sposób przekazać do stanu architektonicznego, aby następnie wykorzystać go w niecnych celach.
Przykładem może być tutaj poniższy kod asemblerowy, który polega na tym, żeby niedostępny adres jądra w rejestrze RCX spróbować odczytać (instrukcja mov al, byte [rcx]). Powinno to prowadzić do spowodowania wyjątku i przerwania wykonywania programu, jednak ze względu na to, że procesor jeszcze o tym nie wie (i jedzie dalej) to nieświadomie wyciąga z przestrzeni pamięci jądra dane.
; rcx - adres jądra, rbx - tablica próbna
xor rax, rax
.retry:
mov al, byte [rcx]
shl rax, 0xC
jz .retry
mov rbx, qword [rbx+rax]
W tym przykładzie zapisujemy bajt pod adresem jądra wskazywanym przez rejestr RCX w najmniej znaczącym bajcie 64-bitowego rejestru RAX. W tym momencie potrzebujemy jeszcze tzw. instrukcji przechodniej, czyli takiej która będzie wykonywana jeszcze zanim procesor się połapie, że coś jest nie w porządku.
Alokujemy sobie w pamięci tablicę, co do której mamy pewność że nie jest jeszcze obecna w pamięci cache. W instrukcji shl rax, 0xC mnożymy zawartość rejestru RAX razy 4096 (czyli rozmiar typowej strony pamięci), co zapobiega zgadywarce procesora przed załadowaniem tych danych do cache’u.
Zauważamy jeszcze że w wykonywaniu operacji poza kolejnością mamy przekłamanie ku zerze (czyli najczęściej odczytamy zero). Z tego powodu dodaliśmy jeszcze instrukcję jz .retry, która ponowi operację, jeśli odczytane zostanie zero. Ostatnia linijka, mov rbx, qword [rbx+rax] powoduje dodanie sekretu do adresu, formując adres ukrytego kanału. To będzie nasza instrukcja przechodnia. Dzięki temu, że jest to adres ukrytego kanału, zostanie ona załadowana do konkretnej linii cache’u, najważniejsze że procesor nie zgadnie do której konkretnej linii cache’u i przypadkowo nie załaduje sobie jakiejś linii danych do tego cache’u, co utrudni nam wykonanie tego zadania.
Otrzymywanie sekretu
Do wyciąganięcia sekretu z cache’u możemy zastosować metodę Flush+Reload. Gdy instrukcja przejściowa jest wykonywana, dokładnie jedna linia cache’u tablicy którą próbujemy odczytać jest zcache’owana, a jej pozycja zależy tylko od sekretu, który ustaliliśmy sobie w instrukcji nr 1. Tak więc napastnik iteruje po wszystkich 256 stronach tablicy próbkującej i mierzy czas dostępu do każdej z nich. Liczba stron zawierających linię umieszczoną w cache’u koresponduje bezpośrednio z tajną wartością.
Wyciąganie całej pamięci
Powtórzenie wszystkich 3 kroków Meltdown pozwala nam na wyciągnięcie całej pamięci. Ponieważ większość systemów operacyjnych mapuje całą pamięć fizyczną w pamięci jądra, staje się to niezwykle proste.
Walka z zabezpieczeniami
KASLR
KASLR, czyli “losowanie pamięci jądra” pozwala na umieszczenie w czasie rozruchu systemu jądra systemu w losowym adresie. KASLR został domyślnie włączony w maju 2017. Utrudnia to co prawda atak, bo nie wiemy gdzie to jądro jest, nie mniej jednak losowanie jest ograniczone do 40 bitów. Jeśli nasza maszyna będzie miała jednak 8 GB RAM, to wystarczy nam przetestowanie przestrzeni 40 bitów jedynie za pomocą 128 testów.
Poprawka KAISER
Poprawka KAISER, wdrożona przez Daniela Grussa et al. ogranicza liczbę nakładających się stron między procesami a jądrem do niezbędnego minimum, co znacznie utrudnia, jeśli nie uniemożliwia przeprowadzenie tego ataku. Dodatkowo, wykorzystuje nowoczesne funkcje procesorów aby zmniejszyć liczbę czyszczenia buforów TLB (bufor który tłumaczy procesorowi którą prawdziwą stronę pamięci ma wykorzystać dany program), co jeszcze bardziej przyśpiesza wykonywanie programów na procesorach Intel.
Jak znajdę coś jeszcze, to napiszę