Ring0 pod DPMI

Autor: Wojciech Muła
Dodany:3.03.2002
Aktualizacja:16.04.2002

Treść

Wprowadzenie

Uwaga! — poziomy uprzywilejowania na x86 są numerowane „odwrotnie” 0 = najwyższy, 3 = najniższy. Tutaj, pisząc w kontekście poziomów uprzywilejowania, słowa niższy/wyższy mam na myśli możliwości aplikacji.

Aplikacje uruchamiane przez serwer DPMI zawsze są uruchamiane na Ring3, czyli najniższym poziomie uprzywilejowania. Do większości zastosowań w zupełności to wystarcza, ale np. profilowanie kodu przy użyciu instrukcji rdmsr/wrmsr nie będzie możliwe — są to instrukcje uprzywilejowane.

Bieżący poziom uprzywilejowania (CPL) kodu jest zapisany w selektorze.

15                       4 3 2   1
+------------------------+--+----+
|         INDEX          |TI|RPL |
+------------------------+--+----+

Gdyby zabezpieczenia były bardzo prymitywne wystarczyłoby utworzyć kopię bieżącego deskryptora kodu, nadać mu DPL=0, stworzyć selektor do niego z RPL=0 i wykonać daleki skok. Jednakże procesor sprawdza czy CPL (ang. Current Privilage Level — bieżący poziom uprzywilejowania) jest dostatecznie wysoki: innymi słowy czy CPL <= RPL. Można wykonać skok z kodu działającego na poziomie 0 na kod ring3, ale w przeciwną stronę nie jest to możliwe.

Zmiany poziomu uprzywilejowania są możliwe w trzech przypadkach:

Bramki wywołania

Bramka wywołania jest specjalnym deskryptorem, który określa adres procedury oraz jej poziom uprzywilejowania który może być wyższy niż CPL. Deskryptor ten ma następujący format:

liczba bitów zakres bitów opis
16 bitów 48..63 offset 16..31
1 bit 47 P
2 bity 45..46 DPL
5 bitów 40..44 TYPE=01100
8 bitów 32..39 DWORD COUNT
16 bitów 16..31 code selector
16 bitów 0..15 offset 0..15

Interesują nas w zasadzie trzy pola tego deskryptora:

Pole DWORD COUNT określa ile podwójnych słów ma skopiować procesor ze stosu wywołującej aplikacji na nowy stos — w naszym przypadku to będzie zero. Odsyłam do dokumentacji procesora, gdzie zagadnienie to jest szczegółowo wyjaśnione.

Poniższy przykład pokazuje jak używać callgates.

segment. data

adress:
        garbages            dd ? ; cokolwiek
        callgate_selector   dw ? ; selektor bramki

segment. text

call far [adress]

; lub

push word [callgate_selector]
pop  gs

call dword [gs:0]

Sterowanie zostanie przekazane do procedury pod adresem zapisanym w deskryptorze bramki, tj. code_selector:offset. CPL zostanie ustawiony na poziom uprzywilejowania bramki.

Ring0, to wcale nie jest tak proste...

Właściwie pokazałem już jak wejść na ring0, jednak „problemy” stwarza serwer DPMI — standardowymi funkcjami do zapisywania deskryptorów nie uda się zapisać ani deskryptora kodu na ring0, ani bramki wywołania.

Dlatego trzeba samodzielnie zapisać w odpowiednie miejsca tablicy LDT niedozwolone (niebezpieczne?) deskryptory. Dlaczego w LDT? Bo DPMI pozwala na alokację deskryptorów w LDT, dzięki temu będziemy znać „pewne” adresy, i nie trzeba będzie ryzykować zapisem (i zniszczeniem) przypadkowych deskryptorów.

Problemem jest dostanie się do pamięci przechowującej tablice deskryptorów — obszar tej pamięci na 99% znajduje się poza dostępną aplikacji przestrzenią adresową. Trzeba się więc jakoś do niej dostać, a przepis na to jest nadzwyczaj prosty — stworzyć segment danych obejmujący całe 4GB pamięci, o adresie bazowym 0, z prawami zapisu/odczytu, z DPL=3 — serwer DPMI powinien pozwolić na zapisanie takiego deskryptora — jeśli nie pozwoli — game over.

To nieważne, że obszary pamięci które obejmie ten nowy segment są chronione z poziomu innych deskryptorów — procesor sprawdza prawa dostępu tylko poprzez bieżący deskryptor.

Zapis deskryptorów

Załóżmy, że mamy już rzeczony 4-gigabajtowy segment — pora dostać się do LDT naszej aplikacji.

segment .data

whole_memory dw 0 ; selektor danych 4GB

descriptor   dw 0 ; deskryptor który chcemy zapisać
             dw 0 ;
             dw 0 ;
             dw 0 ;

LDT_selector dw 0 ; oraz jego selektor

; opis Globalnej Tablicy Deskryptorów
GDT dw 0 ; GDT limit - rozmiar tablicy
    dd 0 ; GDT base adres - adres liniowy początku tablicy

Proszę zwrócić uwagę, że adres liniowy GDT nie wymaga żadnych translacji — jest to równocześnie adres logiczny w 4-gigabajtowym segmencie.

segment .text

sgdt [GDT]   ; rozkaz SGDT nie jest uprzywilejowany - na szczęście :)
sldt ax      ; ax = selektor LDT

Adres bazowy bieżącej tablicy LDT (w 4GB segmencie) jest równy:

movzx eax, ax      ; eax = selektor LDT (jednocześnie offset w GDT)
mov   esi, [GDT+2] ; esi = GDT base adress
add   esi, eax     ; esi = adres bazowy bieżącego LDT

Z kolei mając selektor w LDT wystarczy dodać go teraz do esi, by móc zapisać żądany deskryptor.

push ds

push ds              ;
pop  es              ; es = ds

push [whole_memory]  ;
pop  ds              ; ds = whole_memory

mov  edi, descriptor ; esi = offset descriptor

push esi
add  esi, [LDT_selector]

cld
movsd                ; przesłanie danych spod es:edi do ds:esi
movsd                ;

pop  esi
pop  ds

Najczęściej będzie tak, że procedura mająca działać na ring0 będzie zapisana w bieżącym segmencie kodu. Aby uzyskać selektor kodu ring0 wystarczy odczytać deskryptor bieżącego kodu, zmodyfikować tylko pole DPL, i rzecz jasna zapisać.

Podsumowanie

W artykule przedstawiłem wszystko co jest potrzebne by wskoczyć na ring0. Przykładu nie zamieszczam bo w zasadzie wszystko już jest, jedyny co trzeba zrobić to alokować deskryptory, odpowiednio je inicjować (lub modyfikować) i zapisywać.

Oto kolejne kroki:

  1. alokacja 3 deskryptorów w LDT: na kod ring0, bramkę wywołania i segment 4GB
  2. utworzenie segmentu 4GB
  3. zapisanie deskryptora kodu ring0
  4. zapisanie deskryptora bramki

Funkcje DPMI które będą potrzebne (ich opis można znaleźć w liście przerwań Ralfa Browna):

Powodzenia!