Post

Learning from CVE-2025-7771 (ThrottleStop) Part 1

Analyzing CVE-2025-7771 Vulnerable Driver "ThrottleStop"

Learning from CVE-2025-7771 (ThrottleStop) Part 1

Disclaimer: This blog is merely to demonstrate my learning process of this vulnerability. This is not an original discovery of the vulnerability nor an original exploit of the vulnerability. All references (other blog posts, PoCs) are listed at the end of this blog.

Introduction

The goal was simple: I want to start my learning on kernel vulnerabilities and exploit development, as well as practice on reverse engineering. So I just go to https://www.loldrivers.io/ and randomly picked a known vulnerable driver with blog posts and PoCs about it. My goal is not to come up with an original exploit, but to learn from what others have found. So, this series of blog posts simply demonstrates my learning process of CVE-2025-7771.

In this first part, we will look into the vulnerable driver and determine how it can be triggered.

Looking at Vulnerable IOCTLs

The vulnerable driver sample can be found here: https://www.loldrivers.io/drivers/6e0786f5-2168-40a8-a068-e261c4eb10e7/

The first blog post I read about this vulnerability was from Kaspersky’s blog where they observed threat actors abusing this vulnerability to terminate Anti-Virus processes. The blog post stated that the following 2 IOCTL codes were used.

  • 0x80006498 (to read physical memory)
  • 0x8000649c (to write physical memory)

So I loaded the vulnerable driver into Ghidra and started to find the vulnerable codes. First of all, I used this ntddk_64.gdt file and followed this blog post from Matt Hand to import symbols related to Windows drivers. This simplifies the reversing process a lot with the respective data structures loaded.

To find the IRP handler, I simply need to locate the code section where function pointers are assigned to the MajorFunction offset of the PDRIVER_OBJECT in the DriverEntry. The IRP Major function code for IRP_MJ_DEVICE_CONTROL is 0xe, so the function pointer shown below is the function that handles IRP from userland.

Screenshot

Following the function pointer, we can see a large switch case based on the IOCTL code sent from user land. As the vulnerable IOCTL codes were mentioned above, we can simply jump to the respective switch case by finding the corresponding hex values.

Arbitrary Memory Read (0x80006498)

The vulnerability lies in the code section shown below, where the MmMapIoSpace function is called to map an arbitrary physical address provided by the user program in the IRP (pIrp->AssociatedIrp + 4). Screenshot

If MmMapIoSpace was able to find the physical address, it returns a virtual address that is mapped to the requested physical address. The address value is stored in the output buffer specified when calling DeviceIoControl. And based on the specified output buffer size, the function can read either 1, 2, 4, or 8 bytes (of the virtual address) at a time. Screenshot

Note that the function is vulnerable because there is no additional check on whether the IRP sender is allowed to call this IOCTL code, thus allowing any userland program to send the IRP and trigger this function.

Arbitrary Memory Write (0x8000649c)

Similar to the arbitrary read vulnerability, this IOCTL code allows arbitrary write operation without any extra validation. The function again uses MmMapIoSpace to map an arbitrary physical address provided from userland to a virtual address. Screenshot

Then based on a size value, the function will either write 1, 2, 4 or 8 bytes to the mapped address with contents provided in the IRP. Screenshot

Classic Exploit Primitives

This driver demonstrated 2 of the most straightforward exploit primitive, where userland programs can directly read and write physical memory. As explained in this article, Windows process uses virtual address when reading or writing to memory, which is then mapped to the actual physical memory. If a process can read and write to the physical memory, it can tamper with the content of other processes and break process integrity.

Building a PoC

I’m not really familiar with exploiting a vulnerable driver at this point. So instead of building a PoC from the ground up, I’ve actually read and copied multiple blogs and PoCs (links in the Reference section). During the process, I’ve actually learned a lot of new knowledge as described in the following sections.

Obtaining Physical Address

The first question I have is that since both of the vulnerable IOCTLs requires a physical address to be passed through the IRP, how can I obtain a physical address from a userland process? Through some digging online, I’ve found that almost all blogs and PoCs uses something called “Superfetch”.

This blog by Outflank precisely answered my question. In short, Windows has a built-in service “Superfetch” (aka SysMain) that keeps track of memory pages and its state. Researchers found a way to use it to fetch virtual address to physical address mappings, thus able to resolve the physical address mapped to a virtual address. After some more digging, I found a Github repository by jonomango that contained a simple C library to retrieve a physical address using Superfetch. (Note that administrator privilege is needed to create the memory map using Superfetch).

The example below shows how the “superfetch.h” header file can be used to find a mapped physical address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <superfetch/superfetch.h>

int main() {
  // Creating a memory map
  auto const mm = spf::memory_map::current();

  // Any virtual address.
  void const* const virt = ...;

  // Searching for mapped physical address
  std::uint64_t const phys = mm->translate(virt);

  std::printf("%p -> %zX\n", virt, phys);
}

DeviceIoControl Parameters

The next question is that what parameters do I need to send the IRP using DeviceIoControl? The official Microsoft documentation shows the required parameters below.

1
2
3
4
5
6
7
8
9
10
BOOL DeviceIoControl(
  [in]                HANDLE       hDevice,
  [in]                DWORD        dwIoControlCode,
  [in, optional]      LPVOID       lpInBuffer,
  [in]                DWORD        nInBufferSize,
  [out, optional]     LPVOID       lpOutBuffer,
  [in]                DWORD        nOutBufferSize,
  [out, optional]     LPDWORD      lpBytesReturned,
  [in, out, optional] LPOVERLAPPED lpOverlapped
);

It seems to be straightforward for what each parameters stands for, but in the Ghidra decompiled there seems to be some checks on certain parameters before they are passed to call MmMapIoSpace. So I’ll need to figure out what are the conditions required for a successful function call.

Arbitrary Read Parameters

Before reaching MmMapIoSpace, there are 2 IF statement checks that needs be passed.

Screenshot

It is not exactly clear what those parameters refer to in Ghidra. While I could spend some time looking up various documentations regarding the IRP structure, I decided to do some trial and error and use WinDbg to verify my assumptions.

Firstly, the 2 checked parameters “(p_Var3->Parameters).Power.ShutdownType” and “p_Var3->Parameters + 4” are compared against an integer value. So here I assume that it should be referring to the nInBufferSize and nOutBufferSize in the DeviceIoControl call.

I’ll use the following PoC to test my assumption. The nInBufferSize and nOutBufferSize are just random values for testing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define IOCTL_READ_MEMORY 0x80006498
#define IOCTL_WRITE_MEMORY 0x8000649C

// Code omitted ...
char output[8] = { 0 };
DWORD bytesReturned = 0;
ULONGLONG result;
ULONG_PTR physicalAddress = 0xFFFFFFFFFFFFFFFF; // Dummy address

DeviceIoControl(
	hDevice,
	IOCTL_READ_MEMORY, // 0x80006498
	&physicalAddress, // lpInBuffer
	0x5, // nInBufferSize
	output, // lpOutBuffer
	0x3, // nOutBufferSize
	&bytesReturned,
	NULL
);

// ...

In WinDbg, I’ve attached to another test VM’s kernel through KDNET Network Kernel Debugging and set a breakpoint at ThrottleStop.sys+0x2184, which is where the first IF statement starts. Screenshot

In WinDbg, we can check the value stored at rbp+10 which is 0x5. This proves that the function first checks if the input buffer size is exactly 8 bytes. Screenshot

Then, we change nInBufferSize to 8 bytes and set a breakpoint at the second IF statement (bp ThrottleStop.sys+0x219d). Screenshot

Now we see that the value at rbp+0x8 is 0x3, which corresponds to the output buffer size. Screenshot

Based on the Ghidra disassembly, the IF statement checks whether:

  • the output buffer size is less than 9
  • the output buffer size is either 1, 2, 4 or 8 (exact match)

This suggests that the output buffer size will specify how many bytes to read from the physical address.

We will change the output buffer size to 0x8 and set another breakpoint at ThrottleStop.sys+0x21c4, which is right before MmMapIoSpace is called. Screenshot

According to the official documentation, the following parameters are required to call MmMapIoSpace.

1
2
3
4
5
PVOID MmMapIoSpace(
  [in] PHYSICAL_ADDRESS    PhysicalAddress,
  [in] SIZE_T              NumberOfBytes,
  [in] MEMORY_CACHING_TYPE CacheType
);

We can see the following 3 parameters are passed to MmMapIoSpace from our PoC. Screenshot

This shows that our input buffer containing the address 0xFFFFFFFFFFFFFFFF will be used as the physical address to be mapped using MmMapIoSpace.

Finally, we have all the parameters required.

1
2
3
4
5
6
7
8
9
10
DeviceIoControl(
	hDevice,
	IOCTL_READ_MEMORY, // 0x80006498
	&physicalAddress, // Input Buffer containing physical address
	0x8, // Size of input buffer == 8
	output, // Output buffer to hold return content
	sizeof(output), // Size of output buffer (either 1, 2, 4 or 8 bytes)
	&bytesReturned,
	NULL
);

Arbitrary Write Parameters

Unlike the previous read IOCTL, the arbitrary write IOCTL has a different requirement regarding the provided input buffer. However, we can follow a similar approach to discover the required parameters.

We’ll use the same PoC again as below. This time we will set a breakpoint at ThrottleStop.sys+0x2271, which is the first IF statement.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define IOCTL_READ_MEMORY 0x80006498
#define IOCTL_WRITE_MEMORY 0x8000649C

// Code omitted ...
char output[8] = { 0 };
DWORD bytesReturned = 0;
ULONGLONG result;
ULONG_PTR physicalAddress = 0xFFFFFFFFFFFFFFFF; // Dummy address

DeviceIoControl(
	hDevice,
	IOCTL_WRITE_MEMORY, // 0x80006498
	&physicalAddress, // lpInBuffer
	0x5, // nInBufferSize
	output, // lpOutBuffer
	0x3, // nOutBufferSize
	&bytesReturned,
	NULL
);

// ...

Screenshot

When we inspect rbp+0x8, we can see the value 0x3 which corresponds to the output buffer size. This IF statement checks whether the output buffer size is set to 0. (actually you can also just infer from the previous analysis when we are inspect rbp+0x8). Screenshot

So we will set the output buffer size to zero and set a breakpoint at the next IF statement at ThrottleStop.sys+0x2286 Screenshot

Screenshot

Again by inspecting rbp+0x10 (or simply infer from previous analysis), the value 0x5 corresponds to the input buffer size. This check is similar to the IF statement in the arbitrary read section, where it ensures the value in ebx is either 1, 2, 4 or 8 bytes. However, this time the value is subtracted by 8 before the comparison. So it actually ensures that the input buffer size is either 9, 10, 12 or 16 bytes.

This suggests that the function is always expecting a 8 byte value followed by either a 1, 2, 4 or 8 byte value. Since the function attempts to write a value to a physical address, the most plausible structure of the input buffer could be something like:

1
2
target_address // + 0x0 (8 bytes)
value // + 0x8 (1, 2, 4 or 8 bytes)

And based on this we can create a simple C struct to be used in later exploitation.

1
2
3
4
typedef struct{
	ULONG_PTR PhysicalAddress;
	ULONG_PTR Value;
} WRITE_PAYLOAD;

To prove this assumption, we will use the following PoC. Here I’m using a hardcoded physical address obtained through the Sysinternal tool “RamMap”, which is actually how researchers discovered the use of “Superfetch” to obtain mapped physical addresses.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#define IOCTL_READ_MEMORY 0x80006498
#define IOCTL_WRITE_MEMORY 0x8000649C

#pragma pack(push,1)
typedef struct {
    ULONG_PTR PhysicalAddress; // +0
    ULONG_PTR Value;   // +8
} WRITE_PAYLOAD;
#pragma pack(pop)

// Code omitted ...
char output[8] = { 0 };
DWORD bytesReturned = 0;
ULONGLONG result;
ULONG_PTR physicalAddress = 0xA859000; // Dummy address
WRITE_PAYLOAD writePayload = { 0 };

writePayload.PhysicalAddress = physicalAddress;
writePayload.Value = 0x1234;

DeviceIoControl(
	hDevice,
	IOCTL_WRITE_MEMORY,
	&writePayload,
	sizeof(writePayload),
	output,
	0x0,
	&bytesReturned,
	NULL
);

// ...

And we will set a breakpoint at ThrottleStop.sys+0x22a4 where the physical address is retrieved. Screenshot

In WinDbg, if we step over until ThrottleStop.sys+0x22b2, we can see the instruction mov rcx, [r15] moves the physical address into rcx before calling MmMapIoSpace. So the first part of our input buffer is correct. Screenshot

Screenshot

The mapped virtual address is then saved in rcx for later use. Screenshot

As we continue stepping over the code until ThrottleStop.sys+0x233d, we will arrive at the IF statement below where it checks the size of the value to be written (second part of our input buffer). Screenshot

Continue stepping over until ThrottleStop.sys+0x2346 and we will see that rcx is dereferenced and assigned the value of rax, which is the target value 0x1234 we assigned in out input buffer. Screenshot

This confirms that our payload structure is correct for the input buffer. So now we know that the requirements are:

  • output buffer size must be 0
  • input buffer size must be either 9, 10, 12 or 16 bytes
    • this will instruct the driver to write either 1, 2, 4 or 8 bytes to the physical address
  • the input buffer should be structured as shown below.
    1
    2
    
    target_address // + 0x0 (8 bytes)
    value // + 0x8 (1, 2, 4 or 8 bytes)
    

    Conclusion

    After the analysis shown above, we now have a clear idea on how the vulnerable IOCTL code can be reached and interact with, along with any conditions that needs to be met. In the next blog, we will create another PoC to abuse these vulnerable IOCTLs and tamper with any process on the system.

References

Blog Posts

Blog post by Kaspersky Blog post by Xavi Blog post by OutFlank Blog post by Matt Hand

PoCs

PoC by xM0kht4r Superfetch PoC by jonomango

Microsoft Documentation

DeviceIoControl MmMapIoSpace

This post is licensed under CC BY 4.0 by the author.