_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

the KASLR graveyard: every way i tried to leak ntoskrnl on Win11 25H2, and what killed each one

2026-07-14 · windowskaslrkernelamp.syscyrenntoskrnlexisrestrictedcallerdebug-privilegedesktop-heapgdtidtvmwarewilexploit-dev

For there is nothing covered, that shall not be revealed; neither hid, that shall not be known.

Luke 12:2 (KJV)

i had a signed driver with arbitrary kernel read and write. CYREN's amp.sys, shipped with their AV product, exposes \\.\AMP with an IOCTL (0x226003, METHOD_NEITHER, no ProbeForRead / ProbeForWrite) that gives unrestricted kernel memory access from any user process. code 5 with command 26 reads any kernel address into an internal buffer; code 5 with command 2 copies that buffer out to userspace. code 1 writes a DWORD of zero to any address. that's the read primitive, the write primitive, and the goal: NT AUTHORITY\SYSTEM on Windows 11 25H2.

the plan was simple. leak ntoskrnl.exe's base address, walk PsInitialSystemProcess to find System's EPROCESS, read its token, write it into my process's EPROCESS. standard token steal, same playbook that's worked since Windows 7. the read primitive handles everything except one thing: the kernel base address. i don't know it, and on 25H2, nothing will tell me.

this is the catalog of everything i threw at that problem. not in hindsight, not curated to look smart. in the order i tried them, with the actual error messages and crash dumps.

the target

Windows 11 25H2, build 26200.8655. VMware Workstation VM on an AMD Ryzen 9 5900X host. 16GB RAM. HVCI off. VBS off. Secure Boot off. UMIP off (confirmed: sidt and sgdt both work from CPL3). the VM is joined to no domain, no Defender tamper protection, no EDR. easy mode.

the user is noob, medium integrity, no admin, no special groups. amp.sys loads at boot via the CYREN AV install and creates \\.\AMP with a default DACL -- any user can open it.

what 25H2 changed

starting with 24H2, Microsoft added the KASLR Leaks Restriction. Yarden Shafir at Windows Internals wrote the definitive deep-dive on how it works internally and it's required reading if you want to understand the dispatch chain from NtQuerySystemInformation down through ExpQuerySystemInformation to the ExIsRestrictedCaller gate. the short version: the implementation is a function called ExIsRestrictedCaller that gets called from inside ExpQuerySystemInformation and every other NtQuery* dispatch path. it checks a WIL (Windows Implementation Libraries) feature flag named Feature_RestrictKernelAddressLeaks_private_IsEnabled. if the flag is on (and on 25H2 it is), it calls SeSinglePrivilegeCheck(SeDebugPrivilege, PreviousMode). if you don't hold SeDebugPrivilege, the function returns true -- meaning "yes, this caller is restricted" -- and the dispatch handler zeroes every kernel pointer in its output buffer before returning it to userspace.

i confirmed this in IDA. the gate DWORD for the feature flag lives at ntoskrnl RVA 0xFC3A88. on this build it holds 0x57 (enabled + cached). writing 0x00000000 to it via amp.sys disables the check for the current call. i verified: after the write, NtQuerySystemInformation(SystemHandleInformation) returns real kernel Object pointers instead of zeros.

but here's the catch. the gate re-arms itself. WIL's feature state is not a simple toggle -- it's a cached evaluation that the kernel re-runs periodically. i'd write zero, confirm zero, make one NtQuerySystemInformation call, get real pointers, and by the next call the gate was back to 0x57. or worse, it came back as 0x00040004, a different enabled state. writing zero doesn't stick. the feature table around the gate is full of 0x57 values -- 39 feature states packed into a table, all evaluated by the same WIL machinery.

so you get one call through, maybe. and then it closes. and you need the base address before you can write to the gate, because the gate is at base + 0xFC3A88 and you don't know base.

that's the catch-22. everything below is what i tried to get out of it.

attempt 1: NtQuerySystemInformation (the obvious one)

NtQuerySystemInformation takes a class number that tells it what info you want. the ones that matter for KASLR leaking (the full list is in phnt's ntexapi.h):

on 25H2, class 11 still succeeds (returns STATUS_SUCCESS), the buffer fills up with 196 module entries, but every ImageBase field is zeroed. the module names are there, the sizes are there, the addresses are gone.

i tried class 16 -- same deal. 35,000+ handles returned, every Object pointer zeroed. class 64 -- same. class 5 -- returns process info but no kernel addresses anywhere. i dumped the raw buffer and scanned every qword for the 0xFFFF high 16 bits that identify kernel pointers. zero hits across 2MB of data.

ExIsRestrictedCaller got them all.

attempt 2: NtQueryInformationProcess / NtQueryInformationThread

these take their own class numbers (documented in ntdoc's PROCESSINFOCLASS table and the thread equivalent). the ones that used to leak kernel addresses:

i tried every class from 0 to 50 on both APIs.

every class that historically returned kernel pointers now returns either STATUS_ACCESS_DENIED (0xC0000022) or succeeds with the kernel pointer fields zeroed. the restriction isn't just in NtQuerySystemInformation -- it's in every NtQuery* dispatcher that touches kernel addresses. ExIsRestrictedCaller is called from each one individually.

attempt 3: EnumDeviceDrivers (psapi)

EnumDeviceDrivers in psapi.dll was the classic KASLR bypass. it went through a different code path than NtQuerySystemInformation -- or so people thought. turns out on 24H2+, psapi's EnumDeviceDrivers calls NtQuerySystemInformation(SystemModuleInformation) internally. same dispatch, same ExIsRestrictedCaller gate, same zeroed output.

dead end. the only non-NtQSI path i could think of was direct device I/O, which is what amp.sys already provides.

attempt 4: sidt / sgdt (hardware registers)

the IDT and GDT live at known offsets from the kernel base. on older Windows, you'd run sidt from CPL3, read the IDT base address, walk the interrupt handler entries to find a function pointer into ntoskrnl, then page-walk backward to the MZ header. clean, no APIs, no privileges needed.

UMIP was off on this box, confirmed:

sidt → IDT base = 0xFFFFF80000001000
sgdt → GDT base = 0xFFFFF8534B590000

# both valid kernel addresses. both readable from CPL3. UMIP off confirmed.

but when i tried to amp_read32 the IDT base, the VM immediately bugchecked. PAGE_FAULT_IN_NONPAGED_AREA or ATTEMPTED_WRITE_TO_READONLY_MEMORY, depending on which address i hit first.

the IDT and GDT regions are not amp-readable. amp.sys uses METHOD_NEITHER -- it dereferences the target address directly in kernel mode with no ProbeForRead. if the page is mapped but has a protection mismatch, or if VMware's nested paging treats certain system-structure pages differently, the read faults. and since amp.sys has no exception handler around the dereference, the fault goes straight to the bugcheck handler.

i tried the GDT next, same result. immediate BSOD. seven crashes in total, each from a single amp_read32 call on a different address in the IDT/GDT region. the VM went into a crash loop twice because the auto-restart hit the same fault before i could SSH in.

both the IDT and GDT are always-mapped, per-CPU structures. the CPU itself dereferences them on every interrupt and segment load. they are absolutely in memory. but amp.sys can't touch them. probably because they're in a special page protection region that VMware's hypervisor enforces, or because Windows marks those pages with a protection that traps METHOD_NEITHER reads. either way: dead.

attempt 5: the desktop heap (heap_kbase)

the user-mode desktop heap mapping in the TEB contains a self-referential kernel pointer at offset +0x100: heap_kva = *(uint64_t*)(hub + 0x100). this pointer holds the kernel VA of the desktop heap. if you can read past the user-mapped portion into the kernel-only portion, the heap objects (tagWND, THREADINFO, etc.) contain raw kernel pointers to win32k and ntoskrnl.

i mapped the full 112KB user portion of the heap and scanned every qword. result: exactly one kernel pointer. the self-referential one at hub + 0x100. zero ntoskrnl pointers, zero win32k pointers.

then i used amp.sys to read the kernel-side copy at heap_kbase = heap_kva & ~0xFFF. result: byte-for-byte identical to the user mapping. the user mapping is a direct map of the same physical pages. there's no hidden unsanitized data on the kernel side. the 24H2 restriction (or a related change) already cleaned the heap objects.

the old technique of reading past the user mapping boundary (deep_heap) also failed: the heap is exactly as large as the user mapping. reading past it hits unmapped kernel addresses. the tagWND pti and pEThread pointers that used to live here as raw kernel addresses are gone.

attempt 6: KUSER_SHARED_DATA

KUSER_SHARED_DATA at 0xFFFFF78000000000 (kernel alias) / 0x7FFE0000 (user alias) is always mapped and amp-readable. i confirmed this:

amp_read32(0xFFFFF78000000000 + 0x330) = 0x75D3CAE3   # amp read works
*(uint32_t*)(0x7FFE0000 + 0x330)        = 0x75D3CAE3   # user mapping matches

i scanned the full KUSD structure (0xA80 bytes) for any kernel pointer. zero hits. the NtBuildNumber is there (0x6658 = 26200), the system root path is there, but no kernel addresses anywhere in the struct. Microsoft cleaned this one out long ago.

attempt 7: the gate crash-oracle (binary search via BSOD)

this was the ugly one. the gate DWORD is at ntoskrnl_base + 0xFC3A88. i know the RVA. i don't know the base. but ntoskrnl is large-page aligned (2MB boundaries, since the VM has >2GB RAM). so the base is one of a finite set of candidates: 0xFFFFF800'00000000 + N * 0x200000.

the idea: read the DWORD at each candidate base + 0xFC3A88. if it's 0x57 (or has bits 0x11 set), we found the gate. if the page is unmapped, amp.sys bugchecks. each wrong guess is one BSOD, one reboot, one more candidate eliminated.

i wrote a script that reads an index from a file, tries the Nth candidate, and increments the index for the next run. on each boot, i run the script, it tries one address, either finds the gate or crashes, and i reboot and try again.

idx 0  (base + 0x200000):  BSOD
idx 1  (base + 0x400000):  BSOD
idx 2  (base + 0x600000):  BSOD

# three reboots, three BSODs, crash loop. VM required manual recovery.

ntoskrnl loads somewhere in a range of about 512 possible 2MB-aligned addresses. that's 512 potential crashes, each requiring a reboot. at 90 seconds per crash-reboot cycle, that's 13 hours of crashing. theoretically possible. practically insane.

attempt 8: prefetch sidechannel (EntryBleed on Windows)

hackyboiz published a writeup showing that the Linux EntryBleed prefetch sidechannel (CVE-2022-4543) works on Windows too, because KVA Shadow is disabled by default on modern CPUs. the technique uses prefetchnta / prefetcht2 timing to determine which addresses are TLB-cached, scanning the entire kernel address space for the syscall entry point.

two problems. first, the writeup explicitly states: "reliable only on recent Intel processors. on AMD CPUs, results are inconsistent." this box is a Ryzen 9 5900X. second, this VM runs under VMware's nested paging (NPT/EPT), which flattens TLB timing. the prefetch instruction hits VMware's shadow page table, not the guest's TLB. the timing difference between cached and uncached addresses is dominated by VMware's hypervisor overhead, making the sidechannel noise floor higher than the signal.

i ported the prefetch-tool PoC from exploits-forsale and ran it. results: random noise. no consistent timing peaks. after 30 minutes of scanning, the tool reported zero candidates with sufficient confidence.

attempt 9: the admin oracle (SeDebugPrivilege from another account)

the restriction checks for SeDebugPrivilege. admins hold it in their token (disabled by default, but can be enabled). there was a second account on the box (exp, full admin) with SSH access.

the approach: SSH in as exp, enable SeDebugPrivilege, call NtQuerySystemInformation(SystemModuleInformation), read the ntoskrnl base, write it to a file. then switch back to noob and proceed with the amp.sys chain using the leaked base.

# as exp (admin):
[*] NtQSI(11=ModuleInfo): st=0x0 len=58024
[*] 196 kernel modules:
  [0] base=0xFFFFF805C0E50000  size=0x1450000  ntoskrnl.exe

[+] *** NTOSKRNL BASE = 0xFFFFF805C0E50000 ***

this worked. exp with SeDebugPrivilege got 196 module entries with real addresses. ntoskrnl.exe at 0xFFFFF805C0E50000. the entire KASLR problem, solved by a second account with admin rights.

is it a KASLR bypass from an unprivileged user? no. it's a KASLR bypass from an admin user, which was never restricted. but it got me the base address, which is what i needed.

what i learned

the KASLR Leaks Restriction on 25H2 is thorough. it's not a single check you can NOP -- it's per-dispatcher, per-call, re-evaluated by WIL. the gate DWORD can be poked for one call, but it re-arms. the hardware register approach (sidt / sgdt) is dead because the driver can't read those memory regions without faulting. the desktop heap is sanitized. KUSER_SHARED_DATA is clean. the prefetch sidechannel is neutralized by AMD + nested paging.

the restriction's real strength is that it removed every easy leak. the remaining options all require either a second primitive (physical memory, MSR access, a known-offset structure) or a second account. on a real target with no second account, you'd need either:

or you do what i did: get the address from somewhere else and move on. the token steal via PsInitialSystemProcess (RVA 0xFC5AF0) works perfectly once you have the base.

PsInitialSystemProcess (0xFFFFF805C1E15AF0) = 0xFFFFBA0E416E2040   # System EPROCESS (PID 4)
System Token (EPROCESS+0x248)                   = 0xFFFFA808382894B9   # _EX_FAST_REF

# ActiveProcessLinks walk: 119 entries. found our EPROCESS (PID match).
# amp_write0 our token → PreviousMode flip → NtWriteVirtualMemory the real token in.
# cmd.exe spawns. whoami: nt authority\system.

once you have the base, the rest is just offsets. UniqueProcessId at +0x1D0, ActiveProcessLinks at +0x1D8, Token at +0x248, PreviousMode at KTHREAD+0x232. i confirmed all of these against build 26200 and they're correct. the hard part was never the token steal. it was getting that first address.

and yeah, credit where it's due, the restriction works. i spent way longer than i should have on this. it used to be one NtQuerySystemInformation call and you were done. now you need either a second bug (physical memory, MSR access) or a second account. that's annoying, which means it's doing its job.

but the driver is still there. the gate can still be poked for one call. and i had an admin account on the box. one SSH session later i had the base and was done.

a nice double tap; kernel pointers are zombie land

i disassembled ExIsRestrictedCaller in IDA and pulled the raw bytes off the live kernel with the amp.sys read primitive. the function is more interesting than the public writeups let on. it's not one check, it's two, and they're independent:

// IDA decompilation of ExIsRestrictedCaller (ntoskrnl RVA 0xA105FC)
__int64 ExIsRestrictedCaller(KPROCESSOR_MODE a1, DWORD *a2)
{
    // check 1: PreviousMode bypass
    if (!a1)          // PreviousMode == KernelMode
        return 0;      // not restricted, skip everything

    // check 2: WIL feature flag + SeDebugPrivilege (fills *a2)
    if (a2 && Feature_RestrictKernelAddressLeaks_IsEnabled())
        *a2 = !SeSinglePrivilegeCheck(SeDebugPrivilege, a1);

    // check 3: SeAccessCheck against SeMediumDaclSd (determines return value)
    SeCaptureSubjectContext(&SubjectContext);
    success = SeAccessCheck(SeMediumDaclSd, &SubjectContext, ...);
    SeReleaseSubjectContext(&SubjectContext);

    return !success;   // TRUE if access denied
}

the first check is obvious: PreviousMode == KernelMode skips everything. if you're calling from kernel mode, you're not restricted. this is the PreviousMode bypass that the whole amp.sys chain was trying to exploit.

the second check is the one everyone knows about: the WIL feature flag Feature_RestrictKernelAddressLeaks plus SeSinglePrivilegeCheck(SeDebugPrivilege). if you don't hold SeDebugPrivilege, *a2 gets set to 1 (restricted). but this value only fills an output parameter that the caller reads separately. it doesn't determine the function's return value.

the third check is the one nobody talks about: SeAccessCheck against SeMediumDaclSd. this is what actually gates the return value. i pulled SeMediumDaclSd off the live kernel via amp.sys. it's a SECURITY_DESCRIPTOR with a NULL DACL (everyone has access) but a SACL with a mandatory label. the mandatory label enforces integrity level -- if it's set to high-IL, every medium-IL caller fails SeAccessCheck and the function returns TRUE (restricted).

so it's a belt-and-suspenders design. the WIL flag gates one thing (whether the output pointers get zeroed at the call site), and the SACL gates another (whether the function returns restricted at all). you'd need to defeat both to get raw kernel pointers back. the WIL flag re-arms itself when you poke the gate DWORD. the SACL is hardcoded in .rdata and can't be poked without the kernel R/W primitive, which needs the base address, which is what you're trying to leak.

kernel pointers are zombie land. you can shoot at them from every angle and they just keep coming back zeroed.

round 2: every other lane i could think of

after writing up the first nine attempts i went back and tried harder. fired everything i could think of from the unprivileged user.

brute-force NtQuerySystemInformation (classes 0-260)

scanned every SYSTEM_INFORMATION_CLASS from 0 to 260. all 261 classes. a few returned data. none had kernel pointers.

class  5 (ProcessInfo):   337KB, 0 kptrs
class 11 (ModuleInfo):    62KB, 0 kptrs   # ImageBase fields zeroed
class 16 (HandleInfo):    3.6MB, 0 kptrs   # Object fields zeroed
class 22 (PageFileInfo):  106KB, 0 kptrs
class 57 (ExtProcInfo):   479KB, 0 kptrs
class 64 (ExtHandleInfo): 6.1MB, 0 kptrs
class 66 (BigPoolInfo):   397KB, 0 kptrs   # VirtualAddress fields zeroed
class 234 (FeatureConfig): 17KB, 0 kptrs
class 241 (NumaPerf):     1MB, 0 kptrs     # ASN.1 blob, no pointers

also tried every class of NtQueryInformationProcess (0-100) and NtQueryInformationThread, plus NtQueryObject, NtQuerySection, NtQueryTimer, NtQueryEvent, NtQueryMutant, NtQuerySemaphore, NtQueryInformationToken, NtQueryInformationJobObject, NtQueryVirtualMemory, and NtSystemDebugControl. zero kernel pointers everywhere. the restriction covers every surface.

firmware tables (SMBIOS / ACPI)

GetSystemFirmwareTable lets any user read ACPI and SMBIOS tables. these are copied from physical memory by the kernel, so i thought maybe an address survived in the table data. queried every ACPI table (MCFG, FACP, SRAT, WAET, APIC, WSMT) and the full SMBIOS structure. 13KB of SMBIOS data, 768 bytes of ACPI tables. zero kernel pointers.

RPC endpoints (381 total)

enumerated all 381 RPC endpoints on the box. 150 unique interface UUIDs. identified the high-value ones: samr (SAM), lsarpc (LSA), spoolss (Print Spooler), svcctl (SCM), WinReg (Registry), EventLog, KeyIso (CNG Key Isolation), ProcessTag, StateRepository, AppIDSvc, LicenseManager.

335 of 381 accepted unauthenticated management queries (RpcMgmtIsServerListening returned RPC_S_OK). that tells you the server is alive, but it doesn't mean you can call methods. the actual method calls enforce auth: OpenSCManager works but EnumServicesStatusEx returns ACCESS_DENIED. OpenEventLog returns ACCESS_DENIED outright.

WTSEnumerateProcesses worked and returned 210 processes, but only PIDs and names. no kernel addresses.

crash dumps

the BSODs from the gate crash-oracle (attempt 7) left crash dumps on disk. C:\Windows\memory.dmp (679MB) and five minidumps in C:\Windows\Minidump\. i copied one with an admin account and read it.

PAGEDU64 signature confirmed

PsLoadedModuleList:   0xFFFFF801EADA52D0     # ntoskrnl module list head
PsActiveProcessHead:  0xFFFFF801EADB5B40     # process list head
KdDebuggerDataBlock:  0xFFFFF801EACB1040     # debugger data
PfnDatabase:          0xFFFFF801EAE76C78

the dump header contains four kernel addresses in the 0xFFFFF8 range. any one of them gives you the KASLR slide. this is a real leak.

but: those addresses are from the boot that crashed. after the reboot, KASLR re-randomized. and the dump files are ACL'd Administrators:(F) with a high mandatory label. an unprivileged user can't read them.

so it's not a live bypass. it's proof that the data exists on disk in cleartext, and if the ACLs are ever wrong (WER sometimes leaves dumps world-readable), or if you can read the dump through a side channel before the reboot finishes, you have the kernel base. the KASLR restriction sanitizes API responses but does nothing about crash dump files.

VMware backdoor (port 0x5658)

this is a VM-specific angle. the VMware hypervisor exposes a backdoor via I/O port 0x5658. on this VM, CPL3 IN to that port doesn't trap -- it goes straight to the hypervisor. the backdoor responds, returns a version number (0x06), and even opens an RPC channel (cookie 0x10000). VMwareToolboxCmd.exe stat speed and stat hosttime both work from the unprivileged user, proving the RPC layer is functional.

but the RPC commands are handled by vmtoolsd inside the guest, and they don't expose kernel memory layout. the info-get guestinfo.* commands return nothing useful on Workstation (those are ESX features). the backdoor is a channel, not a leak. it bypasses the guest OS entirely, which means it bypasses the KASLR restriction, but it also doesn't know about ntoskrnl's load address. the hypervisor manages guest physical memory, not guest virtual memory.

CPUID hypervisor leaves (0x40000000-0x40000010) return VMware identification and feature bits, but no memory addresses.

PDH performance counters

tried reading \Process(System)\Virtual Bytes, \Memory\System Code Total Bytes, and every other counter that might embed a kernel address as a raw value. PdhCollectQueryData failed with PDH_CANNOT_CONNECT_TO_MACHINE (0x800007D3). the PDH counter path format was wrong or the counter provider needs the machine name.

what else

references

. _SiCk · 0xdeadbeefnetwork · afflicted.sh