What a forgotten driver from 2008 can still do to a fully patched Windows 11 system and what it taught me about Windows internals.
Introduction
There is a learning method associated with Richard Feynman, the Nobel Prize-winning physicist who was, by most accounts, a better teacher than he was a physicist, which is saying something. The method is simple: pick something you think you understand, then try to explain it from scratch as if you were teaching it to someone who knows nothing. Where your explanation becomes vague, where you reach for jargon you cannot unpack, where you wave your hands and say “and then the magic happens”, that is exactly where your understanding runs out. Most of the time, you do not notice those gaps until you try to close them in writing.
I have been doing security research on and off for years. I have read hundreds of posts, papers, and books about page table walks, token-stealing shellcodes, and BYOVD attacks. I have nodded along to blog posts and felt a comfortable sense of familiarity. But there is a meaningful difference between recognising a concept when you encounter it and being able to produce it from nothing on a blank page. Writing this post forced me to find out which side of that line I was actually on, for each topic, one at a time. The answer, predictably, was: further from the left than I had assumed.
So this post is a record of a driver hunt, a real one, with a real primitive, real disassembly, and a working proof-of-concept executed in a VM. But it is also a document of the process of understanding that hunt, written out in enough detail that a reader can follow the reasoning rather than just watch the results. If you are at the stage I was at when I started, where you recognise the concepts but cannot yet produce them, start with the primer. Either way: if you find a place where my explanation is vague, where I wave my hands, where something does not quite add up, that is worth noting. It certainly means I have not finished understanding it yet.
The series
This is part one of a series documenting a real driver hunt: taking a signed, uncatalogued Windows kernel driver from “suspicious import list” to a working arbitrary physical read/write proof-of-concept. The plan:
- Windows internals for driver hunters — this post. Everything you need to follow the hunt, written from scratch.
- Closing the call graph — static analysis, the first-pass misclassification, and the driver’s IOCTL surface.
- Building the PoC — from disassembly to working code.
- Dynamic proof — the read/write primitive and kernel CR3 recovery, live in a VM.
- From physical memory to SYSTEM — page-table walking, token theft, mitigations.
- Disclosure and wrap-up — CVE history, the reseller rebrand angle, and what this means for defenders.
This post is the primer. Later parts link back here instead of re-explaining the foundations.
Bring Your Own Vulnerable Driver
I always wanted to find a zer0-day :) and a few months ago I started a personal project I’d been putting off for too much time: finding signed Windows kernel drivers that expose a kernel read/write primitive to user mode and haven’t been catalogued as known-bad yet. The motivation is straightforward, BYOVD attacks have been a staple of APT toolkits for years, and the public known-bad lists (LOLDrivers and the Microsoft Vulnerable Driver Blocklist) are necessarily reactive. If you can run a hunt proactively, before the driver shows up in a threat report, you find things worth disclosing.
Before getting to the driver itself, some foundational knowledge.
Windows Internals Primer
CPU Privilege Rings
Modern x86-64 CPUs enforce hardware-level isolation through privilege rings, numbered 0 to 3.
This rings concept was introduced by Intel with the 80286 processor in 1982, as a hardware answer to the question “how do you stop a program from crashing the entire machine?” The 80386 in 1985 added 32-bit protected mode and the paging model that modern operating systems still use. Rings 1 and 2 were defined but never widely adopted, OS designers found that using only rings 0 and 3 was simpler, and that rings 1/2 offered no practical benefit for the software models they were building. Both the Windows NT kernel (1993) and Linux made this choice from the start, and every major x86 OS since has followed it.
When x86-64 (AMD64) arrived in 2003, it kept the ring model intact: the same mechanism of encoding the current privilege level in the low two bits of the CS segment register, the same privilege checks, just with 64-bit addresses and a few new features like SYSCALL. Today, Intel and AMD continue to support all four rings in hardware; the OS simply never uses 1 or 2.
ARM, which powers iPhones, most Android phones, and Apple Silicon Macs, uses a different term for the same idea: Exception Levels (EL0 to EL3). EL0 is user mode, EL1 is the OS kernel, EL2 hosts a hypervisor (like Hyper-V or Apple’s secure enclave), and EL3 is the most privileged level used by the firmware. The concepts are symmetrical to x86 rings, lower numbers are less privileged, transitions are of course controlled but the instruction set and register names differ. On Apple M-series chips, the macOS kernel runs at EL1 and user applications at EL0, exactly mirroring the Windows ring 0/ring 3 model.
As we said, Microsoft Windows for Intel CPUs only uses ring 0 and ring 3, let’s add a few more details:
Ring 0 (kernel mode)
The OS kernel, drivers, and hardware abstraction layer. No restrictions: full access to all physical memory, all privileged instructions (WRMSR, LGDT, CLI, HLT…), any I/O port, any register. One bug here crashes the whole machine.
Ring 3 (user mode)
Every user process: your browser, cmd.exe and, why not, malware. Restricted: cannot issue privileged instructions, cannot directly access physical memory, cannot read or write kernel memory.
The Current Privilege Level (CPL) is stored in the low two bits of the CS segment register and tells the CPU which ring is currently executing. It is not something software can set freely; it changes only through the controlled hardware mechanisms described below.
How does code actually get into ring 0?
There are exactly three hardware-sanctioned pathways from ring 3 into ring 0:
System calls (
SYSCALL/SYSENTER). When a user-mode program calls a Windows API likeReadFileorVirtualAlloc, the call eventually reaches a thin stub inntdll.dllthat loads a syscall number intoEAXand executes theSYSCALLinstruction. The CPU then reads the kernel entry-point address from a special CPU register calledIA32_LSTAR. You might now ask: what isIA32_LSTAR? MSRs (Model-Specific Registers) are a family of CPU registers addressable by a 32-bit index, readable and writable only from ring 0 using theRDMSR/WRMSRinstructions. They store per-CPU configuration that does not fit into general-purpose registers: power management settings, performance counters, and, relevant here, the syscall entry point.IA32_LSTAR(index0xC0000082) holds the 64-bit virtual address the CPU jumps to whenSYSCALLis executed. Windows writes the address ofKiSystemCall64intoIA32_LSTARonce during boot; the value stays fixed until the next reboot.KiSystemCall64is a real function insidentoskrnl.exe, the Windows kernel binary. It is the entry point for every 64-bit system call on the machine. It validates the syscall number, switches to the kernel stack, saves the caller’s register state, and dispatches to the correct handler. Crucially: you can only land here viaSYSCALL; you cannot call it or any other kernel function directly from user mode. You can only invoke functions the kernel explicitly exposes through its syscall table.Interrupts and exceptions. Hardware events (a timer tick, a keyboard press, a page fault) and software-triggered exceptions cause the CPU to consult the Interrupt Descriptor Table (IDT), a fixed table in memory with 256 entries, one per interrupt or exception number (0 to 255). Each entry is 16 bytes and contains the virtual address of the handler function plus permission flags. The location of the IDT is stored in the
IDTRregister (loaded by theLIDTprivileged instruction). When interrupt 14 (page fault) fires, the CPU reads entry 14 of the IDT and jumps to the handler: in Windows, the function isKiPageFault. These are fixed entry points defined by the OS. You can’t control where the CPU goes.Kernel drivers. This is the only pathway that lets your code run in ring 0. A
.sysfile loaded by the kernel is mapped directly into kernel address space and executes with CPL=0. Because CPL=0, every privileged instruction the CPU checks (WRMSR,LGDT,CLI,IN/OUT, reads of CR3) passes the privilege check and executes normally. There is no kernel wrapper, no permission table, no control: the driver’s code is in ring 0 and the hardware does not restrict it further. This is why BYOVD attacks work: instead of trying to break into ring 0 through the controlled pathways above, you find a legitimate driver that is already there, and you send it an IOCTL request that tricks it into doing your bidding on your behalf. The driver does the ring-0 work; your user-mode application, which runs in ring-3, controls it through the device object.
Every file read, memory allocation, and printf funnels through a SYSCALL, and it is worth following that detail. Saving state, switching to another stack, changing privilege: these are the same building blocks the CPU uses on every transition between user mode and the kernel, and the vocabulary, registers you will meet again in the page-table sections, comes up throughout the post. Here is what happens, step by step, when user-mode code executes SYSCALL:
The CPU reads the kernel entry-point address from
IA32_LSTARand saves the currentRIP(the instruction afterSYSCALL) intoRCX, andRFLAGSintoR11. It clears certainRFLAGSbits specified by theIA32_FMASKMSR (index0xC0000084): for example, clearing the interrupt flag so the kernel starts with interrupts disabled.The CPU loads new values into
CSandSSfrom theIA32_STARMSR (index0xC0000081). On x64,CS(Code Segment) andSS(Stack Segment) are segment registers, a legacy from 16-bit x86. In 64-bit long mode, segments no longer define memory ranges (all segments cover the full 64-bit space), butCSstill matters for one critical reason: its low 2 bits are the Current Privilege Level. Loading a kernel-modeCSselector changes CPL from 3 to 0. That is the exact moment the transition to ring 0 occurs.SSis loaded simultaneously to point to the kernel data segment.IA32_STARbits 47:32 hold the kernelCS/SSselectors; bits 63:48 hold the user selectors used bySYSRET.The CPU has CPL=0 at this point but is still running on the user-mode stack.
KiSystemCall64immediately switches to the kernel stack. Every Windows thread has its own kernel-mode stack (~16 KB) allocated at thread creation. The kernel keeps a per-CPU data structure called the KPCR (Kernel Processor Control Region) always accessible via theGSsegment in kernel mode.KiSystemCall64readsGS:[0x188](on x64 Windows that isKPCR.Prcb.CurrentThread, a pointer to the currently executing thread’sKTHREAD), then loads the kernel stack address fromKTHREAD+KernelStack_offset(a build-specific offset withinKTHREAD;0x58on recent Windows 11 builds) intoRSP, completing the stack switch.KiSystemCall64, the kernel dispatcher, is now running on the kernel stack. It saves all general-purpose registers (RAX, RBX, RCX … R15), the saved user RIP (from RCX), and the saved RFLAGS (from R11) into a structure called theKTRAP_FRAMEon the kernel stack. TheKTRAP_FRAME(~0x190 bytes) is a complete snapshot of the CPU state at the moment the user-mode thread was interrupted. It is how the kernel knows exactly where to resume user-mode execution after the syscall returns.With the
KTRAP_FRAMEsaved, the dispatcher reads the syscall number fromEAX, validates it against the syscall table, and calls the corresponding kernel function. When the function returns,SYSRETexecutes: the CPU restoresRIPfromRCX,RFLAGSfromR11, loads user-modeCS/SSfromIA32_STAR, and CPL returns to 3.
The entire transition takes on the order of 100 to 300 CPU cycles on modern hardware. Nothing about user-mode register state survives unexamined into the kernel: the dispatcher validates everything before using it.
Why ring 0 means God Mode from the security perspective
A ring-0 code execution or an arbitrary ring-0 memory write lets an attacker modify any kernel data structure, disable security enforcement, silence EDRs, and own the machine entirely. Every Windows privilege escalation technique ultimately aims to get a write primitive in ring 0.
To understand why ring-0 writes are so powerful, and why a physical memory primitive achieves exactly that, we need to look at how memory is organised between the kernel and user-mode processes.
The Memory Abstraction: Virtual Addresses, Physical RAM, and the MMU
Every time your program reads or writes a variable, the address it uses is not a real hardware address. It is a virtual address, a fiction maintained by the CPU and the operating system. The real location of that data in physical RAM chips is managed invisibly, behind the scenes, by a hardware unit called the Memory Management Unit (MMU).
Here is the key distinction:
Physical address: the actual location of a byte on the RAM chips. Your computer’s RAM is, physically, a set of silicon chips soldered onto a PCB. Every byte on those chips has a unique number: byte 0, byte 1, byte 2… up to however many bytes are installed (say, 16 GB = ~16 billion bytes). That number is the physical address. There is one physical address space per machine, shared by all CPUs. When software wants to read byte number
0x00100000from RAM, the CPU puts that number on the memory bus and the DRAM controller fetches the byte from the corresponding chip location. Physical addresses are real and exactly fixed hardware coordinates.Virtual address: the address a running program uses. Instead of interacting with physical addresses directly (which would mean any program could read any other program’s data, and the OS could never keep secrets from applications), the CPU introduces a layer of indirection. Every program operates in its own virtual address space: a private map of addresses that exists only as a concept, translated to real physical addresses on the fly. Two processes can both have a variable at virtual address
0x1000, and they will be referring to completely different physical bytes, because they each have a different translation map. The kernel has its own virtual address space too, and it keeps it entirely separate from the user address space, enforced in hardware.
Physical addresses are coordinates on the hardware: byte 0, byte 1, up to the last byte of installed RAM. Virtual addresses are what a process uses; the MMU translates them to physical ones on every memory access. Two processes can both use virtual address 0x1000 and refer to completely different physical bytes, because each has its own page table. The page table is that mapping.
Who creates the page table, and when?
The OS kernel’s memory manager creates and maintains page tables. When a new process is created, the kernel allocates a physical page to serve as the top-level page table for that process and fills in the kernel-space entries (copying them from the kernel’s own table so that kernel code and data appear at the same virtual addresses in every process). This kernel-space portion of every process’s page table contains two main kinds of mappings: the kernel image and its pooled data structures (ntoskrnl.exe, lsass.exe, driver code, kernel heaps, and the EPROCESS/ETHREAD objects for every running thread), and the direct physical memory map—a region where all of physical RAM is aliased as kernel virtual addresses at a fixed offset. The user-space portion starts empty (not-present). When the process attaches to the desktop and its first thread runs, the kernel uses a technique called copy-on-write for the kernel-space entries: it shares the kernel PML4 entries across all processes initially, and only creates private copies when the kernel needs to modify them (which is rare and always during controlled operations like loading or unloading a driver). When a new process is created, the kernel doesn’t walk the kernel’s page table entry by entry to copy them; instead, it takes a shortcut. It temporarily switches to the kernel’s address space by calling KiAttachProcess (which updates the current thread’s ethread pointer and loads the kernel’s CR3), performs the copy, then switches back. This works because the kernel’s own page tables are mapped under a special system address space that every process’s CR3 references while in kernel mode, so the attaching step is essentially just loading a different CR3 value.
User-space entries start empty (not-present). When the process’s first thread executes and touches a virtual address for the first time, the CPU finds a not-present entry, raises a page fault (#PF), and the kernel’s fault handler allocates a physical frame, updates the entry, and resumes the thread. The process never notices the interruption. This technique, allocating physical memory only when it is actually used, is called demand paging, and it is why a Windows machine with 16 GB of RAM can run dozens of processes that collectively claim to use 40 GB of virtual address space.
The CPU never puts virtual addresses on the memory bus. Every memory access (every instruction fetch, every variable read, every stack push) passes through the MMU, which translates the virtual address to a physical one using the page table. The CPU caches recent translations in a small hardware structure called the TLB (Translation Lookaside Buffer) so it does not have to walk the full table on every single access. On a context switch (switching from one thread to another in a different process), the OS loads the new process’s top-level page table address into CR3 register, which also invalidates the TLB, ensuring the new process cannot see the previous process’s translations.
How a page table entry works
Memory is managed in fixed-size blocks called pages (4 KB on x64, though larger sizes exist). The page table maps virtual page numbers to physical page frame numbers (PFN), one entry per page.
Why “page” vs “page frame”? The terminology preserves the conceptual split between virtual and physical. In virtual space, the 4 KB units are called pages: virtual concepts, slots in the address map. In physical space, the same-sized units are called frames: real chunks of RAM chips. A page table entry maps one virtual page to one physical frame. Using different names makes it immediately clear which side of the translation you are on: “page” = virtual, “frame” = physical.
Each page table entry on x64 is an 8-byte value with this layout:
Bit 63 NX (No-Execute) : if set, code cannot run from this page
Bits 51:12 PFN (Physical Frame) : the physical address of the page
Bit 8 G (Global) : keep translation in TLB across CR3 switches
Bit 6 D (Dirty) : set by CPU on first write
Bit 5 A (Accessed) : set by CPU on first read or write
Bit 4 PCD (Cache Disable) : bypass CPU cache for this page
Bit 3 PWT (Write-Through) : cache write-through policy
Bit 2 U/S (User/Supervisor) : 0 = ring 0 only; 1 = ring 3 accessible
Bit 1 R/W (Writable) : 0 = read-only; 1 = read+write
Bit 0 P (Present) : 0 = not in RAM (triggers #PF on access)
A real example: reading a PTE in WinDbg
The !pte command decodes the page table walk for any virtual address. Here is an example for a user-mode address in a running process:
0: kd> !pte 0x00007FF800000000
VA 00007ff800000000
PXE at FFFF93C7E3F000F8 PPE at FFFF93C7E3F01FF8 PDE at FFFF93C7E03FFC00 PTE at FFFF93C07FF80000
contains 0x0A000001D4F2B867 contains 0x0A000001D4F2C867 contains 0x0A000001D4F2D867 contains 0x8A00000003C50025
pfn 1d4f2b ---DA--UWEV pfn 1d4f2c ---DA--UWEV pfn 1d4f2d ---DA--UWEV pfn 3c50 ---DA--UW-V
!pte walks all four levels of the page table in one shot. Reading across each row, left to right:
- PXE — the PML4 entry
- PPE — the PDPT (Page Directory Pointer) entry
- PDE — the Page Directory entry
- PTE — the Page Table entry
The last column, pfn 3c50 ---DA--UW-V, is the actual page table entry for this address. !pte prints its flags as characters, one per bit, read from right to left:
| Flag | Meaning |
|---|---|
V | Present — the page is mapped (this is bit 0, P) |
W | Writable (bit 1, R/W) |
U | User-accessible (bit 2, U/S) |
A | Accessed — the CPU has read it (bit 5) |
D | Dirty — the CPU has written it (bit 6) |
The entry’s physical frame is PFN 0x3C50. A PFN (page-frame number) counts physical RAM in units of one 4 KB (0x1000) page, so the frame’s base byte address is:
0x3C50 * 0x1000 = 0x3C50000
A specific byte inside that page sits at:
0x3C50000 + (VA & 0xFFF)
where the low 12 bits of the virtual address are the byte offset within the page. Here the VA happens to end in ...000, so the offset is 0 and the frame base is the address you want.
To verify: db /p 0x3C50000 shows the physical bytes that user-mode code at this VA is reading from.
We’ll hand-walk this exact sequence ourselves in a later part of this series, armed only with CR3 and a physical R/W primitive.
For comparison, a kernel-mode PTE would show - instead of U (the User/Supervisor bit is 0), and db /p on its physical address would show the actual kernel data.
What is a page fault?
A page fault (#PF) is CPU exception number 14. It fires whenever the MMU cannot complete a memory translation:
- Not present (P=0): The page is not currently in RAM; it may have been swapped to disk, or it was never allocated. The OS fault handler allocates a frame and updates the PTE, then resumes the instruction transparently. This is demand paging.
- Protection violation: The access mode does not match the entry’s flags. A ring-3 write to a read-only page (R/W=0). A ring-3 access to a supervisor page (U/S=0). A write to a copy-on-write page. In all these cases the OS delivers
EXCEPTION_ACCESS_VIOLATION(0xC0000005) to the offending process. - Reserved bits set: A PTE has reserved bits set to non-zero, indicating table corruption; this triggers a fatal bug-check.
When #PF fires, the CPU automatically saves the faulting virtual address in CR2 and jumps to entry 14 of the IDT, which is KiPageFault in Windows, which calls MmAccessFault to decide what to do.
Why bother with all this? Several reasons:
Isolation. Each process gets its own page table. Process A’s virtual address
0x1000maps to a different physical frame than process B’s0x1000. Neither can read the other’s memory; the MMU enforces this boundary on every single access, in hardware, with no way for software to bypass it from ring 3.Page tables as a security boundary. The
User/Supervisorbit (bit 2) in every page table entry controls whether ring-3 code can access that page. Kernel pages have this bit set to 0 (supervisor only). If a user-mode process tries to read a kernel address, the MMU seesU/S = 0, raises a page fault (#PF, interrupt 14), and the OS exception handler delivers anACCESS_VIOLATIONto the process before the read completes. Here is the concrete sequence:1. User-mode code executes: mov rax, [0xFFFFF80012345678] ; kernel address 2. MMU walks the page table for that VA. 3. MMU finds the PML4 entry for VA[47:39]. The U/S bit is 0 (supervisor only). 4. CPU raises #PF. Execution jumps to KiPageFault in the kernel. 5. KiPageFault sees: fault address = kernel VA, faulting CPL = 3. 6. OS dispatches EXCEPTION_ACCESS_VIOLATION (0xC0000005) to the process. 7. If unhandled, the process crashes. The kernel is unaffected.
A natural question: if interrupt 14 is the same interrupt for every page fault, how does KiPageFault know why the fault happened? Because the CPU leaves a note. When a #PF fires, it writes the faulting virtual address into CR2, and it pushes an error code onto the stack (one of the few exceptions that does). Every bit of that error code names a cause: bit 0 says not present (0) vs. protection violation (1); bit 1 says write (1) vs. read (0); bit 2 says the access came from user mode (1) vs. kernel mode (0); bit 4 says the fault was an instruction fetch. So “user-mode tried to read a kernel address” parses out of the error code alone, and the saved CS value on the stack confirms which privilege level was executing. The interrupt is one shared doorbell, but the CPU always leaves a describable reason on the doorstep.
Two terms in that sequence need unpacking before we move on.
PML4 is the top-level page table on x64, the first of four levels the MMU walks to translate a virtual address. For now: the MMU always starts at the PML4, and checking the U/S bit of the PML4 entry alone is enough to block an entire 512 GB region (9 bits of VA index) of the virtual address space from user-mode access. All kernel addresses share PML4 entries with U/S=0, so the check fires at the very first step.
KiPageFault is the Windows kernel’s entry point for page faults (interrupt 14 in the IDT). It saves the CPU state, calls MmAccessFault (which decides whether to fix the mapping or deliver an exception), and either resumes the faulting instruction or unwinds into an exception dispatch. From a user-mode process’s perspective it is invisible: the process either gets its page mapped transparently, or it receives an ACCESS_VIOLATION signal and crashes.
The important detail: the kernel itself never even saw the attempted read. The MMU is a circuit integrated into the CPU die, not a software module. The address translation and the permission check happen in the CPU’s execution pipeline, in hardware, before the result is sent to the memory bus controller. The physical memory bus (the electrical connection between the CPU and the RAM chips) never sees the address. The RAM chips have no idea the access was attempted. The kernel data structure that was the target was never touched at the silicon level. The hardware stopped the operation mid-flight and raised an interrupt instead.
The OS can make non-contiguous physical frames appear contiguous in a process’s virtual address space. It can also swap pages to disk when RAM is full and load them back transparently; the process never knows. And it can map device MMIO into the virtual address space.
A few words on MMIO Hardware devices (GPU, network card, storage controller, USB host) need to communicate with the CPU. One mechanism is Memory-Mapped I/O (MMIO). Here is how it works end to end on a high-level perspective. Every PCIe device exposes a block of registers called Base Address Registers (BARs) in its PCI Configuration Space, a standardised 4 KB descriptor block that the CPU can read at boot via the PCIe configuration mechanism. The BARs tell the system “I need N bytes of physical address space for my internal registers.” During firmware initialisation (UEFI/BIOS POST), the firmware reads every device’s BARs, decides which physical addresses to assign to each device, and writes those assignments back into the BARs. Where do those ranges come from? The physical address space is not all RAM: the firmware keeps ranges aside for device registers, and it hands each device one of those non-RAM ranges, so the device’s registers and RAM never land on top of each other. The BARs it chose are then stitched into a single board-wide map called the memory map; the UEFI firmware describes the final layout to the OS via ACPI tables, specifically the MCFG and DSDT tables.
The result is a physical address map that is not all RAM. For example:
0x00000000 - 0xBFFFFFFF DRAM (your 3 GB of RAM below 4 GB)
0xFE000000 - 0xFEFFFFFF GPU MMIO registers ← device, not RAM
0xFEC00000 - 0xFEC00FFF I/O APIC registers ← device, not RAM
0x100000000 - 0x43FFFFFFF DRAM (remaining 13 GB above 4 GB)
Now, when the CPU writes to 0xFE000000, how does the write reach the GPU instead of RAM?The answer is in the chipset (on modern CPUs, integrated into the CPU package as the “uncore” or “PCH”). The chipset monitors the memory bus and acts as a traffic controller: if the physical address falls in a DRAM range, the write goes to the memory controller and reaches the RAM chips. If it falls in a device’s MMIO range, the chipset routes the write over the PCIe bus to that device instead. The device’s firmware sees it as a write to an internal hardware register at the corresponding offset within its BAR.
This is why device MMIO pages are mapped with the PCD (Cache Disable) bit set in the PTE: reading or writing device registers should always go to the hardware, not to a stale CPU cache copy. The MmMapIoSpace function that appears throughout our driver analysis maps exactly these ranges: physical addresses that correspond to device registers, not RAM, and it sets MmNonCached to ensure the CPU does not cache the access.
ddcdrv.sys uses HalTranslateBusAddress in one code path to convert a bus-relative DDC/I2C address to a physical address before mapping it. But, as we will see, it also has a raw-physical-address path that bypasses this translation entirely, which is the path we exploit.
Kernel vs. User Address Space
On x64 Windows the 64-bit virtual address space is split into two canonical halves:
0x0000000000000000 - 0x00007FFFFFFFFFFF user mode (128 TB)
(non-canonical gap: any access here raises #GP)
0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF kernel mode (128 TB)
Addresses are canonical when the top 16 bits are all-zero (user) or all-one (kernel). Every process shares the same kernel half; the kernel maps its own code, data, and all physical memory into the top half of every page table, invisible to user mode but present in the same address space. This is how a system call transitions to ring 0 without needing to switch address spaces.
The kernel image itself (ntoskrnl.exe) typically loads near 0xFFFFF80000000000
after KASLR randomization. Kernel heap, the PFN database, the kernel stacks, and every
EPROCESS / ETHREAD object reside somewhere in the kernel half; exact addresses
vary per boot.
How the Kernel Accesses Physical Memory
A kernel driver is a .sys file, a Windows executable that the OS loads directly into kernel address space and runs in ring 0. It can write to any physical address, call any kernel function, and issue any privileged CPU instruction. Drivers exist because hardware needs to be controlled from ring 0, and the OS cannot anticipate every piece of hardware that will ever be attached to a Windows machine, so it provides an extension mechanism.
In the x64 paging model, the User/Supervisor bit only restricts ring-3 accesses to supervisor pages. There is no restriction in the other direction: ring-0 code can access supervisor pages freely; those are its pages. The kernel maps its own code, heap, stacks, and every kernel object into the kernel half of the address space (virtual addresses above 0xFFFF800000000000), and CPL=0 means the MMU never blocks a kernel access to those pages.
More importantly: the Windows kernel maintains a direct map, a region of the kernel virtual address space where all of physical RAM is mapped, contiguously, with a fixed offset. On x64 Windows this region is sometimes called the physical memory view or the system address map; its virtual base is KASLR-randomised each boot (typically near 0xFFFFA00000000000 on recent builds). It means that for any physical address PA, there is a kernel virtual address VA = PA + DirectMapBase that aliases the same physical bytes. Ring-0 code can read or write any physical frame simply by computing this VA. This is how the kernel itself operates on physical memory internally. What makes an arbitrary physical R/W so powerful is that it essentially replicates this capability in ring 3, via a driver that maps the physical bytes into the user process’s address space. Once that mapping exists, the user-mode process can read and write physical RAM as freely as the kernel can, for as long as the mapping is held open.
The security assumption is that drivers come from trusted sources (hardware manufacturers) and are signed accordingly. What BYOVD breaks is that assumption: a driver written for a legitimate purpose (like controlling a monitor’s display settings) may have a bug that lets an attacker use it as a proxy inside the kernel. The driver is trusted, but because the driver runs in ring 0 and accepts instructions from user mode via IOCTLs, the distinction collapses. The standard protection model (supervisor bits, per-process page tables, ring-3 restrictions) operates entirely in the virtual address space.
In our case, the vulnerable driver bypasses it by mapping a Windows kernel object called \Device\PhysicalMemory.
Windows has an internal object namespace managed by the Object Manager, a kernel subsystem that tracks every kernel object (files, events, mutexes, processes, devices) under a hierarchical path, similar to a filesystem. \Device\ is a directory in that namespace where device objects live. \Device\PhysicalMemory is a special section object (a shareable memory region) that the kernel creates at boot to represent all of physical RAM. Unlike a file-backed section, its backing store is the physical address space itself.
A section object in Windows is a mappable entity: any process that has a handle to a section can map part of it into its virtual address space using ZwMapViewOfSection, getting a range of virtual addresses that directly aliases the section’s physical backing. For \Device\PhysicalMemory, that backing is physical RAM. Mapping offset 0x31100000 with PAGE_READWRITE gives you a user-mode pointer whose reads and writes go directly to physical address 0x31100000.
Normally, \Device\PhysicalMemory is protected: the Object Manager enforces a DACL on it that permits access only to the SYSTEM account and callers holding SePhysicalMemoryPrivilege. A standard user-mode process cannot open it directly. But a kernel driver running at ring 0, in the SYSTEM context can open it freely with ZwOpenSection(..., SECTION_ALL_ACCESS, ...). The Object Manager does not restrict ring-0 callers the same way.
This is the exact chain ddcdrv.sys uses. When you send it IOCTL 0x222030 with a physical address and a size, the driver calls ZwOpenSection on \Device\PhysicalMemory, then calls ZwMapViewOfSection with ProcessHandle = -1 (which means “the calling process”), SectionOffset = your physical address, ViewSize = your size, and Win32Protect = PAGE_READWRITE. The kernel creates a new virtual address range in your process’s address space that aliases exactly the physical bytes you specified, and the driver hands you the pointer. From that moment, you can read and write those bytes with a plain memory dereference (*(UINT64 *)va = value), and the write goes directly to physical RAM with no further checks.
The kernel’s own data structures (the EPROCESS object tracking your process’s identity, the access token determining your privileges, the code pages of ntoskrnl.exe itself) all occupy physical frames. With this mapping, you can address any of them.
In our case, this is what “arbitrary physical read/write” means: a user-mode-accessible window into the machine’s physical RAM, granted by a legitimately signed driver that was written to control a monitor’s display brightness.
Kernel Drivers and the Windows Driver Model
The Windows Driver Model (WDM) provides a framework for drivers to register with the I/O Manager and receive requests from user mode.
When a driver loads, the kernel calls its DriverEntry(PDRIVER_OBJECT, PUNICODE_STRING)
entry point. The driver uses this to:
- Create one or more device objects (
IoCreateDevice), which are kernel objects with a name in the object namespace (e.g.\Device\WinI2C). - Register a symbolic link (
IoCreateSymbolicLink) that exposes the device to user mode under\??\(e.g.\??\DDCHELPER→ accessible as\\.\DDCHELPER). - Populate the
DriverObject->MajorFunctiondispatch table with function pointers for each IRP type the driver handles:IRP_MJ_CREATE(file open),IRP_MJ_CLOSE,IRP_MJ_DEVICE_CONTROL(IOCTL), etc.
From that point, the driver sits at ring 0 and waits. A user-mode process that opens
\\.\DDCHELPER with CreateFileW and sends an DeviceIoControl call wakes the driver
up via the IRP_MJ_DEVICE_CONTROL handler.
IRPs and IOCTLs
The I/O Manager communicates with drivers through I/O Request Packets (IRPs), which are kernel heap allocations that describe a single I/O operation. When a user-mode process calls DeviceIoControl, the I/O Manager builds an IRP, fills in the control code and buffer pointers, and calls the driver’s IRP_MJ_DEVICE_CONTROL handler.
The driver reads parameters from the current IO stack location, a per-driver slot in the IRP containing:
IoControlCode - the IOCTL code (encodes device type, function, method, access)
Parameters.DeviceIoControl.InputBufferLength
Parameters.DeviceIoControl.OutputBufferLength
Parameters.DeviceIoControl.Type3InputBuffer (METHOD_NEITHER only)
The IOCTL code is a 32-bit value encoded as:
bits 31:16 DeviceType (0x22 = FILE_DEVICE_UNKNOWN for many third-party drivers)
bits 15:14 Access (0 = any, 1 = read, 2 = write, 3 = read+write)
bits 13:2 Function (driver-defined code)
bits 1:0 Method (0=BUFFERED, 1=IN_DIRECT, 2=OUT_DIRECT, 3=NEITHER)
METHOD_BUFFERED (method = 0) is the simplest and most common in vulnerable drivers.
The I/O Manager allocates a single kernel buffer of max(InputLength, OutputLength)
bytes, copies user input into it, hands it to the driver as
IRP.AssociatedIrp.SystemBuffer, and, after the driver returns, copies it back to the
user output buffer. Input and output share one allocation.
EPROCESS and the Token Model
Every running process on Windows is represented by a kernel object called EPROCESS, a large structure (several kilobytes) in non-paged kernel memory that tracks the process’s virtual address space, handle table, thread list, and security context.
The security context is the access token, pointed to by EPROCESS.Token. The token encodes what the process is allowed to do (privileges like SeDebugPrivilege and SeTcbPrivilege), which groups it belongs to including the Mandatory Integrity Label (low, medium, high, system), and the primary user SID.
The classic LPE technique is token stealing: find the SYSTEM process’s EPROCESS, read its token pointer, and overwrite the current process’s token pointer with the SYSTEM token. The result: the current process runs as SYSTEM. No credential needed, no service exploit; just a ring-0 memory write to a single pointer.
This is why EPROCESS and the token are the primary targets in most BYOVD exploitation chains: they are the exact structures that determine “who are you” in the Windows security model, and they live at predictable offsets within kernel heap objects.
The Three Security Gates: DSE, WDAC, and HVCI
Three distinct mechanisms sit between an attacker and a running kernel driver. They are commonly conflated but do different things:
DSE: Driver Signature Enforcement.
A kernel policy (enforced since Vista x64) that requires every kernel driver image to carry a valid Authenticode signature from a certificate that chains to a trusted root. Controlled by the CI.dll kernel component. Can be bypassed by: loading in debug mode (bcdedit /debug on), test-signing mode (bcdedit /set testsigning on), or, the BYOVD angle, loading a legitimately signed but vulnerable driver and exploiting it from user mode. DSE validates the signature; it
does not audit the driver’s behavior.
WDAC: Windows Defender Application Control.
A code-integrity policy engine that can specify, per hash or per signer, which drivers and executables are allowed to run. The Microsoft Vulnerable Driver Blocklist is a WDAC policy that lists known-bad driver hashes. If a driver’s hash is in the blocklist and WDAC is enforcing it, the driver will not start; sc start returns an error. This is the only gate that can block a signed
but vulnerable driver by hash.
HVCI: Hypervisor-Protected Code Integrity.
Runs the CI policy checks inside the Hyper-V hypervisor, running in VSM (Virtual Secure Mode), so the kernel cannot modify them even after being compromised. HVCI also places the kernel’s executable pages under SLAT (second-level address translation) protection, making them read-only from the kernel’s perspective. Critically: HVCI restricts \Device\PhysicalMemory mappings of arbitrary frames, which is precisely the primitive ddcdrv exposes. HVCI is the meaningful mitigation against this class of attack; DSE and WDAC without HVCI do not prevent the physical mapping from being established.
The ddcdrv driver passes DSE (valid GlobalSign signature with embedded timestamp), is not on the WDAC blocklist (confirmed empirically by loading under an enforced WDAC policy), and the test environment has HVCI off, so all three gates are either passed or absent.
KASLR and how to bypass It
KASLR randomizes the kernel image base at every boot. You cannot assume nt!PsInitialSystemProcess lives at the same virtual address twice in a row. KASLR bypass techniques usually rely on information leaks — kernel addresses chopped out of NtQuerySystemInformation outputs, handle table sprays, or side-channels. But our physical R/W primitive plus CR3 recovery is a cleaner path: once you know CR3, you can walk the kernel’s own page tables to resolve any kernel virtual address to its physical frame, on every boot, without knowing the KASLR slide in advance.
But first: why does CR3 give you this power? Every x86-64 processor has a register called CR3 that holds the physical address of the PML4 table — the top-level entry in the four-level radix tree the MMU uses to translate virtual addresses to physical ones. CR3 is not a virtual address; it is a physical address baked into the CPU by the kernel. That means KASLR cannot change it and every process on a given core must agree on its value when executing kernel code. When a process’s thread runs, the kernel loads CR3 pointing to the kernel’s PML4 (all processes share the same kernel page tables), and the CPU begins translating kernel virtual addresses by walking that PML4. Recover CR3 from user mode, and you now hold the copying master key to the kernel’s entire page-table structure. With it, you can resolve arbitrary kernel VAs to physical frames using only your physical read primitive — and write to them too.
Integrity Levels and the DACL Finding
Windows adds a second access-control layer on top of the traditional SID-based DACL (Discretionary Access Control List) — a list of Access Control Entries (ACEs) that specifies which users or groups can access an object and what operations they’re allowed (read, write, execute) — : Mandatory Integrity Control (MIC). Every object and every process token carries an integrity label (low, medium, high, system). A process at medium integrity (a normal user-mode process) cannot write to objects labelled high, regardless of DACL permissions.
UAC filtering further restricts administrators: when an administrator logs in, they get
two tokens: a full admin token (high integrity) and a filtered token (medium integrity,
with the Administrators group as deny-only). Standard CreateProcess uses the filtered
token. The filtered token cannot open objects whose DACL grants access only to
Administrators.
The ddcdrv device was opened successfully from a medium-integrity filtered token, meaning
the device’s DACL grants access to a SID that the filtered token carries without
restriction: Everyone, BUILTIN\Users, Authenticated Users, or INTERACTIVE). That
is the “any-user → kernel” finding. In the BYOVD threat model the attacker normally
needs admin to load the driver; but once the driver is loaded, the DACL determines who
can use it. A permissive DACL means any process on the system, including one spawned
by a low-privilege web server or a sandbox escape, can issue the IOCTL.
What’s next
The foundations are laid. The next part starts the hunt itself: how a first pass misclassified the driver as a harmless bus-translate path, and what closing the call graph actually revealed — an IOCTL that maps arbitrary physical memory into a caller’s address space with no bounds check.