Jedną z najciekawszych i najczęściej używanych koncepcji w architekturze x86 jest tryb chroniony (Protected mode) i jego obsługa w 4 trybach (aka rings):

To była trudna idea do uchwycenia i postaram się ją wyjaśnić w tym poście tak jasno jak to możliwe. Omówimy następujące pojęcia:

  • GDT, LDT, IDT.
  • Translacja pamięci wirtualnej.
  • ASLR i Kernel ASLR (KASLR).

Zacznijmy od podstaw, każdy komputer ma przynajmniej (miejmy nadzieję) następujące komponenty: CPU, Disk i RAM. Każdy z tych komponentów posiada kluczową rolę w przepływie systemu. Procesor wykonuje polecenia i operacje na pamięci (RAM), RAM przechowuje dane, których używamy i umożliwia szybki i niezawodny dostęp do nich, dysk przechowuje trwałe dane, których potrzebujemy, aby istnieć nawet po restarcie lub wyłączeniu. Zaczynam od tego, ponieważ mimo że jest to podstawa, ważne jest aby o tym pamiętać i czytając ten artykuł zadać sobie pytanie, o którym komponencie mówimy w danym momencie.

System operacyjny jest oprogramowaniem, które zarządza tym wszystkim, a także tym, które umożliwia szybki, wygodny, spójny i wydajny interfejs, aby uzyskać dostęp do wszystkich jego możliwości – niektóre z nich to dostęp do sprzętu, a inne to zwiększenie wygody i wydajności.

Jak każde dobre oprogramowanie, system operacyjny działa w warstwach, jądro jest pierwszą warstwą i – moim zdaniem – najważniejszą. Aby zrozumieć znaczenie jądra, musimy najpierw zrozumieć jego działanie i wyzwania, przed którymi stoi, więc spójrzmy na niektóre z jego obowiązków:

  • Obsługa wywołań systemowych (ten sam interfejs, o którym mówiliśmy).
  • Przydzielanie zasobów (RAM, CPU i wiele innych) procesom/wątkom w ręku.
  • Zabezpieczanie wykonywanych operacji.
  • Pośredniczy pomiędzy sprzętem a oprogramowaniem.

Wiele z tych czynności wykonywanych jest z hojną pomocą procesora, w przypadku x86, tryb chroniony (Protected mode) jest trybem, który umożliwia nam ograniczenie mocy (zestawu instrukcji) aktualnie uruchomionego kontekstu wykonania.

Załóżmy, że mamy dwa światy – świat użytkownika i świat nadzorcy. W danym momencie możesz być tylko w jednym z tych światów. Kiedy jesteś w świecie użytkownika, widzisz świat takim, jakim chce go widzieć nadzorca. Zobaczmy, co mam przez to na myśli:

Powiedzmy, że jesteś procesem. Proces jest kontenerem jednego lub więcej wątków. Wątek jest kontekstem wykonania, jest to logiczna jednostka, w której wykonywane są instrukcje maszynowe. Oznacza to, że gdy wątek wykonuje, powiedzmy, odczyt z pamięci o adresie 0x80808080, to faktycznie odwołuje się do wirtualnego adresu 0x80808080 bieżącego procesu. Jak można się domyślić, zawartość adresu będzie różna pomiędzy dwoma procesami. Teraz, wirtualna przestrzeń adresowa jest na poziomie procesu, co oznacza, że wszystkie wątki tego samego procesu mają tę samą przestrzeń adresową i mogą uzyskać dostęp do tej samej pamięci wirtualnej. Aby podać przykład zasobu, który jest na poziomie wątku, użyjmy słynnego stosu.

Mam więc wątek, który wykonuje następujący kod:

Nasz wątek wykonuje funkcję main, która wywoła naszą funkcję „func”. Załóżmy, że przerwiemy wątek w linii 9. Układ stosu będzie następujący:

  1. zmienna_a.
  2. parametr.
  3. adres powrotu – adres linii 20.
  4. zmienna_b.

Dla zobrazowania:

W podanym kodzie tworzymy 3 wątki dla naszego procesu i każdemu z nich wypisujemy jego id, segment stosu i wskaźnik stosu.

Możliwe wyjście tego programu to:

Jak widać wszystkie wątki miały ten sam segment stosu, ponieważ mają tę samą wirtualną przestrzeń adresową. Wskaźnik stosu dla każdego z nich jest inny, ponieważ każdy z nich ma swój własny stos, w którym przechowuje wartości.

Uwaga uboczna na temat segmentu stosu – wyjaśnię więcej na temat rejestrów segmentu w sekcji GDT/LDT – na razie uwierz mi na słowo.

Dlaczego to jest ważne? W dowolnym momencie procesor może zamrozić wątek i przekazać kontrolę dowolnemu innemu wątkowi, który chce. Jako część jądra, scheduler jest tym, który przydziela CPU do aktualnie istniejących (i „gotowych”) wątków. Aby wątki mogły działać niezawodnie i wydajnie, konieczne jest, aby każdy z nich miał swój własny stos, na którym może zapisać swoje istotne wartości (zmienne lokalne i adresy powrotne na przykład).

Aby zarządzać swoimi wątkami, system operacyjny utrzymuje specjalną strukturę dla każdego wątku zwaną TCB (Thread Control Block), w tej strukturze zapisuje – między innymi – kontekst tego wątku i jego stan (uruchomiony / gotowy / itp…). Kontekst zawiera – ponownie – między innymi, wartości rejestrów procesora:

  • EBP -> Adres bazowy stosu, każda funkcja używa tego adresu jako adresu bazowego, z którego przesuwa się, aby uzyskać dostęp do zmiennych lokalnych i parametrów.
  • ESP -> Aktualny wskaźnik do ostatniej wartości (pierwszej do pop) na stosie.
  • Rejestry ogólnego przeznaczenia -> EAX, EBX, itd…
  • Rejestr flag.
  • C3 -> zawiera lokalizację katalogu stron (zostanie omówiony później).
  • EIP – Następna instrukcja do wykonania.

Poza wątkami system operacyjny musi śledzić wiele innych rzeczy, w tym procesy. Dla procesów OS zapisuje strukturę PCB (Process Control Block), powiedzieliśmy, że dla każdego procesu istnieje izolowana przestrzeń adresowa. Na razie załóżmy, że istnieje tabela, która mapuje każdy adres wirtualny na fizyczny i ta tabela jest zapisana w PCB, OS odpowiedzialny za aktualizację tej tabeli i utrzymanie jej w prawidłowym stanie pamięci fizycznej. Za każdym razem, gdy scheduler przełącza wykonanie do danego wątku, tabela, która została zapisana dla procesu tego wątku jest stosowana do procesora, więc będzie on w stanie poprawnie przetłumaczyć adresy wirtualne.

To tyle jeśli chodzi o koncepcje, zrozummy jak to jest faktycznie zrobione. W tym celu spójrzmy na świat z perspektywy procesora:

Global Descriptor Table

Wszyscy wiemy, że procesor posiada rejestry, które pomagają mu wykonywać obliczenia, niektóre rejestry bardziej niż inne (;)). Z założenia x86 obsługuje wiele trybów pracy, ale najważniejsze z nich to tryb użytkownika i nadzorcy, procesor posiada specjalny rejestr zwany gdtr (Global Descriptor Table Register), który przechowuje adres do bardzo ważnej tabeli. Tabela ta mapuje każdy wirtualny adres do odpowiadającego mu trybu pracy procesora, zawiera również uprawnienia dla tego adresu (READ | WRITE | EXECUTE). oczywiście rejestr ten może być zmieniany tylko z trybu nadzorcy. W ramach wykonywania instrukcji procesor sprawdza, którą instrukcję wykonać jako następną (i pod jakim adresem), sprawdza ten adres z GDT i w ten sposób wie, czy jest to ważna instrukcja na podstawie jej pożądanego trybu (dopasowanie aktualnego trybu procesora do trybu w GDT) i uprawnień (jeśli nie jest wykonywalna – nieważna). Przykładem może być instrukcja 'lgdtr’, która ładuje wartość do rejestru gdtr i może być wykonana tylko z trybu nadzorowanego. Kluczową kwestią do podkreślenia jest to, że wszelkie zabezpieczenia operacji pamięciowych (wykonywanie instrukcji / zapis do nieprawidłowej lokalizacji / odczyt z nieprawidłowej lokalizacji) są wykonywane przez GDT i LDT (o tym za chwilę) na poziomie procesora przy użyciu tych struktur, które zostały zbudowane przez OS.

Tak wygląda zawartość wpisu w GDT / LDT:

http://wiki.osdev.org/Global_Descriptor_Table

Jak widać ma ona zakres adresów, których wpis dotyczy, a jego atrybuty (uprawnienia) są takie, jakich można się spodziewać.

Local Descriptor Table

Wszystko co powiedzieliśmy o GDT jest również prawdziwe dla LDT z małą (ale dużą) różnicą. Jak sama nazwa wskazuje GDT jest stosowany globalnie w systemie, podczas gdy LDT jest stosowany lokalnie, co mam na myśli mówiąc globalnie i lokalnie? GDT śledzi uprawnienia dla wszystkich procesów, dla każdego wątku i nie zmienia się między przełączaniem kontekstu, LDT z drugiej strony jest. Ma to sens tylko wtedy, gdy każdy proces ma swoją własną przestrzeń adresową, możliwe jest, że dla jednego procesu adres 0x10000000 jest wykonywalny, a dla innego tylko do odczytu/zapisu. Jest to szczególnie prawdziwe przy włączonym ASLR (będzie to omówione później). LDT jest odpowiedzialny za utrzymanie uprawnień, które rozróżniają każdy proces.

Jedną rzeczą do zauważenia jest to, że wszystko co zostało powiedziane jest celem struktury, ale w rzeczywistości niektóre OS mogą lub nie mogą używać niektórych struktur w ogóle. na przykład możliwe jest używanie tylko GDT i zmienianie go między przełączaniem kontekstu i nigdy nie używać LDT. To wszystko jest częścią projektowania systemu operacyjnego i kompromisów. Wpisy w tej tabeli wyglądają podobnie jak w GDT.

Selektory

Skąd procesor wie, gdzie szukać w GDT lub LDT, gdy wykonuje konkretną instrukcję? Procesor posiada specjalne rejestry zwane rejestrami segmentowymi:

https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture

- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').

Każdy rejestr ma długość 16 bitów, a jego struktura jest następująca:

http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm

Mamy więc indeks do GDT/LDT, mamy też bit mówiący o tym czy jest to LDT czy GDT, oraz w jakim trybie jest on potrzebny (RPL 0 to supervisor, 4 to user).

Interrupt Descriptor Table

Poza GDT i LDT mamy także IDT (Interrupt Descriptor Table), IDT jest po prostu tabelą, która przechowuje adresy bardzo ważnych funkcji, niektóre z nich należą do OS, inne do sterowników i urządzeń fizycznych podłączonych do PC. Podobnie jak gdtr mamy idtr, który, jak się zapewne domyślasz, jest rejestrem przechowującym adres IDT. Co czyni IDT tak wyjątkowym? Kiedy inicjujemy przerwanie, procesor automatycznie przełącza się w tryb nadzorowany, co oznacza, że każda funkcja wewnątrz IDT działa w trybie nadzorowanym. Każdy wątek z każdego trybu może wywołać przerwanie poprzez wydanie instrukcji 'int’, po której następuje liczba mówiąca procesorowi, na jakim indeksie znajduje się docelowa funkcja. Mając to na uwadze, jest teraz oczywiste, że każda funkcja wewnątrz IDT jest potencjalną bramą do trybu nadzorowanego.

Wiemy więc, że mamy GDT/LDT, który mówi CPU o uprawnieniach dla każdego adresu wirtualnego i mamy IDT, który wskazuje funkcje „bramy” do naszego ukochanego jądra (które oczywiście rezyduje wewnątrz nadzorowanej sekcji pamięci). Jak te struktury zachowują się w działającym systemie?

Pamięć wirtualna

Zanim zrozumiemy jak to wszystko razem gra, musimy omówić jeszcze jedno pojęcie – pamięć wirtualną. Pamiętasz, kiedy powiedziałem, że istnieje tabela, która mapuje każdy adres pamięci wirtualnej do jego fizycznej? W rzeczywistości jest to trochę bardziej skomplikowane niż to. Po pierwsze nie możemy po prostu mapować każdy adres wirtualny, jak to zajmie więcej miejsca niż faktycznie mamy, i odkładając potrzebę bycia wydajnym na bok, OS również może zamienić strony pamięci na dysku (dla wydajności i wydajności), jest możliwe, że strona pamięci potrzebnego adresu wirtualnego nie jest w pamięci w tej chwili, więc oprócz tłumaczenia adresu wirtualnego do fizycznego musimy również zapisać, czy pamięć jest w pamięci RAM, a jeśli nie, gdzie to jest (może być więcej niż jeden plik strony). MMU (Memory Management Unit) jest komponentem odpowiedzialnym za tłumaczenie pamięci wirtualnej na fizyczną.

Jedną z rzeczy naprawdę ważnych do zrozumienia jest to, że każda instrukcja w każdym trybie przechodzi przez proces tłumaczenia adresu wirtualnego, nawet kod w trybie nadzorowanym. Kiedy procesor jest w trybie chronionym, każda wykonywana przez niego instrukcja używa adresu wirtualnego – nigdy fizycznego (istnieją pewne sztuczki, które powodują, że rzeczywisty adres wirtualny zawsze będzie przekładał się na dokładnie tę samą pamięć wirtualną, ale to jest poza zakresem tego wpisu).

Więc kiedy procesor jest już w trybie chronionym, skąd wie gdzie szukać kiedy musi przetłumaczyć adres wirtualny? Odpowiedzią jest rejestr CR3, rejestr ten przechowuje adres do struktury, która zawiera wymagane informacje – katalogu stron. Jego wartość zmienia się wraz z aktualnie uruchomionym procesem (znowu inna wirtualna przestrzeń adresowa).

Jak więc wygląda ten katalog stron? Jeśli chodzi o wydajność to musimy być w stanie odpytywać tą „tabelę” tak szybko jak to tylko możliwe, musimy również zadbać o to aby była ona tak mała jak to tylko możliwe, ponieważ tabela ta będzie tworzona dla każdego procesu. Rozwiązanie tego problemu jest po prostu genialne. Najlepszy obraz, jaki mogłem znaleźć, aby zilustrować proces tłumaczenia jest ten (z wikipedii):

MMU mają 2 wejścia, wirtualny adres do tłumaczenia i CR3 (adres do aktualnie odpowiedniego katalogu strony). Specyfikacja x86 tnie adres wirtualny na 3 kawałki:

  • 10 bitowy numer – indeks do katalogu stron.
  • 10 bitowy numer – indeks do tablicy stron.
  • 12 bitowy numer – offset do samego adresu fizycznego.

Więc procesor bierze pierwszą 10-bitową liczbę i używa jej jako indeksu do katalogu stron, dla każdego wpisu w katalogu stron mamy tabelę stron, którą następnie procesor używa następnej 10-bitowej liczby jako indeksu. Każdy wpis w tabeli katalogowej wskazuje na stronę pamięci 4K boundary, która następnie ostatni 12bitowy offset z adresu wirtualnego jest używany do wskazania dokładnej lokalizacji w fizycznej. Błyskotliwość tego rozwiązania polega na:

  • Elastyczności, że każdy adres wirtualny wskazuje na zupełnie niepowiązany adres fizyczny.
  • Wydajność w przestrzeni zaangażowanych struktur jest niesamowita.
  • Nie każdy wpis każdej tabeli jest używany, tylko adresy wirtualne, które są faktycznie używane i mapowane przez proces istnieją w tabelach.

Naprawdę przepraszam za brak wyjaśnienia tego procesu w szczegółach, jest to dobrze udokumentowany proces, nad którego wyjaśnieniem wielu ludzi ciężko pracowało lepiej niż ja mógłbym to kiedykolwiek zrobić – wygoogluj to.

Jądro vs Użytkownik

Tutaj robi się ciekawie (i magicznie, jeśli mogę).

Zaczęliśmy ten artykuł od stwierdzenia, że system operacyjny orkiestruje to wszystko, robi to używając jądra. Jak już stwierdzono, jądro jest uruchomiony w sekcji pamięci, która jest mapowana jako tryb nadzorowany tylko w GDT dla wszystkich procesów. Tak wiem, że każdy proces ma swoją własną przestrzeń adresową, ale jądro wycina tę przestrzeń adresową (zazwyczaj górną połowę, zależy od systemu operacyjnego) na swój własny użytek, nie tylko wycina przestrzeń adresową, ale również pod tym samym adresem dla wszystkich procesów. Jest to ważne, ponieważ kod jądra jest stały i każde odwołanie do zmiennych i struktur musi być w tej samej lokalizacji dla wszystkich procesów. Możesz spojrzeć na jądro jak na specjalną bibliotekę załadowaną do każdego procesu w tym samym miejscu.

Głębiej w przerwania

Wiemy, że IDT zawiera adresy funkcji, te funkcje nazywane ISR (Interrupt Service Routine), niektóre wykonują się gdy wystąpi zdarzenie sprzętowe (naciśnięcie klawisza na klawiaturze), a inne gdy oprogramowanie zainicjuje przerwanie, na przykład aby przełączyć się do trybu jądra.

Windows ma fajną koncepcję na temat przerwań i ich priorytetyzacji: Jednym ze szczególnie ważnych przerwań są tyknięcia zegara. Z każdym tyknięciem zegara jest przerwanie, które jest obsługiwane przez jego ISR. Harmonogram systemu operacyjnego używa tego zdarzenia zegara do kontrolowania, ile czasu każdy proces jest uruchomiony i czy nadeszła kolej kolejnego. Jak można zrozumieć, to przerwanie jest super ważne i musi być obsłużone tak szybko, jak to się dzieje, nie wszystkie ISR’y mają takie samo znaczenie i to jest to, gdzie priorytety między przerwaniami. Weźmy na przykład naciśnięcie klawisza na klawiaturze i załóżmy, że ma ono priorytet 1. Właśnie nacisnąłem klawisz na klawiaturze, a jego ISR jest w trakcie wykonywania, podczas wykonywania ISR klawiatury wszystkie przerwania o tym samym priorytecie i niższym są ignorowane. Podczas wykonywania ISR, wyzwalany jest ISR zegara z priorytetem 2 (dlatego nie został wyłączony), natychmiastowe przełączenie następuje do ISR zegara, gdy zegar zakończy pracę, sterowanie wraca do ISR klawiatury z miejsca, w którym się zatrzymał. priorytety tych przerwań nazywane są IRQLs (Interrupt ReQuest Level), gdy IRQL przerwania wzrasta, jego priorytet jest wyższy. Przerwania o najwyższym priorytecie nigdy nie są przerwaniami w środku, działają do końca, zawsze. IRQLs jest specyficzny dla Windowsa – IRQL to liczba z przedziału 0-31, dla Linuksa natomiast nie istnieje, Linux obsługuje każde przerwanie z tym samym priorytetem i po prostu wyłącza wszystkie przerwania kiedy naprawdę potrzebuje, aby ta konkretna procedura nie była zakłócana. Jak widzisz to wszystko jest kwestią projektu i preferencji.

Połączmy to wszystko z naszym ukochanym trybem użytkownika . ISR tego zdarzenia zegarowego będzie wykonywał się niezależnie od tego jaki wątek aktualnie działa i może nawet przerwać innemu ISR dla niepowiązanego zadania. jest to doskonały przykład dlaczego kernel jest pod tym samym adresem dla wszystkich procesów nie chcemy zmieniać GDT i katalogu stron (w C3) za każdym razem gdy wykonujemy przerwanie jak to się dzieje MANY razy podczas nawet pojedynczej funkcji dowolnego procesu trybu użytkownika. Wiele dzieje się pomiędzy tymi liniami kodu, które piszesz podczas tworzenia aplikacji trybu użytkownika (;)).

Inny sposób patrzenia na przerwania to zewnętrzne i niezależne wejścia do naszego systemu operacyjnego, ta definicja nie jest dokładna (nie wszystkie przerwania są zewnętrzne lub niezależne), ale jest dobra, aby zwrócić uwagę, dużą częścią pracy jądra jest nadawanie sensu zdarzeniom, które występują cały czas z każdego miejsca (urządzenia wejściowe) i z jednej strony obsługa tych zdarzeń, a z drugiej upewnianie się, że wszystko jest poprawnie skorelowane.

Więc aby to wszystko miało sens zacznijmy od prostej aplikacji w trybie użytkownika wykonującej następującą instrukcję:

0x0000051d push ebp;

Dla każdej instrukcji, którą wykonuje CPU najpierw bada adres tej instrukcji (w tym przypadku '0x0000051d’) względem GDT/LDT używając rejestru segmentu kodu (’cs’ ponieważ jest to instrukcja do wykonania) aby wiedzieć jakiego indeksu szukać w tabeli (pamiętaj rejestr segmentu mówi CPU dokładnie gdzie szukać). Gdy procesor wie, że instrukcja znajduje się w miejscu wykonywalnym, a my w odpowiednim pierścieniu (tryb użytkownika/tryb jądra), kontynuuje wykonywanie instrukcji. W tym przypadku instrukcja 'push ebp’ nie wpływa tylko na rejestr, ale także na stos programu (przesuwa na stos zawartość ebp), więc CPU sprawdza także w GDT/LDT adres wewnątrz rejestru esp (adres aktualnej lokalizacji na stosie, a ponieważ jest to lokalizacja stosu, CPU wie, że należy użyć do tego celu rejestru segmentu stosu), aby upewnić się, że jest on zapisywalny w tym konkretnym pierścieniu. Należy pamiętać, że gdyby ta instrukcja była również czytanie z pamięci, CPU musiałby sprawdzić odpowiedni adres do odczytu access too.

To nie wszystko, po CPU sprawdził wszystkie aspekty bezpieczeństwa to jest teraz trzeba uzyskać dostęp i manipulować pamięci, jak pamiętasz adresy są w ich wirtualnym formacie. MMU teraz przetłumaczyć każdą pamięć wirtualną określoną przez instrukcję do adresu pamięci fizycznej za pomocą rejestru CR3, który wskazuje na katalog stron (że wskazuje na tabeli stron), który pozwala nam ostatecznie przetłumaczyć adres do fizycznego. Zauważ, że adres może nie być w pamięci w momencie potrzeby, w takim przypadku system operacyjny wygeneruje błąd strony (wyjątek generujący przerwanie) i przeniesie dane do pamięci fizycznej za nas, a następnie będzie kontynuował wykonanie (jest to przezroczyste dla aplikacji trybu użytkownika).

Od użytkownika do jądra

Każda wymiana między trybem użytkownika a trybem jądra odbywa się przy użyciu IDT. Z aplikacji w trybie użytkownika, instrukcja 'int <num>’ przekazuje wykonanie do funkcji w IDT o indeksie num. Kiedy wykonanie jest w trybie jądra, wiele zasad się zmienia, każdy wątek ma inny stos dla trybu użytkownika i jądra, kontrole dostępu do pamięci są znacznie bardziej skomplikowane i obowiązkowe, w trybie jądra jest bardzo mało, czego nie można zrobić i wiele można złamać.

ASLR i KASLR

częściej niż nie jest to „tylko” brak wiedzy, który uniemożliwia nam osiągnięcie niemożliwego.

ASLR (Address Space Layout Randomization) jest koncepcją, która jest różnie implementowana w każdym systemie operacyjnym, koncepcja polega na randomizacji wirtualnych adresów procesów i ich załadowanych bibliotek.

Zanim się zanurzymy chciałem zauważyć, że zdecydowałem się włączyć ASLR w tym poście, ponieważ jest to miły sposób, aby zobaczyć, jak tryb chroniony i jego struktury umożliwiły ten rodzaj możliwości, nawet jeśli nie jest to ten, który go implementuje lub odpowiedzialny za to dla tej sprawy.

Dlaczego ASLR?

Dlaczego jest to proste, aby zapobiec atakom. Kiedy ktoś jest w stanie wstrzyknąć kod do działającego procesu, nieznajomość adresów niektórych korzystnych funkcji jest tym, co może spowodować, że atak się nie powiedzie.

Mamy już inną przestrzeń adresową dla każdego procesu, oznacza to, że bez ASLR wszystkie procesy miałyby te same adresy bazowe, dzieje się tak dlatego, że kiedy każdy proces ma własną wirtualną przestrzeń adresową, nie musimy uważać na kolizje między procesami. Kiedy linkujemy program, linker wybiera stały adres bazowy, na który linkuje plik wykonywalny. Na papierze wszystkie pliki wykonywalne linkowane przez ten sam linker z domyślnymi parametrami (adres bazowy może być konfigurowany w razie potrzeby) będą miały ten sam adres bazowy. Dla przykładu napisałem dwie aplikacje jedną o nazwie „1.exe” i drugą „2.exe”, obie są różnymi projektami w Visual Studio, a jednak obie mają ten sam adres bazowy (użyłem exeinfo PE do sprawdzenia adresu bazowego w pliku PE):

Nie dość, że te dwa pliki wykonywalne mają ten sam adres bazowy to jeszcze oba nie obsługują ASLR (wyłączyłem go):

Można je również zobaczyć zawarte w formacie PE w zakładce Charakterystyka pliku:

Następnie uruchommy oba programy wykonywalne jednocześnie i oba mają ten sam adres bazowy (do podglądu obrazu bazowego będę używał vmmap z Sysinternals):

Widzimy, że oba procesy nie używają ASLR i mają ten sam adres bazowy 0x00400000. Gdybyśmy byli atakującymi i mieli dostęp do tego pliku wykonywalnego, moglibyśmy dokładnie wiedzieć, jakie adresy będą dostępne dla tego procesu, gdy znajdziemy sposób na wstrzyknięcie się w jego wykonanie. Włączmy ASLR w naszym pliku wykonywalnym 1.exe i zobaczmy jego działanie:

Zmieniło się!

KASLR (Kernel ASLR) jest taki sam jak ASLR, tylko działa na poziomie jądra, co oznacza, że jeśli atakujący był w stanie wstrzyknąć się do kontekstu jądra, to (miejmy nadzieję) nie będzie w stanie wiedzieć, które adresy zawierają jakie struktury (na przykład gdzie GDT siedzi w pamięci). Jedną rzeczą, o której należy wspomnieć jest to, że ASLR działa swoją magię z każdym spawnem nowego procesu (który wspiera ASLR oczywiście), podczas gdy KASLR robi to przy każdym restarcie, jako że jest to moment, w którym jądro jest „spawnowane”.

Jak ASLR?

Więc jak to działa i jak jest połączone z trybem chronionym? The jeden który odpowiedzialny implementować ASLR być the ładowacz. Kiedy proces jest uruchamiany, loader jest tym, który musi umieścić go w pamięci, stworzyć odpowiednie struktury i odpalić jego wątek. Program ładujący najpierw sprawdza czy plik wykonywalny obsługuje ASLR i jeśli tak, to losuje jakiś adres bazowy z zakresu dostępnych adresów (przestrzeń jądra na przykład nie jest oczywiście dostępna). Na podstawie tego adresu program ładujący inicjalizuje teraz katalog stron dla danego procesu, aby wskazać randomizowaną przestrzeń adresową na fizyczną. The elastyczność LDT być także przychodzić nasz ratunek gdy the ładowacz po prostu tworzyć LDT the korespondować the randomizować adres z the odpowiedni pozwolenie. Piękno tutaj jest to, że tryb chroniony nie jest nawet świadomy, że ASLR jest używany, jest wystarczająco elastyczny, aby nie dbać.

Kilka ciekawych szczegółów implementacji jest to, że w oknach randomizowany adres dla konkretnego pliku wykonywalnego jest ustalony ze względu na wydajność. Chodzi mi o to, że jeśli randomizujemy adres dla, powiedzmy, calc.exe, to za drugim razem, gdy zostanie on wykonany, adres bazowy będzie taki sam. Jeśli więc otworzę 2 kalkulatory w tym samym czasie – będą one miały ten sam adres bazowy. Gdy zamknę oba kalkulatory i otworzę je ponownie, oba będą miały ten sam adres, tyle że będzie on inny niż adresy poprzednich kalkulatorów. Dlaczego to nie jest efektywne? Zastanów się nad powszechnie używanymi bibliotekami DLL. Wiele procesów z nich korzysta i jeśli ich adresy bazowe byłyby różne dla każdej instancji procesu, ich kod również byłby inny (kod odwołuje się do danych używając tego adresu bazowego) i jeśli kod jest inny, DLL będzie musiała być załadowana do pamięci dla każdego procesu. W rzeczywistości OS ładuje obrazy tylko raz dla wszystkich procesów, które używają tego obrazu. To oszczędza miejsce – dużo miejsca!

Wniosek

Do tej pory powinieneś być w stanie wyobrazić sobie jądro w pracy i zrozumieć, jak wszystkie kluczowe struktury architektury x86 grają razem do większego obrazu i umożliwiają nam uruchamianie potencjalnie niebezpiecznych aplikacji w trybie użytkownika bez (lub z niewielkim) strachem.

Articles

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.