1. Overview

Item Value
Unpatched binary winhv_unpatched.sys
Patched binary winhv_patched.sys
Overall similarity 0.6721
Matched functions 118
Changed functions 81
Identical functions 37
Unmatched (unpatched / patched) 0 / 0

Verdict: The patch threads the active-processor count (SizeOfBitMap from KeQueryActiveProcessorCountEx) plus an affinity-derived value into the inner per-VP hypercall issuer during VTL/VSM enablement, replacing an unbounded call. This is consistent with fixing an out-of-range VP-index access to per-VP context/overlay memory during Virtualization-Based-Security bring-up.

Of the 81 changed functions, the large majority are recompilation noise across a wide version gap: register/stack reshuffling, out-of-lined memset/memcpy helpers (sub_140004640, sub_140004940), an inverted-but-equivalent fast/slow-path selection in WinHvSignalEvent, and a substantially expanded hypervisor-status-to-NTSTATUS mapping table in sub_1C00011B0 (new 0xA0011, 0xA000C–0xA000F, 0xA1002–0xA100F ranges). None of those alter memory safety or authorization. The single meaningful security delta is concentrated in the VTL per-VP enablement routine sub_1C00038BC (patched sub_1400030F4), where an explicit bounds argument was added to the per-VP successor routine. There is no feature-flag or KIR gate on the fix — it is a straightforward additive argument change.


2. Vulnerability Summary

2.1 MEDIUM — Missing bounds argument / potential out-of-bounds VP-index access (CWE-129 -> CWE-787)

  • Affected function: sub_1C00038BC (unpatched), sub_1400030F4 (patched) — the VTL per-VP enablement loop that iterates active processors and issues a per-VP hypercall for each.
  • Root cause: The routine computes the active processor count, initializes an RTL_BITMAP of that size, then loops over VP indices issuing a per-VP hypercall through the inner routine sub_1C0003B90. In the unpatched build that inner routine is called with no arguments — it derives its per-VP target entirely from ambient/affinity state rather than from a caller-supplied bound.
  • Relevant state/layout:
    • data_1C00065E8RTL_BITMAP buffer, sized to SizeOfBitMap (active processor count).
    • data_1C0006600 — fast mutex guarding the enable/disable critical section.
    • data_1C00065BD — selects hypercall code 0xA0011 vs 0xA1011.
    • Per-VP context/overlay pages — allocated at init in sub_1C00027A0, linked via the data_1C0006068 table at +0x30/+0x48.
  • Why exploitable: The inner issuer indexes/writes per-VP context using state (affinity / VP index) that is not validated against the current active-processor count. If the processor-count or affinity state at the time of the VTL-enable call diverges from the count used to size the per-VP arrays at init, the effective VP index can exceed the array bound, yielding an out-of-range read/write of kernel per-VP context/overlay memory.
  • What the patch does: Replaces the parameterless sub_1C0003B90() with sub_140003408(rdi, SizeOfBitMap, rdx_5), explicitly passing the VP context pointer (rdi), the active-processor-count bound (SizeOfBitMap), and an affinity-derived value (rdx_5). The successor now constrains its per-VP indexing to the bound. No unchecked fallback path remains in the patched routine.
  • Attacker-reachable entry point: winhv.sys exposes no device object / IOCTL surface. Reachable only via the WinHv VTL-enable export invoked by kernel components (securekernel / VID / Hyper-V partition setup) during VBS/HVCI/VTL configuration.
  • Call chain:
  • VSM/VTL enable request (securekernel / VID / Hyper-V setup)
  • winhv.sys VTL-enable export
  • sub_1C00038BC (0x1C00038BC) — iterate active processors, RTL_BITMAP over data_1C00065E8
  • sub_1C0003B90 (0x1C0003B90) — inner per-VP hypercall issuer, UNBOUNDED in unpatched

3. Pseudocode Diff

3.1 sub_1C00038BC (patched sub_1400030F4)

// UNPATCHED — sub_1C00038BC; inner issuer receives NO processor-count bound
eax = KeQueryActiveProcessorCountEx(0xFFFF);      // active proc count
RtlInitializeBitMap(&BitMapHeader, data_65e8, eax); // SizeOfBitMap = eax (esi)
ebx = data_65bd ? 0xA1011 : 0xA0011;              // hypercall selector
ExAcquireFastMutex(&data_6600);
if (sub_1c0001150(ebx, &var_58) >= 0 && var_58 == 1) {   // outer wrapper, 2 args
    for (rbx_1 = 0; rbx_1 < esi; rbx_1++) {       // loop bounded ONLY by bitmap size
        if (RtlTestBit(...)) {
            KeGetProcessorNumberFromIndex(rbx_1, &var_58);
            KeSetSystemGroupAffinityThread(&Affinity, &PreviousAffinity_1);
            if (sub_1c0003b90() >= 0) {           // <-- INNER ISSUER: NO BOUND PASSED
                sub_1c00035cc();                  // per-VP finalize (VP index)
                RtlSetBit(&BitMapHeader, rbx_1);
            }
        }
    }
}
ExReleaseFastMutex(&data_6600);
// PATCHED — sub_1400030F4; bound (SizeOfBitMap) + affinity threaded into successor
SizeOfBitMap_1 = KeQueryActiveProcessorCountEx(0xFFFF);
RtlInitializeBitMap(&BitMapHeader, data_65e8, SizeOfBitMap_1);
rbx = data_61d ? 0x1000 : 0;
ecx = rbx + 0xA0011;                              // hypercall selector, additive form
ExAcquireFastMutex(&data_6600);
for (rbx_2 = 0; rbx_2 < SizeOfBitMap_1; rbx_2++) {
    if (RtlTestBit(...)) {
        // outer wrapper now 3 args (code, &out, 0)
        if (sub_140003a58(rbx_2 + 0xa0011, &var_58, 0) >= 0 && var_58 == 1) {
            KeGetProcessorNumberFromIndex(rbx_2, &var_58);
            KeSetSystemGroupAffinityThread(&Affinity, ...);
            // <-- INNER ISSUER now receives VP context + SizeOfBitMap bound + affinity
            if (sub_140003408(rdi, SizeOfBitMap_1, rdx_5) >= 0) {
                sub_140002ca4(rdi);               // per-VP finalize, explicit VP context
                RtlSetBit(&BitMapHeader, rbx_2);
            }
        }
    }
}
ExReleaseFastMutex(&data_6600);

Key changes: - Unpatched: sub_1c0003b90() is called with no arguments — its per-VP indexing/writes are governed only by ambient affinity state, not by the computed SizeOfBitMap. - Patched: sub_140003408(rdi, SizeOfBitMap, rdx_5) receives the active-processor-count bound and the VP context pointer explicitly, constraining the per-VP access. - The outer hypercall wrapper gained a third argument (sub_140003a58(code, &out, 0)), and the finalize call now passes the VP context (rdi) explicitly (sub_140002ca4(rdi) vs sub_1c00035cc()). - The only VP-index bound in the unpatched build is the bitmap loop guard cmp ebx, esi; jb; the inner issuer itself was unbounded.


4. Assembly Analysis

4.1 sub_1C00038BC (patched sub_1400030F4)

Unpatched — setup, selector, and the unbounded inner call:

0x1c00038bc | mov qword [rsp+0x10], rbx
0x1c00038c1 | mov qword [rsp+0x18], rbp
             push rsi ; push r14 ; push r15 ; sub rsp, 0x60
             mov rax, qword [rel 0x1c0006000]      ; __security_cookie
             xor rax, rsp ; mov qword [rsp+0x58], rax
             lea r15, [rsp+0x48]
             movzx r14d, cl                        ; r14b = arg1 enable flag
             mov ecx, 0xffff
             xor bpl, bpl
             call qword [rel 0x1c000c0c0]          ; KeQueryActiveProcessorCountEx -> eax
             mov rdx, qword [rel 0x1c00065e8]      ; RTL_BITMAP buffer
             lea rcx, [rsp+0x28] ; mov r8d, eax ; mov esi, eax   ; esi = SizeOfBitMap
             call qword [rel 0x1c000c158]          ; RtlInitializeBitMap
             cmp byte [rel 0x1c00065bd], bpl
             lea rcx, [rel 0x1c0006600]
             mov eax, 0xa1011 ; mov ebx, 0xa0011
             cmovne ebx, eax                       ; select hypercall code 0xA0011/0xA1011
             call qword [rel 0x1c000c088]          ; ExAcquireFastMutex
             lea rdx, [rsp+0x20] ; mov ecx, ebx
             call 0x1c0001150                      ; OUTER hypercall wrapper (2 args)
             test eax, eax ; js 0x1c0003a63
             cmp qword [rsp+0x20], 0x1 ; jne 0x1c0003a63
             xor ebx, ebx ; test esi, esi ; je 0x1c0003a63
; --- per-VP loop @ 0x1c0003991 ---
             ; KeGetProcessorNumberFromIndex(rbx_1,&var_58)
             ; KeSetSystemGroupAffinityThread(&Affinity, PreviousAffinity_1)
0x1c0003b90? call 0x1c0003b90                      ; INNER per-VP issuer -- NO BOUND ARG
             test eax, eax ; js 0x1c0003a4a
             call 0x1c0003aa0
             call 0x1c00035cc                      ; per-VP finalize (VP index)
             lea rcx, [rsp+0x28] ; mov edx, ebx
             call qword [rel 0x1c000c168]          ; RtlSetBit(bitmap, rbx_1)
             inc ebx ; cmp ebx, esi ; jb 0x1c0003991
             lea rcx, [rel 0x1c0006600]
             call qword [rel 0x1c000c078]          ; ExReleaseFastMutex
             mov rcx, qword [rsp+0x58] ; xor rcx, rsp
             call 0x1c0001200                      ; __security_check_cookie
             retn

Annotations: - call qword [rel 0x1c000c0c0] (~0x1c0003920): KeQueryActiveProcessorCountEx produces esi = SizeOfBitMap, the intended VP-count bound. - cmovne ebx, eax (~0x1c000394x): selects hypercall 0xA0011/0xA1011 from data_65bd. - call 0x1c0003b90: the vulnerable instruction — the inner per-VP issuer is invoked with no processor-count argument; it indexes per-VP context using ambient affinity state only. - inc ebx ; cmp ebx, esi ; jb 0x1c0003991: the sole VP-index bound present, and it only governs the bitmap loop, not the inner routine's own indexing.

Patched — bound threaded into successor (conceptual, per pseudocode):

; equivalent block in patched sub_1400030F4
             ; ecx = (data_61d ? 0x1000 : 0) + 0xA0011      ; additive selector
             call 0x140003a58                      ; OUTER wrapper (3 args: code, &out, 0)
             ; ...
             mov rcx, rdi                           ; VP context pointer
             mov edx, <SizeOfBitMap>                ; active-proc-count BOUND
             mov r8, <rdx_5>                         ; affinity-derived value
             call 0x140003408                       ; INNER issuer -- receives bound
             test eax, eax ; js ...
             mov rcx, rdi
             call 0x140002ca4                        ; per-VP finalize (explicit VP ctx)
             ; RtlSetBit(&BitMapHeader, rbx_2)

Annotations: - call 0x140003408 with edx = SizeOfBitMap: the new check — the successor now receives the active-processor count and can reject or clamp any VP index/count that exceeds it. - The finalize (sub_140002ca4(rdi)) now takes the VP context explicitly, removing reliance on ambient state that made the unpatched index derivation opaque.


5. Trigger Conditions

  1. Reach the target. No user device object exists on winhv.sys; obtain execution in a kernel context that drives VTL/VSM enablement (securekernel / VID / Hyper-V partition setup during VBS/HVCI configuration).
  2. Invoke the WinHv VTL-enable export that funnels into sub_1C00038BC.
  3. Ensure arg1 (the enable flag in cl/r14b) is non-zero so the per-VP hypercall branch is taken for each bitmap-tested VP.
  4. Arrange for the active processor count / group-affinity state observed at this call to differ from the count used to size the per-VP context/overlay arrays allocated at init in sub_1C00027A0, so the effective VP index the inner routine derives can exceed the array size.
  5. Let the loop reach an iteration where sub_1C0003B90 indexes past the per-VP context bound and issues its per-VP write.
  6. Observable effect. Out-of-range read/write of per-VP context/overlay memory inside sub_1C0003B90 (or the finalize sub_1C00035CC), manifesting as pool-tag corruption, an access to an unmapped per-VP overlay page, or a bugcheck (e.g., 0xA IRQL_NOT_LESS_OR_EQUAL, 0x139 KERNEL_SECURITY_CHECK_FAILURE, or a pool-corruption stop) during VTL enable.

Notes: The critical section is serialized by the fast mutex at data_1C0006600, so this is single-threaded per enable operation. The divergence between init-time array sizing (sub_1C00027A0) and enable-time processor/affinity state is the crux; reproducing it likely requires manipulating processor online/affinity state between init and the VTL-enable call. Reachability is bounded to privileged kernel callers, which limits real-world attack surface.


6. Exploit Primitive & Development Notes

  • Primitive: Controlled/relative out-of-bounds read/write of a kernel per-VP context or overlay page. The attacker (a kernel caller able to influence processor-count/affinity state) controls the VP index that exceeds the per-VP array sized at init, giving a bounded OOB into adjacent per-VP structures or overlay pages.
  • Turning it into a full exploit:
  • Groom the pool region adjacent to the per-VP context/overlay allocation from sub_1C00027A0 so a controllable victim object follows it.
  • Corrupt a targeted field in the neighboring object (function pointer, LIST_ENTRY, or a hypercall input-page pointer such as the data_1C0006068+0x30/0x48 per-processor page link).
  • Leak a kernel pointer if KASLR must be defeated (via a separate info-leak primitive; not provided by this bug directly).
  • Redirect control or perform a data-only privilege escalation depending on what the corrupted per-VP structure feeds.
  • Mitigations to consider:
  • kASLR: Corruption is relative to a kernel allocation; a separate leak is needed for absolute targeting.
  • SMEP / SMAP: Not relevant to a data-only OOB in pool; only matters if pivoting to user-controlled code/data.
  • CFG / CET: If a corrupted function pointer is used for control flow, kernel CFG (if applicable) constrains targets; a data-only path avoids this.
  • HVCI / VBS: Highly relevant — this bug lives in the VBS/VTL bring-up path itself; a data-only variant corrupting per-VP state may survive HVCI, but code-injection paths will not.
  • Pool hardening: Prefer corrupting object fields over pool headers to avoid header-integrity checks.
  • Realistic outcome: Kernel memory corruption / bugcheck during VBS bring-up; local privilege escalation only feasible for a caller already able to drive VTL enable with divergent processor/affinity state — narrow, but a kernel-integrity concern.

7. Debugger PoC Playbook

Assume WinDbg/KD attached to the UNPATCHED binary.

7.1 sub_1C00038BC (0x1C00038BC)

Breakpoints:

bp winhv+0x38bc    ; sub_1C00038BC entry: inspect cl/r14b (enable flag), then eax/esi = SizeOfBitMap
bp winhv+0x3b90    ; sub_1C0003B90 UNBOUNDED inner per-VP issuer: read the per-VP ptr/index it derefs
bp winhv+0x35cc    ; sub_1C00035CC per-VP finalize: watch VP index (ebx) vs per-VP array bound

Rebase note: RVAs above assume the analysis image base 0x1C0000000. If the loaded module base differs, subtract the analysis base (0x1C0000000) from the absolute addresses to obtain these RVAs (e.g., 0x1C00038BC - 0x1C0000000 = 0x38BC).

Inspect at each breakpoint: - At +0x38bc: Confirm cl/r14b (arg1 enable flag) is non-zero. After the KeQueryActiveProcessorCountEx call (call qword [rel 0x1c000c0c0] ~+0x3920), read eax/esi — this is SizeOfBitMap, the intended VP-count bound.

r esi                 ; SizeOfBitMap (active processor count)
dq winhv+0x65e8 L1    ; RTL_BITMAP buffer pointer (data_1C00065E8)
  • At +0x3b90: The smoking gun. Each loop iteration, inspect the per-VP context pointer/index the routine dereferences and compare it against esi (SizeOfBitMap) and against the per-VP array size established in sub_1C00027A0. Watch for an index/offset beyond the array.
r ebx                 ; current VP loop index (rbx_1)
dq winhv+0x6068 L1    ; per-processor page table base (data_1C0006068)
  • At +0x35cc: Watch ebx (VP index) exceeding the per-VP context array bound before RtlSetBit.

Trigger setup (from a kernel driver / KD): 1. There is no user-mode handle; drive VTL/VSM enablement through the WinHv VTL-enable export (VBS/HVCI or partition VTL configuration via securekernel/VID). 2. Ensure the enable flag (cl/r14b) is non-zero so the per-VP hypercall branch executes for each bitmap-tested VP. 3. Create a state where the active processor count / group affinity at this call differs from the count used when per-VP context/overlay pages were allocated in sub_1C00027A0 at init, then invoke the VTL-enable path.

Expected observation: In the unpatched binary, an OOB read or write of per-VP context/overlay memory inside sub_1C0003B90 or sub_1C00035CC when the effective VP index/count exceeds the per-VP array size — surfacing as pool-tag corruption, access to an unmapped overlay page, or a bugcheck (0xA, 0x139, or pool corruption). In the patched binary the same input is clamped by the SizeOfBitMap argument to sub_140003408 and stays in-bounds.

Struct / offset notes:

data_1C00065E8  RTL_BITMAP buffer (sized to SizeOfBitMap)
data_1C0006600  fast mutex guarding the VTL enable/disable section
data_1C00065BD  selector: 0 -> hypercall 0xA0011, non-zero -> 0xA1011
data_1C0006068  per-processor page table; per-VP pages linked at +0x30 / +0x48
sub_1C00027A0   init: allocates/links per-VP context/overlay pages
sub_1C0003B90   returns status in eax; negative (js) bails the loop

8. Changed Functions — Full Triage

  • sub_1C00038BC -> sub_1400030F4 (similarity 0.911, security_relevant): VTL per-VP enablement loop. Unpatched calls the inner per-VP issuer sub_1C0003B90() with no bound; patched calls sub_140003408(rdi, SizeOfBitMap, rdx_5), threading the active-processor-count bound and an affinity value — the actual fix for the OOB VP-index access. Outer wrapper also gained a 3rd arg and the finalize now takes the VP context explicitly.
  • sub_1C0002B04 -> sub_140001F00 (similarity 0.7966, behavioral): Core simple-hypercall issuer. Input-page pointer fetch refactored from a gs-based table to sub_140001150()+0x48; copies arg3 bytes via memmove into fixed 4KB per-processor / SLIST pages (paths 1–2) with no upper bound in either version. Not the fix, but the primary place a caller-supplied length reaches a fixed-size hypercall input page — attack-surface context. Path 3 (rbp<=0x10 into a 16-byte stack buffer) is correctly bounded.
  • sub_1C00011B0 -> sub_140003900 (similarity 0.5031, behavioral): Hypervisor-status-to-NTSTATUS translation table. Patched greatly expands the mapping (new 0xA0011, 0xA000C–0xA000F, 0xA0012–0xA0015, 0xA1002–0xA100F, and 0x240/0x90002/0x90003/0x90004/0x90019/0x90022). Feature/table growth to map newly introduced result codes; no memory-safety or authorization change. The low similarity reflects the table expansion, not mispairing.
  • sub_1C0002360 -> sub_1400016B0 (similarity 0.8586, behavioral): Generic rep-list hypercall page builder feeding WinHvGetVpRegisters (0x50) / WinHvSetVpRegisters (0x51). Per-page batching bound (0xFF0/inElemSize, 0x1000/outElemSize, per-batch min(bound, remaining)) and the returned-count-vs-remaining guard are preserved in both versions; intrinsics memset/memcpy were out-of-lined to sub_140004940/sub_140004640. Compiler-driven; no bound removed or added.
  • WinHvSignalEvent -> 0x140002BB0 (similarity 0.4409, behavioral): SignalEvent wrapper. Fast/slow-path selection inverted in form but equivalent (based on the enlightened/simple-hypercall capability flag data_67c1/d069); connection-id arg narrowed int64 -> int32; slow path builds an 8-byte input via slab allocator sub_140001520 and issues hypercall 0x5D. No validation or bounds change; low similarity is refactor/register churn.

Collapsed note on non-security changes: The remaining ~76 changed functions are recompilation noise across a wide version gap — register/stack reshuffling, out-of-lined memset/memcpy helpers, an expanded NTSTATUS mapping table, and equivalent-but-restructured wrappers — so the only memory-safety-relevant diff is the bounds argument added in sub_1C00038BC.


9. Unmatched Functions

  • Removed (unpatched only): None
  • Added (patched only): None
  • Implication: The fix is purely additive within an existing, matched function: the inner per-VP issuer gained an explicit SizeOfBitMap bound argument rather than introducing a new sanitizer routine. No functions were added or removed and there is no KIR/feature-flag gate — the hardening is unconditional in the patched enablement path.

10. Confidence & Caveats

  • Confidence: medium
  • Rationale:
  • The diff is unambiguous at the call-site level: a parameterless inner call (sub_1C0003B90()) becomes a bounded call (sub_140003408(rdi, SizeOfBitMap, rdx_5)) that receives exactly the active-processor count the caller computed for the RTL_BITMAP — a textbook "add the missing bound" fix idiom.
  • The surrounding structure (KeQueryActiveProcessorCountEx -> RtlInitializeBitMap -> per-VP loop -> RtlSetBit) is stable across versions and clearly identifies SizeOfBitMap as the intended VP-count bound.
  • Assumptions made:
  • That the inner routine actually performs a per-VP index/write governed by the passed bound (inferred from the argument being the active-processor count; the inner routine body was not fully disassembled here).
  • That the per-VP context/overlay arrays are sized at init in sub_1C00027A0 and can diverge from enable-time processor/affinity state — the precise divergence mechanism is not proven.
  • Reachability is assumed to be via privileged kernel callers (securekernel/VID); winhv.sys exposes no user IOCTL, so direct user-mode triggering is presumed impossible.
  • To verify before a PoC:
  • Disassemble sub_1C0003B90 (unpatched) and sub_140003408 (patched) to confirm the per-VP indexing operation and that the new SizeOfBitMap argument gates a write/read that was previously unbounded.
  • Determine the per-VP context/overlay array size and allocation site in sub_1C00027A0, and whether processor online/affinity state can realistically diverge from it between init and a VTL-enable call.
  • Reproduce the bugcheck by driving the VTL-enable export with a divergent active-processor/affinity state and breaking at winhv+0x3b90 to observe the OOB access before RtlSetBit.