winhv.sys — Missing Active-Processor Bound in VTL Per-VP Hypercall Path
KB5094128
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_BITMAPof that size, then loops over VP indices issuing a per-VP hypercall through the inner routinesub_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_1C00065E8—RTL_BITMAPbuffer, sized toSizeOfBitMap(active processor count).data_1C0006600— fast mutex guarding the enable/disable critical section.data_1C00065BD— selects hypercall code0xA0011vs0xA1011.- Per-VP context/overlay pages — allocated at init in
sub_1C00027A0, linked via thedata_1C0006068table 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()withsub_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.sysexposes 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.sysVTL-enable exportsub_1C00038BC(0x1C00038BC) — iterate active processors, RTL_BITMAP overdata_1C00065E8sub_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
- 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). - Invoke the WinHv VTL-enable export that funnels into
sub_1C00038BC. - Ensure
arg1(the enable flag incl/r14b) is non-zero so the per-VP hypercall branch is taken for each bitmap-tested VP. - 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. - Let the loop reach an iteration where
sub_1C0003B90indexes past the per-VP context bound and issues its per-VP write. - Observable effect. Out-of-range read/write of per-VP context/overlay memory inside
sub_1C0003B90(or the finalizesub_1C00035CC), manifesting as pool-tag corruption, an access to an unmapped per-VP overlay page, or a bugcheck (e.g.,0xAIRQL_NOT_LESS_OR_EQUAL,0x139KERNEL_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_1C00027A0so 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 thedata_1C0006068+0x30/0x48per-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 insub_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 beforeRtlSetBit.
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 issuersub_1C0003B90()with no bound; patched callssub_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 tosub_140001150()+0x48; copiesarg3bytes viamemmoveinto 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<=0x10into 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 (new0xA0011,0xA000C–0xA000F,0xA0012–0xA0015,0xA1002–0xA100F, and0x240/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 feedingWinHvGetVpRegisters(0x50) /WinHvSetVpRegisters(0x51). Per-page batching bound (0xFF0/inElemSize,0x1000/outElemSize, per-batchmin(bound, remaining)) and the returned-count-vs-remaining guard are preserved in both versions; intrinsicsmemset/memcpywere out-of-lined tosub_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 flagdata_67c1/d069); connection-id arg narrowedint64 -> int32; slow path builds an 8-byte input via slab allocatorsub_140001520and issues hypercall0x5D. 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
SizeOfBitMapbound 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
SizeOfBitMapas 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_1C00027A0and 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.sysexposes no user IOCTL, so direct user-mode triggering is presumed impossible. - To verify before a PoC:
- Disassemble
sub_1C0003B90(unpatched) andsub_140003408(patched) to confirm the per-VP indexing operation and that the newSizeOfBitMapargument 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+0x3b90to observe the OOB access beforeRtlSetBit.