
Driver Reverse & Exploitation
This README (EN) may contain errors !
⚠️ Disclaimer: This project is strictly educational and demonstrative. It is not intended for malicious use.
The goal is to learn reverse engineering methodology and the exploitation steps of a Windows driver.
Here I explain the approach I followed to solve the exercise proposed by d1rk (SaadAhla) https://github.com/SaadAhla, consisting of performing reverse engineering and exploitation on a legitimate, signed driver, not present in blocklists (HVCI, LOLBIN...).
A C program allowing to terminate any active process on the system via this Kernel-mode Driver is available, I detail its operation below.

📃 Usage:
DriverKiller.exe <process_name.exe> [-d]
Option -d: Removes the service and the Driver from the system after exploitation.
Testsigning mode must be enabled on the target machine because the Driver’s certificate has expired.
The exercise provides a .sys file, named with its SHA-256 hash.
The first step is to open this file with IDA.
IDA is available for free. You just need to go to the Hex-Rays website to generate a license and download the software.
We start by listing the IAT (Import Address Table) of the Driver and searching for the API call we are interested in: ZwTerminateProcess.

Double-clicking on ZwTerminateProcess redirects us to the compiled code of this function. By selecting the entry and displaying the cross-references, we obtain the list of Driver functions that call it.

We see that the function sub_12EF4, at offset 1CE, uses ZwTerminateProcess. After double-clicking, IDA displays its compiled code.

The decompiled code reveals calls to ZwOpenProcess (which opens a handle to the target process) and to ZwTerminateProcess (which terminates the process via this handle).
Looking at the documentation of ZwOpenProcess (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-zwopenprocess), we see that the parameter ClientID corresponds to a pointer indicating the PID of the target process.
On the line above, ClientId.UniqueProcess is initialized with variable v22. This is defined just above:
v22 = (void *)(*(_QWORD *)i + 10);
To understand this assignment, we must identify variable i and the field +10.

Earlier in this function, we see a call to ZwQuerySystemInformation with parameter SYSTEM_PROCESS_INFORMATION. We also see that i is the iterator over the entries of this structure with variable v6.
According to the documentation of ZwQuerySystemInformation: https://learn.microsoft.com/en-us/windows/win32/sysinfo/zwquerysysteminformation, this function returns an array containing one entry per active process on the system.
The structure SYSTEM_PROCESS_INFORMATION is described here: https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
UNICODE_STRING ImageName;
KPRIORITY BasePriority;
HANDLE UniqueProcessId;
PVOID Reserved2;
ULONG HandleCount;
ULONG SessionId;
PVOID Reserved3;
SIZE_T PeakVirtualSize;
SIZE_T VirtualSize;
ULONG Reserved4;
SIZE_T PeakWorkingSetSize;
SIZE_T WorkingSetSize;
PVOID Reserved5;
SIZE_T QuotaPagedPoolUsage;
PVOID Reserved6;
SIZE_T QuotaNonPagedPoolUsage;
SIZE_T PagefileUsage;
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION;
Reminder: sizes of some types on Windows x64:
typedef struct _UNICODE_STRING {
USHORT Length; -> 2
USHORT MaximumLength; -> + 2 = 4
PWSTR Buffer; -> + 8 = 12 (12 is not a multiple of 8 so 4 bytes of padding are added before Buffer) = 16
} UNICODE_STRING;
Offset calculation of UniqueProcessId:
ULONG NextEntryOffset; -> 4
ULONG NumberOfThreads; -> + 4 = 8
BYTE Reserved1[48]; -> + 48 = 56
UNICODE_STRING ImageName; -> + 16 = 72
KPRIORITY BasePriority; -> + 4 = 76 (76 is not a multiple of 8 so 4 bytes of padding are added) = 80
HANDLE UniqueProcessId; -> + 8 = 88
So UniqueProcessId is at offset 0x50 (80 in decimal).
Looking at the assignment of variable v22, we see that i is cast as a pointer QWORD (8 bytes):
v22 = (void *)*((_QWORD *)i + 10);
So v22 corresponds to address of i + 10 * 8 = 80 bytes. This variable thus contains the PID retrieved from the SYSTEM_PROCESS_INFORMATION structure.
To know which PID will be passed to ZwTerminateProcess, we need to analyze the condition surrounding this assignment.

We can see that the process image name is first retrieved:
v9 = (wchar_t *)*((_QWORD *)i + 8);
Because v9 = address of i + 8 × 8 = 64 bytes. This corresponds to the Buffer of the ImageName member, since this member is located at offset 56 + 2 (USHORT) + 2 (USHORT) + 4 (padding) = 64.
Given the manipulations and the loops below, we can hypothesize that a comparison is made between the process name passed as argument (a2) and the active processes on the system (v9/String):
sub_1C078(String, v9, (int)v13); v17 = strupr(a2); v18 = strupr(String);
Thus, the parameter a2 is expected to contain the name of the process to be terminated via ZwTerminateProcess.
We can see that a2 is a parameter of the function sub_12EF4. To go further, we need to examine the references to this function (I renamed it ZwTerminateProcessCaller for better readability).

We can see that ZwTerminateProcessCaller is called by the function sub_13624 at offset 61A.

Before analyzing this decompiled code, I check the references of function sub_13624 (renamed ZwTerminateProcessCallerCaller) to make sure this code is indeed used after an API call to DeviceIoControl from UserMode.

We can see that ZwTerminateProcessCallerCaller is called by the function sub_14130 (renamed ZwTerminateProcessCallerCallerCaller ... fortunately for us, this is the last one before the entry point 😅).

We can see that ZwTerminateProcessCallerCallerCaller is called by the function sub_1A4A8 at offset 306.

We find the assignment of the function ZwTerminateProcessCallerCallerCaller:
memset64(DriverObject->MajorFunction, (unsigned __int64)ZwTerminateProcessCallerCallerCaller, 0x1Cu);
Which means this function is assigned to all entries of the MajorFunction table (0x1B = 27, and there are 28 major IRPs).

Before returning to function sub_13624 (aka ZwTerminateProcessCallerCaller), we retrieve the Symbolic Name and Device Name (identical here): Viragtlt.

Going back to ZwTerminateProcessCallerCaller, we notice that its second parameter (thus a2) corresponds to MasterIrp->AssociatedIrp.SystemBuffer.

Just above the call to ZwTerminateProcessCaller we find the IOCTL code: -2106392528 (in hexadecimal: 0x82730030).
With this information, we can deduce that to exploit this Driver, one must send a DeviceIoControl API call to the Driver with the name of the process to be terminated in the SystemBuffer.
🔷 Information recovered thanks to reverse engineering:
0x82730030ViragtltViragtltTo exploit this Driver (if installed and active on the target machine), it is necessary to open a handle to it, then make a DeviceIoControl API call with a Buffer containing the name of the process to terminate.
For this exercise, I developed a C project that:
DeviceIoControl API call.DeviceIoControl API call. (Requires admin rights since a service is created.)DeviceIoControl API call.I also added a -d option that allows to remove the service and the Driver from the system after exploitation.
Here is the behavior of the C program in its full execution cycle:

In this case, DriverKiller.exe is not detected by Microsoft Defender, neither statically nor dynamically.
Evasion does not really make sense here because the exploited Driver has an expired certificate, making its use in real-world scenarios unlikely.
But for better stealth, one could have implemented:
⚠️ This project was carried out in a learning context. It may contain inaccuracies or errors. Any suggestion, correction, or discussion is welcome! 😃
Thanks to d1rk (SaadAhla): https://github.com/SaadAhla