Building a Scalable Windows Driver Vulnerability Analyzer (Part 3): From One Driver to 1,775

In Part 1, I built a pipeline to ingest and classify tens of gigabytes of Windows drivers. In Part 2, I ran it at scale and found the initial results underwhelming. IOCTLance found bugs, but understanding what those bugs meant required more context than symbolic execution alone could provide. I shifted to ecosystem-level analysis with AutoPiff, tracking how driver families evolve, who patches silently, and who ships decade-old SDK code.

At the end of Part 2 I said the next step was “accelerating static analysis for larger driver volumes.” This post covers what happened when I tried to do that, and a detour through a single anti-cheat driver that changed my research direction.

The CodeMachine Bootcamp

About a year ago, around the same time I was building the Part 1 pipeline, I was also working through a Windows kernel internals bootcamp with CodeMachine. After the course, I wanted to practice what I had learned, so I picked a real signed driver and tried to build a local privilege escalation chain from it. The idea is called BYOVD (Bring Your Own Vulnerable Driver): an attacker drops a legitimately signed driver with known weaknesses onto a target machine, loads it, and uses its vulnerabilities to gain kernel-level access. It’s a real technique used by ransomware groups and APTs because Windows trusts any driver with a valid signature.

I picked SeasunProtect.sys, an anti-cheat driver for Snowbreak: Containment Zone. WHQL-signed by Microsoft. Listed on LOLDrivers with a CVSS 8.8. LOLDrivers is a community-maintained catalog of known vulnerable and malicious drivers, similar to what LOLBAS does for living-off-the-land binaries. It imported all the right APIs: ZwOpenProcess, ZwDuplicateObject, KeStackAttachProcess, PsLookupProcessByProcessId. On paper, this looked like a straightforward target. Open a process, duplicate a handle, read kernel memory, escalate.

I spent weeks reversing it. I did manage to weaponize it. But not for privilege escalation.

SeasunProtect.sys: What It Actually Does

SeasunProtect.sys is 24KB. It creates a device at \\Device\\SEASUNRW64 with a usermode-accessible symbolic link. It has 5 IOCTLs, all METHOD_OUT_DIRECT with FILE_READ_ACCESS, and an ObRegisterCallbacks handler that strips handle permissions from processes trying to access the game.

A quick note on I/O methods, since they come up throughout this post. When a usermode program sends an IOCTL to a kernel driver, Windows can transfer the data in three ways: METHOD_BUFFERED (the kernel copies the input/output into a safe intermediate buffer), METHOD_IN_DIRECT / METHOD_OUT_DIRECT (the kernel locks the user’s memory pages and creates a safe descriptor for the driver to use), and METHOD_NEITHER (the driver gets raw user-mode pointers with no kernel safety net). METHOD_NEITHER is the most dangerous from a security standpoint because the driver must validate the pointers itself, and most don’t.

Here’s the IOCTL dispatch table I recovered through Ghidra headless decompilation:

IOCTLHandlerWhat It Does
0x2355DEinlineStore a user-supplied PID in a kernel global. This sets which process the anti-cheat protects
0x235582FUN_140001becFUN_140001fe8Open target PID with PROCESS_ALL_ACCESS, iterate handle table via ZwDuplicateObject, store matching PIDs in a kernel array
0x235586FUN_140001db0FUN_14000161cFilter the stored PIDs. For each one, open with PROCESS_ALL_ACCESS, duplicate handles, query object names via ZwQueryObject
0x23558EFUN_140001d3cFUN_14000182cSame handle enumeration pattern but checks object type info
0x235596FUN_140001e84FUN_1400022f0FUN_140001c38PsLookupProcessByProcessIdKeStackAttachProcess → walk PEB InMemoryOrderModuleList → pattern match module names

Every IOCTL follows the same structure: take a PID from the input buffer, do something in kernel context, store results in kernel globals. The return to usermode is a single integer via IoStatus.Information, a PID value or a count.

The critical detail is in the handle lifecycle. Look at FUN_14000161c, the core handle enumerator:

iVar2 = ZwDuplicateObject(local_498, uVar3 * 4,
0xffffffffffffffff, // NtCurrentProcess
&local_4a0);
if (-1 < iVar2) {
// ... query object info ...
ZwClose(local_4a0); // <-- always closed
}

Every handle the driver opens from kernel mode gets closed before the IOCTL returns. The duplicated handles go into the System process handle table, get queried for metadata, and get destroyed. Nothing leaks to usermode.

The dispatch function completes the IRP with:

*(ulonglong *)(param_2 + 0x38) = uVar6; // IoStatus.Information = a PID or count
*(int *)(param_2 + 0x30) = (int)uVar5; // IoStatus.Status
IofCompleteRequest(param_2, 0);

No MDL buffer writes. No returned handles. Just an integer.

What I Could Weaponize

I couldn’t build a privilege escalation chain, but I could weaponize the anti-cheat bypass.

ObRegisterCallbacks is Windows’ mechanism for kernel drivers to intercept handle operations. When any process tries to open a handle to another process, registered callbacks can inspect and modify the requested access rights before the handle is granted. Anti-cheat and security software use this to prevent debuggers and cheating tools from getting full access to their protected process.

The SeasunProtect callback at FUN_1400010d0 strips PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD (mask 0x838) from handles opened to the protected PID. The protected PID is stored in DAT_140006248, the same global that IOCTL 0x2355DE writes to.

The attack:

  1. Send IOCTL 0x2355DE with PID 4 (System) as the new target
  2. The game process is no longer protected by ObRegisterCallbacks
  3. Any process can now open the game with full permissions

I built a PoC that exercises all 6 IOCTLs, deployed it to a test VM (Windows Server 2022, test-signing enabled), and captured the output:

============================================================
SeasunProtect.sys - BYOVD Process Oracle PoC
Driver: WHQL-signed by Microsoft (Chengdu Westhouse)
Device: \Device\SEASUNRW64
============================================================
[Phase 1] Driver Installation
[+] Service created
[+] Driver started
[Phase 2] Opening Device
[+] Device handle acquired: 0x00000000000000fc
[Phase 3] Kernel-Directed Process Surveillance
Demonstrating that usermode can direct the kernel driver
to open PROCESS_ALL_ACCESS handles and enumerate handle
tables of ANY process — including protected ones.
--- Target: lsass.exe (PID 664) ---
[IOCTL 0x2355DE] Set target PID = 664 (previous = 664)
-> Kernel global DAT_140006248 now points to lsass.exe
-> ObRegisterCallbacks now protects this PID
[IOCTL 0x235582] Kernel enumerate handles (ret=0)
-> Driver called ZwOpenProcess(664, PROCESS_ALL_ACCESS)
-> Iterated handle table via ZwDuplicateObject
-> Stored matching PIDs in kernel global array
[IOCTL 0x235586] Filter handles (next_idx=50)
-> Checked 50 PIDs from kernel array against target
-> Each check: ZwOpenProcess + ZwDuplicateObject + ZwQueryObject
[IOCTL 0x23558E] Check handle types (matching_pid=0)
-> No processes with matching handle types found
[IOCTL 0x235596] Module walk (matching_pid=0)
-> No processes with matching modules
[IOCTL 0x235622] Full scan (result_pid=0)
[ ... same pattern for csrss.exe, services.exe, svchost.exe, winlogon.exe — all accepted,
all returned matching_pid=0 because the pattern matching is game-specific ... ]
[Phase 4] Anti-Cheat Bypass Demonstration
The driver's ObRegisterCallbacks (FUN_1400010d0) strips
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD
from handles opened to the protected PID.
[1] Protected PID set to lsass.exe (664)
[2] Redirected protection to PID 4 (System)
Previous protected PID was: 4 (lsass.exe)
[!] lsass.exe is NO LONGER protected by ObRegisterCallbacks!
Any process can now open it with full permissions.
[*] OpenProcess returned NULL (err=5) — expected if PPL is active
Note: On Server 2022, lsass may have RunAsPPL enabled
The ObRegisterCallbacks bypass still works but PPL is separate
============================================================
SUMMARY
============================================================
Driver: SeasunProtect.sys (WHQL-signed, 24KB)
Device: \\Device\\SEASUNRW64 (user-accessible)
Demonstrated capabilities:
[1] Set arbitrary target PID from usermode
[2] Trigger kernel-level handle table enumeration
(ZwOpenProcess with PROCESS_ALL_ACCESS)
[3] Filter processes by handle patterns from kernel
(ZwDuplicateObject + ZwQueryObject from kernel)
[4] Walk process modules via KeStackAttachProcess
(bypasses usermode protections)
[5] Redirect ObRegisterCallbacks protection
(anti-cheat bypass)
KDU assessment: DKOM/DumpProcess capable (not MapDriver)
Missing for full kernel compromise: physical memory R/W
============================================================
[Cleanup] Device handle closed
[+] Driver service stopped and deleted
[+] PoC complete.

A few things worth noting from the output. Every IOCTL succeeded — the driver accepted our requests to open PROCESS_ALL_ACCESS handles to lsass, csrss, services, svchost, and winlogon from kernel mode. But the handle enumeration IOCTLs all returned matching_pid=0 because the pattern matching logic is hardcoded for the game’s specific module names. The driver does what we tell it to in the kernel, stores the results internally, and only returns an integer.

The anti-cheat bypass in Phase 4 is the real finding. After redirecting ObRegisterCallbacks protection from lsass to PID 4 (System), the driver confirms lsass is unprotected. Our OpenProcess call still fails with error 5 (ACCESS_DENIED) because Server 2022 has RunAsPPL enabled on lsass — PPL is a separate protection mechanism from ObRegisterCallbacks. On a system without PPL (most gaming machines), this bypass would give full handle access to the previously protected process.

The evidence points to a driver more useful for anti-cheat bypass than kernel exploitation, but that’s based on what the decompiler can show me. More on what it can’t show in Limitations.

From SeasunProtect to DriverAtlas

Here’s where the two threads converged: the CodeMachine practice and the Part 1/2 pipeline.

After spending weeks on SeasunProtect, I started thinking about the other 1,774 drivers on LOLDrivers. Some of them must be easier. The dataset has everything from diagnostic utilities that map raw physical memory to anti-cheat drivers with complex handle management. But manually reversing each one would take years.

The Part 1 pipeline (MWDB + Karton + IOCTLance) was built for vulnerability discovery: find bugs, tag them, store results. What I needed now was different. I wasn’t looking for bugs. I was asking: if I wanted to use this driver for BYOVD, how hard would it be? What primitives does it actually expose through its IOCTLs?

IOCTLance helped with Part 1’s dataset, but it uses symbolic execution, which is slow and struggles with large drivers. AutoPiff from Part 2 was great for tracking how drivers evolve over time, but it compares versions of the same driver, not scoring individual drivers for weaponisability.

So I built DriverAtlas, a new pipeline focused on the question SeasunProtect raised: what’s the path from DeviceIoControl() to a dangerous API, and how clean is it?

What DriverAtlas Does

If you’re doing driver research, whether for a pentest, a course, or a vulnerability disclosure, the core workflow is straightforward: you have a .sys file and you want to know what it exposes. DriverAtlas automates that assessment in two passes.

DriverAtlas analysis pipeline diagram showing Driver Binary flowing through Tier 1 PE Analysis, Tier 2 Ghidra Analysis, KDU Scoring, and KDU Action Assessment
Figure 1: The DriverAtlas analysis pipeline. Each driver passes through increasingly deep analysis tiers.

Tier 1 runs in seconds per driver. It parses the PE binary to extract imports, device names, IOCTL codes, I/O methods, mitigations (ASLR, CFG, GS, FORCE_INTEGRITY, NX), and classifies the driver framework (WDM, KMDF, minifilter, NDIS, etc.). It then runs 22 weighted scoring rules against these features to produce an attack surface score from 0 to 15. Drivers that import MmMapIoSpace with no ProbeForRead, expose a symbolic link, and have all mitigations off will score high. A minifilter with IoCreateDeviceSecure and stack cookies will score low.

This is the fast pass. It tells you what a driver can do based on its imports. But as SeasunProtect showed, imports alone don’t tell the full story.

Tier 2 runs Ghidra headless decompilation. It locates the IRP_MJ_DEVICE_CONTROL handler, extracts every IOCTL code and its handler function, then traces which dangerous APIs are actually called from each handler. It also runs heuristic taint analysis (tracking user-controlled input buffers to sensitive sinks like MmMapIoSpace or ZwOpenProcess), checks for security validations (ProbeForRead, SeAccessCheck, try/except), and scans for ROP/JOP gadgets in the .text section.

This is the slow pass. It tells you what a driver lets you do from usermode. A driver might import MmMapIoSpace but only call it from DriverEntry for internal initialization, never from an IOCTL handler. Tier 2 catches that distinction.

KDU scoring takes the Tier 2 results and maps each IOCTL-reachable API to the exploitation primitives that KDU needs. If a driver has MmMapIoSpace confirmed in an IOCTL handler, that’s a ReadPhysicalMemory + WritePhysicalMemory primitive. If it has MmGetPhysicalAddress in another handler, that’s VirtualToPhysical. Combine those and you have everything needed for MapDriver: loading arbitrary unsigned code into the kernel.

The output is a per-driver verdict: which KDU actions the driver supports, at what confidence level (“confirmed” if Tier 2 verified it, “likely” if only Tier 1 imports suggest it), and what’s missing.

For a researcher picking a target, the workflow is straightforward: run driveratlas scan on a .sys file or a directory of them, get a ranked table showing which ones expose the most dangerous primitives with the least validation. Instead of spending weeks on a SeasunProtect that turns out to be contained, you can quickly identify the WinFlash64s that are one IOCTL away from kernel R/W.

I ran both tiers against all 1,775 drivers on LOLDrivers. It took about 9 hours on Ghidra headless with 3 parallel instances.

The Weaponisation Spectrum

The Weaponisation Spectrum: 1,775 LOLDrivers scored from 15.0 (one IOCTL to kernel code execution) through 10.5 (confirmed but contained, like SeasunProtect) to 0 (no primitives exposed)

The results show a spectrum. Some drivers are practically handing you the keys. Others, like SeasunProtect, require real work and might not get you where you want to go.

To make the results actionable, I scored each driver against KDU’s provider requirements. KDU uses vulnerable signed drivers to load unsigned kernel code. A driver is “KDU-compatible” if it exposes memory primitives through its IOCTL handlers that an attacker can chain into kernel code execution.

KDU supports these actions, from most to least powerful:

  1. MapDriver: Load arbitrary unsigned code into the kernel (needs physical + virtual memory R/W)
  2. MapDriver (physical brute-force): Same, but uses only physical memory with PML4 brute-forcing (PML4 is the root of the x86-64 page table hierarchy; scanning physical memory for it lets you translate virtual addresses without kernel cooperation)
  3. DKOM: Direct Kernel Object Manipulation, e.g. hiding processes (needs virtual memory write)
  4. DSECorruption: Patch ci.dll!g_CiOptions to disable driver signature enforcement (g_CiOptions is the kernel global that controls whether Windows checks driver signatures at load time; flipping it lets you load completely unsigned code)
  5. DumpProcess: Read arbitrary process memory (needs process handle + virtual memory read)

The Trivial End: One IOCTL to Kernel Code Execution

At the top sit drivers where the IOCTL handler is a wrapper around MmMapIoSpace with no input validation. In Part 2 I showed drivers shipping DDK sample code with zero-security device objects. These sit in the same category: they expose raw hardware access through IOCTLs that any usermode process can reach.

WinFlash64.sys is signed by ASUS, has 133 IOCTLs, and 33 of them use METHOD_NEITHER (raw user pointers, no kernel buffering). Eleven IOCTLs call MmMapIoSpace directly: send a physical address and size, get back a mapped kernel pointer. Two more IOCTLs call MmGetPhysicalAddress for virtual-to-physical translation. Every single mitigation is off. No ASLR, no CFG, no stack cookies, no FORCE_INTEGRITY.

The exploitation path:

  1. Open \\.\WinFlash
  2. Send IOCTL 0x222024 with a virtual address, get the physical address back
  3. Send IOCTL 0x222000 with that physical address, get a mapped view of kernel memory
  4. Read/write anything

That’s it. No handle management to untangle, no pattern matching to bypass, no race conditions to hit. A KDU provider could be built around this driver in a day.

ComputerZ.sys is simpler: one IOCTL (0xE0000000, METHOD_BUFFERED) calls MmMapIoSpace with user-supplied parameters and zero validation. All 5 mitigations off. It also has a clean MSR read gadget at offset 0x12229: rdmsr; shl rdx, 0x20; or rax, rdx; mov qword ptr [r9], rax, useful for reading IA32_LSTAR to find the kernel base.

rtkio.sys from Realtek: IOCTL 0x80002000 calls MmMapIoSpace, no validation. The Ghidra deep dive flags it as mapping physical/locked pages to usermode with no IRP completion. All mitigations off.

These three drivers score 15.0/15.0 in DriverAtlas with 500+ ROP gadgets each. They could be turned into KDU providers with minimal effort.

Compare that to the weeks I spent on SeasunProtect, where the most I could get was an anti-cheat bypass.

The Middle: Confirmed but Requires Work

Below the trivially exploitable drivers sit 122 that have Ghidra-confirmed physical and virtual memory primitives in their IOCTL handlers. They’re MapDriver-capable but might need more work. Some have partial input validation, some use METHOD_BUFFERED (so you need to structure the input correctly), and some have secondary checks.

Another 157 have confirmed physical memory read/write but lack virtual memory access. These are still KDU-viable through PML4 brute-forcing: scan physical memory for the page table root, then use physical writes to modify page tables directly. Slower and noisier, but it works.

75 more have confirmed virtual memory write primitives only. These can’t load unsigned drivers, but they can manipulate kernel objects (DKOM) or patch ci.dll!g_CiOptions to disable Driver Signature Enforcement.

The Hard End: SeasunProtect Territory

Then there are drivers like SeasunProtect. They import the right APIs, the IOCTLs reach dangerous functions, but the exploitation path from usermode to kernel primitive isn’t clean. The primitives are contained. The handles get closed. The results stay in kernel globals.

SeasunProtect scores 10.5/15.0. It has 500 ROP gadgets (91 stack-pivots), no GUARD_CF, no GS_COOKIE. But it has ASLR, NX, and FORCE_INTEGRITY enabled. Its IOCTL handlers don’t expose raw memory access.

For KDU compatibility, this puts it in DKOM/DumpProcess territory at best, not MapDriver. It can’t read or write physical memory, can’t map arbitrary kernel virtual memory, and can’t load unsigned code.

The Numbers

Results at a Glance — 1,775 drivers scored

The bottom three rows are worth explaining. “NEITHER I/O + confirmed physical memory” means the driver uses METHOD_NEITHER (raw user pointers, no kernel buffering) and has confirmed physical memory primitives in those handlers. That combination is the most dangerous: there’s no intermediate buffer between the attacker’s usermode process and the hardware. “All 5 mitigations off” means no ASLR, no DEP, no CFG, no stack cookies, and no FORCE_INTEGRITY. These drivers don’t just have exploitable IOCTLs, they have no exploit mitigations to work around either.

354 drivers have Tier 2 Ghidra-confirmed dangerous APIs in their IOCTL handlers. 1,050 more are “likely” because they import the right APIs but I haven’t confirmed IOCTL reachability. That gap is where false positives live. A driver that imports MmMapIoSpace for internal diagnostic logging but never exposes it through an IOCTL will show up as “likely” even though it’s not exploitable from usermode.

What This Means for Defenders

If you’re on a blue team, the 122 confirmed MapDriver drivers are the ones to prioritize. These have verified paths from a usermode IOCTL to physical and virtual memory access. An attacker with admin access and one of these .sys files can load unsigned kernel code in minutes.

The practical action: add these hashes to your WDAC (Windows Defender Application Control) driver block policies. Microsoft maintains a recommended driver block list, but it doesn’t cover all 122. The full hash list is in the KernelSight results. For detection, monitor for service creation events (sc create / NtLoadDriver) that reference driver filenames in the confirmed list. EDR products that flag known-vulnerable driver loads are your best early warning.

The 1,050 “likely” drivers are lower priority but shouldn’t be ignored. If your environment has a BYOVD incident and the driver isn’t in the confirmed 122, check whether it’s in the likely set and escalate accordingly.

How This Fits Together

Looking back at the three parts, each tool answered a different question:

Three Tools, Three Questions — each part built a different lens

They overlap in places, but the questions are different. A driver that scores 15.0 in DriverAtlas probably also has IOCTLance findings from Part 1, and a silently patched MmMapIoSpace caught by AutoPiff would show up as a Tier 2 confirmed primitive in DriverAtlas. Different questions, different tools.

Limitations

The tooling isn’t perfect, and I want to be direct about that.

Ghidra’s decompiler misses indirect calls and can’t resolve all function pointers. Some drivers use obfuscation or uncommon dispatch patterns that the IOCTL tracer doesn’t recognize. The 1,050 “likely” drivers where Tier 1 flags dangerous imports but Tier 2 can’t confirm IOCTL exposure represent a real limitation, not a rounding error.

SeasunProtect itself is an example. My analysis showed a contained process oracle with an anti-cheat bypass. Northwave scored it CVSS 8.8 for privilege escalation. There may be a path I couldn’t see, a race condition in the handle lifecycle, or something the decompiler mangled. I’m reporting what the tooling shows me, not claiming it’s the complete picture.

The scoring is one-dimensional. A driver that scores 10.5 might be harder to weaponize for MapDriver but trivial for a specific DKOM attack that the scoring model doesn’t weight heavily. The numbers are a triage tool, not a verdict.

What I Learned

Running DriverAtlas against every LOLDriver showed just how much variance there is. Some drivers are one IOCTL away from arbitrary kernel code execution with zero validation and zero mitigations. Others have the same imports but wrap them in layers of handle management and kernel-only storage that make exploitation genuinely hard. The import list alone doesn’t tell you which is which. You need to trace the path from DeviceIoControl() to the dangerous API, see what gets validated, what gets contained, and what leaks back to usermode. That’s the lesson SeasunProtect taught me, and it’s the question DriverAtlas was built to answer at scale.

For anyone doing their own driver research, whether for a course, a pentest, or a vulnerability disclosure, the pipeline is open source. Point it at a .sys file and it will tell you what it can see. Just remember that what it can’t see might be the interesting part.


The full analysis results are published on KernelSight. DriverAtlas is at github.com/splintersfury/DriverAtlas. Previous posts: Part 1 (pipeline design), Part 2 (ecosystem analysis).


Comments

Leave a comment