PatchGuard’s Detection of Hypervisor-Based Introspection: KiErrata420Present and Errata1337 [P2]

PatchGuard’s Detection of Hypervisor-Based Introspection: KiErrata420Present and Errata1337 [P2]

Original text: “Patchguard: Detection of Hypervisor Based Introspection [P2]”Aidan Khoury, Reverse Engineering (revers.engineering) (April 26, 2020). The original is licensed Read-Only; prose below is a paraphrase. The 16 verbatim assembly / C code blocks are reproduced under fair-use commentary with attribution captions.

Executive Summary

Part 2 of Aidan Khoury’s PatchGuard introspection-detection series covers two LSTAR-focused checks. The first, KiErrata420Present (Khoury’s informal name for an unnamed Windows 10 1903+ routine), briefly overwrites the LSTAR MSR with a tiny stub that lives inside the PatchGuard context — one byte for RET, two bytes for the SYSCALL opcode — then dispatches SYSCALL through KeGuardDispatchICall. A naive hypervisor that silently blocks WRMSR on LSTAR (to protect its own syscall hook) ends up executing the guest’s real hooked handler instead of the dummy stub, and the entire mechanism unravels. The fix is a transparent shadow: on RDMSR(LSTAR) return the guest’s original value, on WRMSR(LSTAR) swap the guest’s “original” value back to the hook address.

The second technique, KiErrata1337Present, is Khoury’s own derived detection. It deliberately triggers a #PF inside the syscall handler by zeroing IA32_KERNEL_GS_BASE before SYSCALL — the very first SWAPGS+MOV gs:[KPCR.UserRsp], rsp sequence then dereferences a NULL segment base and faults. A temporary IDT with a custom #PF handler catches the fault, reads the true syscall handler RIP off the trap frame, and uses a single XCHG qword [rsp], rcx to swap the trap-frame RIP with the SYSCALL-saved return address held in RCX. The IRET branches cleanly back into the detection routine while RCX now holds the real LSTAR target. Full PoC is published at ajkhoury/Errata1337.

No errata for U!

This is Part 2 of the series. Part 1 walked through three PatchGuard routines that catch a hypervisor cheating on architectural state via FMASK/TF/SYSCALL, MOV SS/POP SS single-step quirks, and the ICEBP pending-BS VMCS trap. This part pivots to the most common hypervisor introspection knob — the LSTAR MSR — and shows two checks that catch a sloppy or naively-implemented LSTAR hook in flight.

KiErrata420Present

Trapping RDMSR/WRMSR on LSTAR is the canonical way to hook system calls in a Windows guest from a hypervisor — it’s the entry-point register for SYSCALL and a single MSR exit lets a VMM redirect kernel-mode dispatch wherever it likes. The catch, despite a lot of online folklore to the contrary, is that this leaves a stack of detection vectors if the VMM isn’t careful about transparency. Windows 10 build 18362 (1903) added several of them. The check below was one of the simpler ones — not even good enough to earn a meme “errata” name from Microsoft. Khoury calls it KiErrata420Present for posterity.

KiErrata420Present:
        cli                             ; disable interrupts
        mov     r9d, 0C0000082h         ;
        mov     ecx, r9d                ;
        rdmsr                           ; read LSTAR MSR value
        shl     rdx, 32                 ;
        or      rax, rdx                ; store LSTAR value in rax
        lea     rdx, [rdi+87Ah]         ; store temp LSTAR value in rdx read from pg context
        mov     rbx, rax                ; rbx = original LSTAR value
        mov     rax, rdx                ; rax = temp LSTAR value
        shr     rdx, 32                 ;
        wrmsr                           ; write temporary LSTAR MSR value
        mov     r14d, 20000h            ;
        lea     rax, [rdi+87Ch]         ; rax = stub to execute syscall
        mov     rsi, 0A3A03F5891C8B4E8h ; rsi = constant to obfuscate pg context pointer
        test    [rdi+994h], r14d        ; test if should store pg check data?
        jnz     short trigger_syscall   ; if nz, skip tracing

        mov     r8, gs:KPCR.CurrentPrcb ;
        lea     rdx, [rdi+rsi]          ;
        mov     rcx, [rdi+4C0h]         ;
        mov     [rcx], rdx              ;
        mov     rcx, [rdi+4C8h]         ; store pg check related data
        mov     [rcx], r8               ;
        mov     rcx, [rdi+4D0h]         ;
        mov     [rcx], r9               ;
        mov     rcx, [rdi+4D8h]         ;
        mov     qword ptr [rcx], 112h   ;

trigger_syscall:
        call    KeGuardDispatchICall    ; dispatch call to syscall instruction stub

        test    [rdi+994h], r14d        ; test if pg check should be traced?
        jnz     short restore_lstar     ; if nz, skip tracing

        mov     rax, [rdi+4C0h]         ;
        mov     [rax], rsi              ;
        mov     rax, [rdi+4C8h]         ;
        mov     [rax], r13              ; wipe pg check related data
        mov     rax, [rdi+4D0h]         ;
        mov     [rax], r13              ;
        mov     rax, [rdi+4D8h]         ;
        mov     [rax], r13              ;

restore_lstar:
        mov     rdx, rbx                ; restore original LSTAR value
        mov     rax, rbx                ;
        shr     rdx, 32                 ;
        mov     ecx, 0C0000082h         ;
        wrmsr                           ; write original LSTAR MSR value
        sti                             ; reenable interrupts

The shape is straightforward: save the current LSTAR, overwrite it with a temporary value that lives at PgContext + 0x87A, dispatch SYSCALL via KeGuardDispatchICall, restore the original LSTAR. The interesting question is what gets written into that PG-context slot.

What ends up at context + 0x87A

The relevant bits of the temporary-LSTAR setup are:

lea     rdx, [rdi+87Ah]         ; store temp LSTAR value in rdx read from pg context
mov     rbx, rax                ; rbx = original LSTAR value
mov     rax, rdx                ; rax = temp LSTAR value
shr     rdx, 32                 ;

So the temporary LSTAR value is RDI + 0x87A. RDI here is the runtime base of the current PatchGuard context. Hunting back into PG initialisation tells us what the kernel puts at that offset:

mov     byte ptr [r14+87Ah], 0C3h ; store RET instruction

That’s the byte C3RET. So byte 0 of the synthesised “syscall handler” is an immediate return. The next piece of evidence is the KeGuardDispatchICall argument:

lea     rax, [rdi+87Ch]         ; rax = stub to execute syscall

KeGuardDispatchICall branches to whatever RAX points at, and here it’s PgContext + 0x87C. PG init at that offset writes:

mov     eax, 050Fh
mov     [r14+87Ch], ax ; store SYSCALL instruction

That two-byte sequence 0F 05 is the encoding of SYSCALL. Stitching the parts together, KiErrata420Present is doing something equivalent to:

_disable();
OriginalSyscall64 = __readmsr(MSR_LSTAR);
__writemsr(MSR_LSTAR, &PgContext->DummySyscallHandler); // C3 -> ret
KeGuardDispatchICall(&PgContext->Syscall); // 0F 05 -> syscall
__writemsr(MSR_LSTAR, OriginalSyscall64);
_enable();

It writes a dummy syscall handler (one RET byte) to LSTAR, executes a SYSCALL through a tiny PG-context-resident trampoline that holds the actual SYSCALL opcode, and restores the real LSTAR. If the hypervisor is hooking LSTAR and refusing to let the guest overwrite it, the SYSCALL will dispatch to the hypervisor’s hook handler rather than to the one-byte RET stub — and the routine doesn’t return cleanly. This is exactly what a naive implementation (e.g. early Hyperbone) trips on.

Bypass: shadow the MSR

The right behaviour for the VMM is to let the guest overwrite LSTAR — treat the “real” value as a shadow — and silently restore the hook value when the guest writes its own original back. PatchGuard has a separate cross-check against LSTAR tampering, so the guest itself can’t leave the MSR rewritten permanently without triggering a different bugcheck. In the VMM, the read/write handlers look like this:

VMM_EVENT_STATUS
HVAPI
VmmHandleMsrRead(
    _In_ PVIRTUAL_CPU Vcpu
    )
{
    // ...

    //
    // Hide our LSTAR syscall hook handler address.
    //
    case MSR_LSTAR:
        if (Vcpu->OriginalLSTAR) {
            MsrValue = Vcpu->OriginalLSTAR;
        } else {
            MsrValue = __readmsr(MSR_LSTAR);
        }
        break;

    // ...
}

VMM_EVENT_STATUS
HVAPI
VmmHandleMsrWrite(
    _In_ PVIRTUAL_CPU Vcpu
    )
{
    // ...

    //
    // Let the guest overwrite our hook to avoid possible detection.
    //
    // If and only if the guest is writing the original LSTAR, we replace
    // the MSR value with the hook LSTAR value.
    //
    // N.B. We do this to get around one of PatchGuard's syscall hook
    //      detections which works like this:
    //
    //  _disable();
    //  OriginalSyscall64 = __readmsr(MSR_LSTAR);
    //  __writemsr(MSR_LSTAR, &PgCtx->PgSyscallDummy); // C3 -> ret
    //  KeGuardDispatchICall(&PgCtx->SyscallOpcode1); // 0F 05 -> syscall
    //  __writemsr(MSR_LSTAR, OriginalSyscall64);
    //  _enable();
    //
    case MSR_LSTAR:
        if (MsrValue == Vcpu->OriginalLSTAR) {
            MsrValue = Vcpu->HookLSTAR;
        }
        __writemsr(MSR_LSTAR, MsrValue);
        break;

    // ...
}

KiErrata1337Present — a derived LSTAR detection

Khoury’s own contribution: using critical thinking on the patterns above, here is a fresh LSTAR-hook detection that doesn’t depend on tampering with the MSR at all. The hook detection now sits inside the syscall handler itself.

Both Linux and Windows 64-bit syscall handlers open with SWAPGS, which exchanges the current IA32_GS_BASE with IA32_KERNEL_GS_BASE (MSR 0xC0000102). The instruction immediately after is a segmented store. From nt!KiSystemCall64:

KiSystemCall64 proc near
        swapgs                                  ; swap GS base with IA32_KERNEL_GS_BASE
        mov     gs:KPCR.UserRsp, rsp            ; store user mode stack in processor control region
        mov     rsp, gs:KPCR.Prcb.RspBase       ; set the kernel stack from processor control region

The implication: if we deliberately corrupt IA32_KERNEL_GS_BASE before issuing SYSCALL, the SWAPGS at the start of the kernel handler will load a bogus GS base, the very next MOV gs:[KPCR.UserRsp], rsp will fault, and we’ll get a #PF inside the syscall handler. The #PF trap frame contains the faulting RIP — which is the real syscall handler address. That’s what we want to read.

First attempt: just fault

KiErrata1337Present:
        swapgs                                  ; swapgs to emulate coming from user mode

        mov     ecx 0C0000102h                  ;
        xor     eax, eax                        ; set KERNEL_GS_BASE MSR to zero
        xor     edx, edx                        ;
        wrmsr                                   ;

        syscall                                 ; execute the syscall instruction to trigger fault

        ret

This will fault, which is the right outcome — but the real #PF handler will bugcheck on the corrupted GS bases, and even if it survived, the next context switch would explode. We need a temporary IDT and we need to restore the GS bases.

Hooking the IDT for the duration of the trick

Six-step recipe: disable interrupts, snapshot the IDTR, swap in a temporary IDT whose #PF vector points at our handler, run the detection, restore the original IDT, re-enable interrupts. Pseudocode:

TempIdtr.Limit = sizeof(TempIdt) - 1;
TempIdtr.Base = (UINT64)&TempIdt[0];
for (IdtEntry in KPCR->IdtBase)
    TempIdt[i] = IdtEntry; // Fill in temporary IDT

_disable();             // Disable interrupts
__sidt(&OriginalIdtr);  // Backup original IDT
__lidt(&TempIdtr);      // Load our temporary hook IDT

// Hook page fault handler.
TempIdt[PF] = PageFaultHookHandler;

// Trigger syscall that will purposely page fault!
KiErrata1337Present();  // This must be lean enough not to timeout watchdog!

__lidt(&OriginalIdtr);  // Restore the original IDT.
_enable();              // Re-enable interrupts.

The minimal #PF handler — for now — just discards the error code and returns:

PageFaultHookHandler:
        add     rsp, 8                  ; skip fault code on stack
        iretq                           ; return from interrupt

And the detection routine now saves and restores both GS-base MSRs so the OS doesn’t fall over after we come back:

KiErrata1337Present:
        mov     ecx, 0C0000101h         ; read original GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original GS_BASE MSR
        push    rax                     ;
        mov     ecx, 0C0000102h         ; read original KERNEL_GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original KERNEL_GS_BASE MSR
        push    rax                     ;

        swapgs                          ; swapgs to emulate coming from user mode

        xor     eax, eax                ;
        xor     edx, edx                ; set KERNEL_GS_BASE MSR to zero
        wrmsr                           ;

        syscall                         ; execute syscall instruction which executes swapgs immediately

        mov     ecx, 0C0000102h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original KERNEL_GS_BASE MSR
        wrmsr                           ;
        mov     ecx, 0C0000101h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original GS_BASE MSR
        wrmsr                           ;

        ret                             ; return back to caller

This already works in the sense of “doesn’t crash” — but it still doesn’t tell us the real syscall handler address. Time for the actual trick.

The juicy part: XCHG the trap-frame RIP with the SYSCALL return in RCX

SYSCALL saves the address of the instruction following itself in RCX (for SYSRET to return to). The Intel SDM semantics:

RCX ← RIP; (* Will contain address of next instruction *)
RIP ← IA32_LSTAR;
R11 ← RFLAGS;
RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
// .... memes

So after our SYSCALL faults inside the kernel handler, RCX already holds the post-SYSCALL return target inside our detection routine. The trap frame at [rsp] (after we’ve discarded the error code) holds the kernel-handler RIP — the value we actually want to read out. A single XCHG swaps the two, and IRETQ branches us back into our detection code with the real syscall-handler address sitting in RCX. Two birds, one stone.

PageFaultHookHandler:
        add     rsp, 8                  ; skip fault code on stack
        xchg    qword [rsp], rcx        ; xchg trap frame RIP with syscall return address in RCX
        iretq                           ; return from interrupt
Trailer Park Boys — the original “two birds stoned at once.” Source: original article.

PoC || GTFO

The full implementation, ready to paste:

// detect.c

VOID
DoTheThing(
    VOID
    )
{
    KIDTENTRY64 TempIdt[19];
    X64_DESCRIPTOR TempIdtr;
    PVOID SyscallHandler;

    TempIdtr.Limit = sizeof(TempIdt) - 1;
    TempIdtr.Base = (UINT64)&TempIdt[0];
    RtlCopyMemory(TempIdt, KeGetPcr()->IdtBase, TempIdtr.Limit + 1);

    _disable();             // Disable interrupts
    __sidt(&OriginalIdtr);  // Backup original IDT
    __lidt(&TempIdtr);      // Load our temporary hook IDT

    // Hook page fault handler.
    TempIdt[X86_TRAP_PF].OffsetLow = (UINT16)(UINTN)PageFaultHookHandler;
    TempIdt[X86_TRAP_PF].OffsetMiddle = (UINT16)((UINTN)PageFaultHookHandler >> 16);
    TempIdt[X86_TRAP_PF].OffsetHigh = (UINT32)((UINTN)PageFaultHookHandler >> 32);

    // Trigger syscall that will purposely page fault!
    SyscallHandler = KiErrata1337Present();

    __lidt(&OriginalIdtr);  // Restore the original IDT.
    _enable();              // Re-enable interrupts.

    LOG_INFO("REAL SYSCALL Handler = 0x%p", SyscallHandler);
}
; detect.asm

PageFaultHookHandler:
        add     rsp, 8                  ; skip fault code on stack
        xchg    qword [rsp], rcx        ; xchg trap frame RIP with syscall return address in RCX
        iretq

KiErrata1337Present:
        push    rbx                     ; backup RBX which is to be clobbered

        mov     ecx, 0C0000101h         ; read original GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original GS_BASE MSR
        push    rax                     ;
        mov     ecx, 0C0000102h         ; read original KERNEL_GS_BASE MSR
        rdmsr                           ;
        push    rdx                     ; backup original KERNEL_GS_BASE MSR
        push    rax                     ;

        swapgs                          ; swapgs to emulate coming from user mode

        xor     eax, eax                ;
        xor     edx, edx                ; set KERNEL_GS_BASE MSR to zero
        wrmsr                           ;

        syscall                         ; execute syscall instruction which executes swapgs immediately
        mov     rbx, rcx                ; store result syscall handler address in RBX for now

        mov     ecx, 0C0000102h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original KERNEL_GS_BASE MSR
        wrmsr                           ;
        mov     ecx, 0C0000101h         ;
        pop     rax                     ;
        pop     rdx                     ; restore original GS_BASE MSR
        wrmsr                           ;

        mov     rax, rbx                ; return result in RAX
        pop     rbx                     ; restore original RBX
        ret                             ; return back to caller

Full PoC repository: github.com/ajkhoury/Errata1337.

Key Takeaways

  • LSTAR hooking is the obvious attack surface and the obvious detection surface. Trapping RDMSR/WRMSR on LSTAR is the most efficient way to hook syscalls in a Windows guest — and that’s exactly why PatchGuard now probes it from multiple angles starting with Windows 10 1903.
  • KiErrata420Present abuses byte-level PG-context layout. One byte of C3 at PgContext+0x87A + two bytes 0F 05 at PgContext+0x87C form a one-shot dummy syscall handler + opcode trampoline. The check stands or falls on whether the VMM allows the guest’s WRMSR on LSTAR to actually land.
  • Naive “just block WRMSR(LSTAR)” hypervisors die here. Hyperbone-class implementations that disallow guest writes to LSTAR outright will execute their hooked handler instead of the one-byte RET stub, then fail to return cleanly. Fix: shadow the MSR transparently on both read and write.
  • PatchGuard has a separate LSTAR-tamper check, so the shadow is safe. A guest can’t permanently rewrite LSTAR from kernel mode without separately tripping PG — that gives the VMM the cover it needs to let the user-visible MSR fluctuate.
  • KiErrata1337Present reads the real LSTAR without ever touching the MSR. Zero IA32_KERNEL_GS_BASE, issue SYSCALL, let the kernel handler’s SWAPGS+segmented store fault, capture the faulting RIP on the trap frame. The MSR is read through an architectural side-channel.
  • The XCHG qword [rsp], rcx is the elegant kernel-level pivot. SYSCALL populates RCX with its return address; the trap frame holds the kernel-handler RIP. One XCHG exchanges them, IRETQ branches back to the detector, and RCX carries the truth.
  • Both detections require an IDT swap to avoid bugchecking. Temporary IDT, custom #PF handler, careful GS-base backup/restore — classic kernel-introspection plumbing. The detection routine has to stay lean enough not to trip the DPC/watchdog timeout while interrupts are disabled.

Hardening Checklist for Hypervisor Authors

  1. Shadow LSTAR, don’t block it. On RDMSR(LSTAR) return the guest’s saved original; on WRMSR(LSTAR) accept the write, but when the guest is restoring the value it expected to find, swap your hook back in. The VmmHandleMsrRead / VmmHandleMsrWrite pattern shown in the article is the right shape.
  2. Don’t silently reject guest writes to security-relevant MSRs. A failed WRMSR(LSTAR) is more detectable than a transparent one — PatchGuard’s probes are sensitive to behaviour, not just to the final MSR value.
  3. Watch for byte-level pokes into the PatchGuard context. C3 and 0F 05 at constant PG-context offsets are a very specific fingerprint — if you ever introspect a PG callback, those bytes are a probe.
  4. Assume IA32_KERNEL_GS_BASE can be deliberately corrupted from kernel mode. A faulting SWAPGS in the syscall handler is now an LSTAR-discovery technique, not just a bug. If you intercept #PF in the host, validate the trap frame and don’t leak the guest-side handler address.
  5. Audit any IDT-related VMEXIT path. KiErrata1337Present-style detections install a temporary IDT and revert it; that’s a strong signal a VMM can hook on, but only if it’s actually tracking LIDT and IDTR shadowing.
  6. Don’t hook syscalls by remapping the LSTAR target page either. Both checks ultimately read the architectural target RIP; nested-paging tricks that hide the page contents from RDMSR still expose the RIP via the trap frame.
  7. Treat any disable of PatchGuard as the loudest thing on the system. Disabling PG removes the cross-check that protects the shadow strategy — legitimate guest software won’t do it, and the system itself will broadcast the act of doing so.
  8. Read the PoC. The Errata1337 repo is short and complete — build it, run it under your VMM, and make sure both KiErrata420Present and KiErrata1337Present return the values you expect.

Conclusion

The two checks in Part 2 reinforce the lesson from Part 1: an introspection hypervisor on Windows has to be architecturally transparent, not just functionally correct. KiErrata420Present doesn’t care that your hook is sophisticated — it cares whether the guest’s own WRMSR(LSTAR) takes effect when it expects to. KiErrata1337Present doesn’t care about MSRs at all; it builds the read out of SWAPGS, the trap frame, and an XCHG. Both are deeply mechanical, both are short, and both are easy to bolt onto a normal anti-cheat or EDR probe. For VMM authors the practical answer is the same in every case: shadow MSR state instead of blocking it, validate guest interruptibility on every relevant exit, and assume that any detection you can think of in five minutes is already inside nt!Ki* somewhere.

Original text: “Patchguard: Detection of Hypervisor Based Introspection [P2]” by Aidan Khoury at Reverse Engineering (revers.engineering).

Comments are closed.