Skip to content
arrow-alt-circle-up icon

Cyber Incident Call

arrow-alt-circle-up icon

00800 1744 0000

arrow-alt-circle-up icon

ISO

intro

Welcome back to our blog series on the kernel driver vulnerability landscape, where we aim to eliminate as many kernel driver zero-days (leading to privilege escalation) as possible. We hope these blogs help you with finding kernel driver zero-days to prevent future exploitation as well.

During this security research, we explored a kernel driver belonging to Macrium Reflect to identify potential vulnerabilities. Several critical vulnerabilities were discovered including a kernel heap overflow, allowing unauthorised write access to kernel memory. Join us in this two-part blog on how a divide by zero bluescreen triggered us to further investigate the driver and eventually give us a privilege escalation. Part one will focus on the details of the two vulnerabilities and in part two we will dive into the tricky exploitation.

The start of a journey

Our journey begins with the discovery of a vulnerable driver identified by our tooling that automates kernel driver vulnerability scanning, revealing susceptibility to a divide by zero error triggered through user-supplied input via the system buffer. While initially unremarkable, this vulnerability hints at a deeper issue—user inputs permeating driver logic without security checks. With this in mind we set out to take a better look at the driver's internals.

The driver's first line of defence is the use of IoCreateDeviceSecure.This API enables a driver to define strict rules for who is allowed to access the driver from user-mode through the use of a so called SDDL string. However, in this case an SDDL string is used that allows any user to interact with the driver. This effectively enabled everything that follows.

Meet psmounterex.sys, a component of Macrium Reflect, entrusted with the crucial task of mounting backup images for enterprise users. The driver spawns several system threads for handling IOCTLs sent from user-mode, allowing for the mounting, unmounting of- and interacting with backup images stored locally or remotely. Security measures, like ensuring singular backup image access per device and invoking client security and impersonation protocols, safeguard against unauthorized file interactions, particularly critical given the driver's accessibility from standard user accounts. While the developers did a very good job at introducing protections for attacks from usermode, we will see that it is very difficult to defend upward of 20 exposed functions from receiving malicious inputs.

The main two vulnerabilities that we found that lead to privilege escalation are a kernel heap overflow write and a kernel heap overflow read using state corruption. Let's dive in!

Writing beyond heap buffer bounds

The first of two vulnerabilities arises from a disconnect between the size of a heap buffer at creation time and at use time. In the handler for IOCTL 0x8100E000, which is used for mounting a file in the driver, a heap allocation is created using the size that we give it from user-mode. The pointer to this heap allocation is stored in the DeviceExtension at offset +0x2C0 :

img1

 

With this heap buffer created and the file (of our choosing) mounted, the driver enables the IRP_MJ_READ functionality. This is where we will find our first vulnerability. Deep inside the function that handles the read request, after passing many constraints, there's some code that reads from the file we mounted earlier. This code is seemingly used to extract a header, more on this later. The code for reading from our file looks somewhat like this:

mov r9, [rdi+2C0h] ... ... ... ... ... mov [rsp+98h+Length], eax ; Length mov [r11-70h], r9; Buffer mov [r11-78h], r8 ; IoStatusBlock xor r8d, r8d ; ApcRoutine xor r9d, r9d ; ApcContext xor edx, edx ; Event call cs:ZwReadFile

We can see the pointer that was created during the mounting of the file being used here as the buffer pointer into which ZwReadFile reads the file contents. The vulnerability here is that the Length parameter used for this call is disconnected from the size that was used to create the buffer. If we create the buffer with a size of 4 and pass a Length longer than that to the ZwReadFile function, we end up reading contents from the file beyond the boundary of the kernel heap buffer.

One thing to note is that in practice we don't call this function ourselves. Calling ReadFile on the device from user-mode after mounting a file in it triggers the windows mounting manager to call the IRP_MJ_READ function. It does so with a Length of 0x1000 or 0x2000. This means that we can overflow up to ~0x2000 bytes.

Reading beyond heap buffer bounds

Armed with the heap buffer overflow, we could have tried a tricky semi-blind attack on kernel structures on the kernel heap. But the driver was complex, and it accepted a lot of input, so we felt there might be more to explore. Our goal now was to find a kernel heap read primitive vulnerability that could support us in exploiting the write primitive. Having a read primitive would eliminate the semi-blind part of the exploit and make it much more consistent and versatile. Thus, we began searching for features in the driver that send information from kernel memory to user mode.

There are two functions in the driver that enable most of the remaining functionality: the mounting functions, let's call them MOUNT_A and MOUNT_W . The former is a very straight forward function that creates some buffers and opens a handle to a file. This file is used for most of the other actions that can be performed by the driver. The latter mounting function contains that same logic, but also has the ability to perform something called catalog optimisation. As the write primitive made use of the less complex of the two mounting functions, we used this as a starting point for the read primitive as well. After spending several days working through the driver, we identified several locations that output data from the driver (stored on the heap) to user-mode. Unfortunately for us, the developers did do an excellent job at performing all the necessary bounds checks. We hit a dead end.

It was not until a few weeks later that we returned to the driver with a fresh mind and some new ideas. Initially we were apprehensive of looking at the more complex mounting function, it having 20 times more logic than the easier of the two. However, this is also what attracted us to it. More complexity, more lines of code, usually means a bigger attack surface and more places to make mistakes. And a single mistake we found. A complex one to spot, but very powerful.

The first hint of the vulnerability was found in the function that handles IOCTL number C99D28D6h . This function takes an output buffer, an offset and a size as input from usermode and reads the contents of a kernel heap buffer managed by the driver into the output buffer. A pointer to the buffer is kept inside of the DeviceExtension of the driver at offset +310h :

img2

 

An offset is calculated by adding the offset value taken from the input buffer (r9) which is multiplied by 0x1E and added to the base pointer in +310h . The maxcount, which is the number of bytes memmove will move, is the parameter of interest to us. We know the source buffer is a kernel heap allocation and the destination is an output buffer which is output to user-mode. If we are able to pass a maxcount that is bigger than the source buffer's size, we would be able to read data on the kernel heap that is not part of the heap allocation we are reading from. The maxcount is calculated using inputs from earlier in the function:

loc_14000BE25: mov r9d, [rbx+4] mov ecx, [r15+80h] cmp r9d, ecx jbe short loc_14000BE64 loc_14000BE64: mov eax, [rbx+8] cmp eax, ecx jbe short loc_14000BE9D

We can see our offset value (r9) being loaded from the input we gave to the function ([rbx+4]). The maxcount is calculated by first taking the size (eax) we gave as input to the function ([rbx+8]) and comparing it to ecx, which is a value taken from offset +80h in the DeviceExtension. If the size we request is bigger than that value, the function fails. It does the same for the offset value. After confirming both the size and the offset are lower than the value taken from +80h , it subtracts the offset from the size and passes it to the function. It seems that the value from +80h is the one that determines the bounds of the buffer from +310h . Since a mismatch between the values in +80h and the actual buffer size in +310h would immediately create a vulnerability, we began searching for where this value is set initially.

Enter the MOUNT_W function. Towards the start of the function we find what we are looking for:

mov rdi, [rdi+18h] #systembuffer ... .... .... ... ... ... ... ... ... ... ... ... ... ... mov eax, [rdi+0Bh] #user input from systembuffer mov [rbx+80h], eax

The value in +80h is set to a value that we provide through the input buffer from user-mode. A bit further down in the function we find how it is used:

mov ebx, [rdi+80h] imul ebx, 1Eh mov edx, ebx mov ecx, 1 ; PoolType mov r8d, 78457350h ; Tag add rdx, 1Eh ; NumberOfBytes call cs:ExAllocatePoolWithTag

So, we control the value that is loaded into +80h , but this value is essentially equal to the size of the buffer. As such the bounds check for the memmove we looked at earlier is solid. There's no way around this. Bummer.

...or is there? It wouldn't be an interesting blog without a few setbacks! While the bounds check is as solid as it gets, there's still something we can do. For this we resorted to the often-overlooked technique of state corruptions. What if we could trick the driver into thinking the buffer size is one size during creation, but another size when it's read out.

The actual vulnerability here is that the pointer at +310h is persistent between the mounting and unmounting of files, while the value at +80h isn't. We can successfully mount a file using the MOUNT_W function and pass it a small size for the value that gets used as the size for the creation of the buffer. Then, we can unmount it. This lets us mount another file, while also keeping the buffer intact. If we look at the start of the MOUNT_A function it becomes apparent that it contains the exact same logic for loading the size value:

mov eax, [rdi+0Bh] mov [rbx+80h], eax

And none of the error paths in that function reset this value. As such, after mounting and unmounting the file using MOUNT_W , we can mount a file using MOUNT_A and give it a different (bigger) size for the value in +80h . If we then read from the buffer using the function we looked at before, we can a number of bytes from it that is (much) larger than the size of the actual buffer. This leads to an out of bounds kernel heap read.

Conclusion

The age-old adage in vulnerability research, "where there's one vulnerability, there's more," proved true once again. By scrutinizing the code a bit beyond the initial blue screen, we quickly identified a kernel heap out-of-bounds write vulnerability. For practicality's sake, we then searched for a corresponding read primitive and successfully found one as well. Who knows what you might find if you look at some kernel drivers.

Join us in part 2 of this blog to see how we combined these two vulnerabilities to create a full privilege escalation exploit!

We are here for you

Need help with getting your organisation ready for DORA or wondering far along you your business currently is?
Get in touch and we will guide you with your next steps.