Meltdown i Spectre wyjaśnione, czyli hakowanie procesorów #1: Optymalizacje

dodał 21 lutego 2018 o 07:10 w kategorii Błędy, Wykłady  z tagami:
Meltdown i Spectre wyjaśnione, czyli hakowanie procesorów #1: Optymalizacje

Ujawnienie podatności Meltdown i Spectre wywołało niemałe zamieszanie. Teraz, gdy emocje opadły, pora na wyjaśnienie. O co tam właściwie chodzi? Wyjaśnimy to krok po kroku w specjalnym cyklu artykułów.

Meltdown i Spectre to podatności niezależnie odkryte przez badaczy z Google Project Zero, Cyberus Technology oraz Politechniki w Graz (m. in. Jann Horn i Paul Kocher). Opublikowane artykuły są zwieńczeniem kilku lat żmudnych badań polegających na reverse-engineeringu procesorów. Samo istnienie tego typu podatności nie jest zaskoczeniem dla naukowców zajmujących się architekturą komputerów, prace wykazujące podobne niedopatrzenia w chaotycznym standardzie x86 ukazują się od wielu lat, ale te dwie są przełomowe.

Publikacje, które ukazały się w styczniu zawierały PoCe (ang. Proof of Concept) udowadniające istnienie ataków pozwalających na wykradzenie pamięci innego procesu, a nawet samego kernela. W tej serii artykułów spróbujemy wyjaśnić dosyć skomplikowaną przyczynowość pomiędzy wprowadzeniem optymalizacji, a powstałymi zagrożeniami.

Przetwarzanie potokowe

Rozważania warto rozpocząć od pytania: dlaczego w ogóle procesory są optymalizowane i jak wyglądają te optymalizacje? Każdy procesor stanowi skomplikowany układ cyfrowy, który jest zbudowany z pewnych bloków funkcjonalnych.

Przykładowa ścieżka danych w prostym procesorze MIPS. Na rysunku widać zależności pomiędzy blokami takimi jak pamięć, rejestry, jednostka arytmetyczno-logiczna, dekoder etc. Źródło: https://www.cise.ufl.edu/~mssz/CompOrg/CDA-proc.html

Najbardziej oczywistym i zarazem najprostszym podejściem byłoby zaprojektowanie procesora tak, aby instrukcje były wykonywane całkowicie sekwencyjnie. Przykładowo, instrukcję add [eax], ebx można zrealizować w następujący sposób:

  1. Pobierz instrukcję z pamięci (adres obecnej instrukcji wskazuje rejestr EIP).
  2. Zdekoduj instrukcję.
  3. Wczytaj pierwszy argument z pamięci (spod adresu zawartego w eax).
  4. Wykonaj operację dodawania w jednostce arytmetyczno-logicznej.
  5. Zapisz wynik do pamięci (pod adres zawarty w eax).
  6. Przesuń wskaźnik instrukcji na następną instrukcję.

Niestety, takie podejście projektowe umożliwia pracę procesora w częstotliwościach rzędu kilku MHz. Ponadto, niektóre bloki funkcjonalne będą bezczynne przez większość czasu potrzebnego do wykonania instrukcji. Przykładowo, jednostka arytmetyczno-logiczna (ALU) jest potrzebna wyłącznie w kroku #4, podczas wykonywania kroków #1-3 oraz #5 nie będzie miała żadnego zajęcia.

Wykonywanie instrukcji bez potokowości. Źródło: https://www.slideshare.net/nithilgeorge/2010-1002-intro-to-microprocessors1

Ponieważ rozważane bloki są fizycznymi układami, powinniśmy przeprojektować procesor tak, aby możliwie zwiększyć ich obciążenie. Tak powstała idea nazywana pipelining, czy też idea „przetwarzania potokowego”.

Potok, który wykonuje instrukcje po kolei, zgodnie z kolejnością zapisania ich w programie. Źródło: https://www.slideshare.net/nithilgeorge/2010-1002-intro-to-microprocessors1

Instrukcje dzielone są na mniejsze etapy, które w miarę możliwości wykonywane są równolegle. Wykonywanie instrukcji w kolejności programu ma swoje minusy. Tak jak widać na powyższym obrazku, w niektórych przypadkach złożone instrukcje mogą zakorkować potok na kilka cykli.

Out-of-order execution

Innym ciekawym aspektem optymalizacji jest wykonywanie poza kolejnością (ang. out-of-order execution). Ustalenie zależności pomiędzy poszczególnymi instrukcjami pozwala na wykonywanie ich w optymalnej kolejności dostępności poszczególnych danych, a nie w arbitralnej kolejności zapisania ich w programie.

Potok z niekolejnym wykonywaniem. Instrukcje są kolejkowane tak, aby maksymalnie wykorzystać zasoby procesora. Źródło: https://www.slideshare.net/nithilgeorge/2010-1002-intro-to-microprocessors1

Ponadto, niektóre bloki funkcjonalne w nowoczesnych procesorach są zdublowane, więc niektóre instrukcje mogą zostać całkowicie zrównoleglone, o ile jest to bezpieczne dla integralności.

Instrukcje skoku

Najwięcej kłopotu sprawiają jednak instrukcje skoku warunkowego. Pamiętajmy, że zgodnie z powyższym modelem, w momencie kiedy rozpoczyna się dekodowanie instrukcji skoku, musimy już zacząć ładować następną instrukcję. Niestety, na tym etapie nie wykonaliśmy jeszcze instrukcji skoku do końca, więc nie wiemy jaką ścieżką dalej podąży program. Czy warunek był pozytywny i skok wystąpił, czy też warunek okazał się negatywny i skok powinniśmy zignorować?

Po załadowaniu instrukcji jnz short loc_47CFD0 musimy natychmiast zacząć ładować następne instrukcje. Nie wiemy jednak, czy będzie to instrukcja pod adresem 0x0047CFD7, czy 0x47CFD0, ponieważ instrukcja skoku zostanie wykonana dopiero kilka cykli później (w momencie kiedy potok całkowicie ją przetworzy).

Branch prediction

Najprostszym rozwiązaniem tego problemu jest zaprzestanie ładowania następnych instrukcji do potoku, tak aby zaczekać na wynik wykonania instrukcji skoku i podjąć właściwą decyzję. Ponownie powracamy jednak do znanego problemu: postępując w ten sposób spowodujemy, że podczas wykonywania każdego skoku większość zasobów procesora będzie marnowana.

Jest jednak sprytniejszy sposób na rozwiązanie tej sytuacji, formalnie nazywany branch prediction. Procesor po napotkaniu instrukcji skoku stara się przewidzieć, jaki będzie wynik jej wykonania. Do tego celu wykorzystuje specjalne heurystyki, które bazują głównie na danych historycznych.

W dużym uproszczeniu: jeżeli niedawno ten kawałek kodu był wykonywany kilka tysięcy razy i w większości przypadków skok wykonał się pozytywnie, to warto obstawiać że również tym razem tak będzie.

Speculative execution

Dzięki tego typu predykcjom procesor po przejściu przez instrukcję skoku może zacząć ładować dalsze instrukcje zgodnie z najbardziej prawdopodobną ścieżką. Ponieważ jednak w nowoczesnych procesorach potok (pipeline) jest znacznie dłuższy niż na prezentowanych tutaj przykładach, wciąż nie jest to wystarczające rozwiązanie.

Idąc za ciosem, producenci procesorów wymyślili jeszcze ciekawszą optymalizację nazywaną speculative execution. Procesor po napotkaniu instrukcji skoku zapisuje swój stan (całkiem analogicznie do np. zapisywania stanu w grze komputerowej), obstawia jaki będzie wynik wykonania tej instrukcji i na tej podstawie ładuje oraz wykonuje następne instrukcje.

Kiedy wykonywanie brancha dojdzie do końca i znany jest wynik, procesor może podjąć decyzję: jeżeli obstawił prawidłowo, to wszystko jest w porządku i może kontynuować pracę bez żadnych opóźnień. Jeżeli jednak spekulacja okaże się błędna, procesor wczytuje zapisany stan, efektywnie cofając się do momentu przejścia przez brancha i ponownie wykonuje ten kawałek programu, tym razem prawidłowo.

Taki sposób działania procesora, sam w sobie nie powoduje jeszcze niczego złego. Nawet jeżeli procesor obstawi błędnie i przez to wykona złe instrukcje, chwilę później „zorientuje się”, że spekulacja była błędna i „naprawi” swój stan. Niektóre instrukcje mają jednak swoje efekty uboczne, których nie można tak łatwo odwrócić. Warto tu podkreślić, że nie chodzi tutaj o takie efekty jak zmiana wartości w pamięci, czy zapisanie pliku na dysku. Procesor nie zleca takich operacji, dopóki nie upewni się, że wykonana instrukcja była prawidłowa. Nieprawidłowe spekulacje pozostawiają jednak efekty uboczne wewnątrz samego procesora, np. w stanie pamięci podręcznej.

Biorąc pod uwagę, że spekulacje zaprojektowane są tak, aby większość z nich wykonała się pozytywnie, ich wpływ na zwiększanie wydajności procesora jest niezwykle istotny. W nowoczesnych procesorach pojedyncze okno spekulacji zawiera nie kilka, lecz nawet kilkadziesiąt instrukcji. Takie zrównoleglenie wiąże się jednak z istotnymi problemami po stronie bezpieczeństwa, ale do tego wrócimy niebawem.

Podsumowanie

Pierwsza część artykułu przybliżyła podstawowe techniki optymalizacji nowoczesnych procesorów. Prawidłowe zrozumienie podstawowych zasad budowy CPU wykorzystujących potokowość jest kluczowe, aby później prawidłowo zinterpretować ataki Meltdown i Spectre. Następna część artykułu poruszy tematykę bocznych kanałów oraz omówi ten, który jest stosowany w obu wspomnianych atakach.