Reverse engineering what HyperGuard monitors in ntoskrnl
Taking HyperGuard off guard
Intro
I have talked briefly about loading code into VTL1 (Virtual Trust Level 1).
This is the more privileged Virtual Trust Level used by Windows VBS. It hosts the Secure Kernel and isolated user-mode components whose
memory can be protected from the normal VTL0 kernel.
Virtual Trust Levels are explained in depth across the internet, and the Secure Kernel has been covered also by various researchers (1, 2, 3, 4, and so on..).
HyperGuard, also called Secure Kernel Patch Guard, complements traditional PatchGuard by moving selected integrity checks and enforcement into the more privileged VTL1 Secure Kernel. Patch Guard needs no introduction for many, but in a nutshell, Microsoft introduced it to protect certain sensitive kernel structures, functions, etc, to prevent rootkits modifying them. Unfortunately, there is one flaw in this approach - if you are operating at the same privilege as the adversary, they have just as much control at deceiving you as you have at protecting the system from them.
Then, Virtualisation Based Security (VBS) came along, which introduced two trust levels, VTL0, the normal trust level, and VTL1, the secure trust level, using
the Hyper-V hypervisor. I won’t profess to be an expert at hypervisors, in fact, this is part of my own journey of exploring them. The tech there is
very cool.
The key thing here, VTL0 code cannot touch things in VTL1 directly, it does so via defined secure-call/hypercall interfaces.
Because code running at VTL0, even a rootkit, cannot touch functions in VTL1, Microsoft brought in the SKPG / HyperGuard, to scan memory from a protected
safe bastion, to authoritatively determine whether a rootkit has tampered with critical parts of the OS that deal with its security & integrity.
The motivation for this post is 3-fold:
- I wanted to dig deeper into the Secure Kernel, and debugging it
- I was curious about Alt Syscalls which I posted about here, and what the state of play is with SKPG. Keep reading to find out!
- I was curious what the SKPG actually monitors in
ntoskrnl, anything different to normal Patch Guard?
Debugging the Secure Kernel
The Secure Kernel, just like the normal Kernel, is an executable - it lives mostly in securekernel.exe, as well as having a few small DLL dependencies.
The image gets loaded sometime at boot into VTL1 and HyperGuard gets initialised. I haven’t reversed the initialisation process myself (yet) however there
is an excellent blog series on this by Yarden Shafir, which you can find
here.
As the Secure Kernel lives in VTL1, we cannot debug it with the normal setup. For this, I used:
- Hyper-V VM Guest - running Windows Server 2022. This is the debugger machine, where I run my tools. From this guest, I pass through Hyper-V to run a second VM inside of that one:
- Windows 11 Enterprise, this is the VM that we are debugging. Running 25H2.
I am not going to go into detail now about how I set this up, I will save that for a subsequent blog post.
Examining HyperGuard
So, securekernel!SkpgContext is a symbol used quite heavily at runtime:

This is a global pointer to the SKPG’s runtime state object, it’s a heap allocated state block that securekernel!SkpgConnect builds once HyperGuard is activated,
which is consulted by the callback / runtime code. We can see that this label holds an address. For this blog, we will only concern ourselves with what
HyperGuard protects in NTOSKRNL.

SkpgContext points to a dynamically sized runtime context allocated during SkpgConnect. The context contains global SKPG state followed by an array
of SKPG_EXTENT records. In this blog post, Yarden explains that the context structure is used in all SKPG checks. They include:
- Hash (the hash of the region protected)
- Base (the base address of what is being protected)
- Size (how many bytes does the protection cover)
- Type & flags
So, we know roughly what the layout is, we know where it is at runtime, and my debugger is properly attached, so, we can go ahead and dump some memory and write a script to iterate through it!
First, we need to know the NTOSKRNL address start and end, which we can get with lm m nt:

We can now filter on any address which falls within that range, and use logopen to write the output to a file to make it easier to go through
the list:
r @$t0 = poi(securekernel!SkpgContext); r @$t1 = dwo(@$t0+0x28); r @$t2 = @$t0+dwo(@$t0+0x2c); .for (r @$t3=0; @$t3<@$t1; r @$t3=@$t3+1) { r @$t4=@$t2+@$t3*0x30; r @$t5=poi(@$t4+0x8); .if (@$t5 >= fffff802`a8a00000) { .if (@$t5 < fffff802`a9e4f000) { .printf "\n[%04x] type=%04x flags=%04x size=%08x base=%p end=%p hash=%p\n", @$t3, wo(@$t4), wo(@$t4+0x2), dwo(@$t4+0x4), @$t5, @$t5+dwo(@$t4+0x4)-1, poi(@$t4+0x10); ln @$t5; } } }
We add an ln in which will help us resolve some symbols, but not all - a few required manual investigation in Ida.
General findings
I started manually going through each entry that matched the VA space of ntoskrnl, however, the vast majority were contiguous regions of single page (0x1000)
SKPG_EXTENT entries; I assume they organise it like this so it is faster to hash, and it makes it easier to determine which page had an unexpected modification.
In short, the contiguously monitored regions of ntoskrnl boil down into (addresses are translated to Ida base, not real VA base):
- range_0 = 0x140000000 - 0x14016BFFF
- range_1 = 0x140200000 - 0x1406E8FFF
- range_2 = 0x140B62000 - 0x140B64FFF
- range_3 = 0x140BA7000 - 0x140BA8FFF
- range_4 = 0x140BAA000 - 0x140BC9FFF
Range 0
This one starts at the base address of NTOSKRNL which you can see here:

It also contiguously covers the image base, image headers, .rdata, .pdata, two .idata mappings, and the first page of PROTDATA.

This protects 0x16C000 bytes.
Range 1
This protects the executable code within ntoskrnl, starting at _tlgWriteTemplate. This runs through to the end of the .text section
as you can see here:

This range protects 0x4E9000 bytes. This is the largest contiguous protection range.
Range 2
This range protects a smaller region, of 0x3000 bytes, which is this POOLCODE section.

Some functions in here are:
ExAllocatePoolWithTagExAllocatePool3ExpPoolTypeToPoolFlagsExpPoolFlagsToPoolTypeExFreePoolWithTag
And so on..
Range 3
This range includes the TRACESUP section, more executable code. This protects 0x2000 bytes.

Including functions such as:
KiTpCompletionKiTpWriteRegisterValueKiTpSignExtendOperandValueKiTpSetImmediateOperandSizeRtlpIcSetFlagsZeroSignParityKiTpSetFlagsSub- Etc
Not functions I am familiar with.
Range 4
More executable code, this time, stats in the KVASCODE section and extends into the INITKDBG section, not something I am familiar with.

This includes functions such as:
KiDivideErrorFaultShadowRtlMinimalBarrierRtlInitMinimalBarrierKiTimerDispatchKeGuardCheckICallKeGuardDispatchICallKiMceLinkageKiMceThunkFsRtlUninitializeSmallMcbRtlLookupFunctionEntryExKiAccessPageKiErrata704Present- Etc, etc.
Summary of sweeping protections
So, for these single page protection ranges, it is fair to conclude that it is protecting early image regions, and executable code, .pdata
to prevent attackers from modifying unwind info, .rdata to protect the read only integrity of kernel values, and so on.
Specific findings
On top of these large contiguous protection entries, we also have a number of specifically monitored addresses (which are more interesting from a research perspective). I will start by posting a bullet list of all of these, then explore some of the interesting ones which caught my eye. I have tried to group them by related functionality.
System Call integrity
KeServiceDescriptorTableKeServiceDescriptorTable+0x20KeServiceDescriptorTableFilterKeServiceDescriptorTableShadow
System call hooking
PsAltSystemCallHandlers(ehem)HalDispatchTable+0x8PspPicoProviderRoutinesHalPerformEndOfInterruptAtControllerHalpProfileInterfaceHalpProfileFeaturesEtwpGetHostPerfCounter+7MmUserProbeAddressMmBadPointer
Misc
InitSafeBootModeRtlpUnwindHistoryTablePsInvertedFunctionTable+0x10xref to global in MmQueryApiSetSchemaKiDebugTrapsMmSystemRangeStartMmHighestUserAddressHvcallCodeVaPsWin32NullCallBackPspSystemMitigationOptionsKdpBootedNodebugKiDynamicTraceEnabledKiDynamicTraceCalloutsHvlpHypercallCodeVaHvlpVsmVtlCallVaMaxDataSize(resolved via Ida, no resolution in windbg, in the oddly namedALMOSTROsection, almost read-only? :D :D)xref in nt!KiSwInterruptDispatch(unlabelled symbol)
Alt Syscalls
Well, it was quite pleasing to see Alt Syscalls make an appearance in the list! I previously reverse engineered
the changes to Alt Syscalls on Windows 11, at which time I wasn’t really too sure how HVCI interacted with them.
Thanks to my friend Xacone, he found that the address used in my new technique is protected by HVCI, meaning it prevents you from
writing into PspServiceDescriptorGroupTable when HVCI is enabled - however, Microsoft here are protecting PsAltSystemCallHandlers, which was the
old (pre 25H2) address that was used for using Alt Syscalls; so my assumption here is:
The old technique which uses PspServiceDescriptorGroupTable, I suspect has a backported protection
(AKA this we are talking about here) (or maybe it existed way back then) in the securekernel.exe to use HyperGuard to monitor that address for
tampering.. and they protect the new technique via HVCI, that’s pretty neat ngl! Kudos Microsoft!
Unless my memory is failing me, I cannot see any callbacks protected by HyperGuard here for Alt Syscalls (25H2), so the SLAT protection must be good enough,
but this is absolutely an area that would be worth a little more research now we can see that in HyperGuard. I will spend some time I think reversing
Alt Syscalls within securekernel.exe and see what I can find! The last time I looked, you can register a driver to become an Alt Syscall provider, but
it needed some form of attestation by Microsoft (above and beyond normal driver attestation) and there was no way to properly register it through the
Vsl call into the SK by disabling integrity checks etc. I forget the exact detail, but there is some code integrity that runs on the secure kernel side.
System call tables
As is probably no surprise, the NT System Service Dispatch Tables (SSDT) are protected by HyperGuard, first are the high level pointers to the tables:
KeServiceDescriptorTableKeServiceDescriptorTable+0x20KeServiceDescriptorTableFilter
I have not looked into whether win32k!W32pServiceTable is protected.
The eager eyed reader may notice that the SSDT tables themselves are not here, well, this is by design, as the actual tables fall within the
protected .rdata section we discovered above, so they are properly protected by HyperGuard:

Infinity Hook ish
We can also see a number of functions which are related to infinity hook - one example would be the callback inside of nt!EtwpGetHostPerfCounter,
effectively there is a HyperGuard SKPG_EXTENT target at the Virtual Address: 0xfffff802a9800c20, which, once I apply a few offset calculations in Ida,
gives me an un-labeled symbol: off_140E00C20, which has an X-Ref in EtwpGetHostPerfCounter. The value stored in this address on my live image is:
kd> dqs fffff802a9800c20
fffff802`a9800c20 fffff802`a8f38eb0 nt!HalpTimerQueryHostPerformanceCounter
And we can see its relevance in EtwpGetHostPerfCounter:

Overwriting this pointer would likely allow the attacker to redirect code execution into their own callback for kernel syscall hooking.
There are also a number of other monitored addresses in our list above which are also in some way intertwined into the Infinity Hook style syscall hooking,
so, I think this is their implementation of protecting that from VTL1 (those related to Hal etc). Just my opinion ^^.
The original Infinity Hook implementation used GetCpuClock, which was nerfed, which then evolved into th HAL timing callbacks being the successor surfaces.
Therefore SKPG protection is consistent with hardening against InfinityHook type attacks. Perhaps there are other reasons for these protections that
I am not yet aware of, please let me know if there are any other cool ways of abusing these things!
Hv family
Finally, there are also a number of monitored addresses related to the Hv* functions, which relate to the hypercall stubs used when the kernel wants to
issue a Hyper-V hypercall. If these were corrupted, an attacker could potentially redirect call sites that indirect through the corrupted hypercall pointer
to arbitrary code. So, similar to syscall hooking really. Hypervisor hooking is not something I have experience of, but I would be interested to learn
more about it. Here, you can see the xrefs to the monitored HvcallCodeVa:


HvcallCodeVa was zero in my live snapshot. Because some call sites indirect through it, those paths must either be gated, initialised only under
specific configurations, or superseded by another hypercall pointer such as HvlpHypercallCodeVa.
Questions
So, this leaves me with some questions over the internals of HyperGuard that I would like to try see at runtime:
- What happens when we write to something protected by HyperGuard? Does each protected address need a gate that asks “is HVCI enabled?” and then, if you want to modify it, it happens from the Secure Kernel instead? Effectively, so it can recompute the hash?
- Can we effectively tamper with things protected by HyperGuard by looking like a legitimate caller? That will require a lot of work to try confuse some function within
securekernel, just a hypothetical thought I had whilst writing this. I am sure this boundary is very secure, but it doesn’t look to hurt!
Conclusion
This was a small expedition into looking at HyperGuard and trying to document some things it monitors, as well as making general use of a debugger able to
read into the securekernel. I want to come back to Alt Syscalls as it would be interesting to see if anything has changed, and in particular, how it
relates to the securekernel.
If there is anything inaccurate here, please contact me so I can correct - this blog is a reflection of my learning and is not intended to be academically sound. I hope you enjoyed the read <3.
I will try find time to do a blog post and a video on how to setup debugging of VTL1 memory; but until next time, ciao xo.