1. Overview

Item Value
Unpatched binary msrpc_unpatched.sys (kernel RPC runtime)
Patched binary msrpc_patched.sys
Overall similarity 0.985
Matched functions 1055
Changed functions (logic) 1
Identical functions 1054
Unmatched (unpatched / patched) 0 / 0

Verdict: A single logic change in EP_LOOKUP_DATA::FreeOldLookupData (unpatched 0x1C0033FEC → patched 0x1C00331EC). Unpatched, the clamp that caps the tower-pointer array count at this+0x2F8 to 4 was wrapped in a WIL feature-staging gate (Feature_666052921__private_IsEnabledDeviceUsage); when that feature evaluated as disabled the clamp was skipped. The patch removes the gate and makes the clamp unconditional, so the cleanup loop over the fixed 4-slot array at this+0x2D8 can never iterate past its bounds regardless of feature state. This is a defense-in-depth hardening: the count originates from an endpoint-mapper RPC reply and is already bounded by NDR array validation present in both builds, so no attacker-reachable out-of-bounds primitive is demonstrated. Direction is correct (the patched build is the stricter one).


2. Vulnerability Summary

Finding #1 — Low Severity (defense-in-depth hardening)

  • Class: Conditional bounds clamp made unconditional. Guards a potential out-of-bounds read (CWE-125) of an in-pool pointer array whose entries are freed with ExFreePoolWithTag.
  • Affected function: EP_LOOKUP_DATA::FreeOldLookupData (cleanup of a client endpoint-map lookup).
  • What the clamp protects: the cleanup loop that frees the tower-pointer array at this+0x2D8 (four 8-byte slots) using the count stored at this+0x2F8.

Root cause. FreeOldLookupData frees each entry of a fixed 4-element pointer array at this+0x2D8, iterating i = 0..count where count = *(uint32)(this+0x2F8). The defensive clamp if (count > 4) count = 4 was conditioned on the WIL feature-staging gate Feature_666052921__private_IsEnabledDeviceUsage(): when the feature evaluated as disabled the gate returned 0 and the clamp was skipped (jz). If count were ever greater than 4 in that state, the loop would read array slots past index 3 (this+0x2F8 at index 4, then bytes beyond it) and pass any non-zero value to ExFreePoolWithTag(ptr, 'RpcM'). The patch deletes the gate call and makes the clamp straight-line, so the count is always capped at 4.

Where the count comes from. The array at this+0x2D8 and the count at this+0x2F8 are [out] parameters of the endpoint-map RPC issued in EP_LOOKUP_DATA::LookupNextChunk (0x1C0031288): the array base (lea rcx,[rdi+0x2D8]), the count pointer (lea rdx,[rdi+0x2F8]) and a hard-coded maximum of 4 are marshalled into NdrClientCall3 / Ndr64AsyncClientCall. The returned tower count is therefore supplied by whatever answers the endpoint-mapper lookup, but it is unmarshalled through the NDR conformant/varying-array path, which validates the returned length against the fixed capacity of 4. That NDR validation is byte-identical in both builds, so count > 4 is not demonstrably reachable through the normal path. The clamp in FreeOldLookupData is a redundant second line of defense.

Entry point & data flow.

  1. A kernel-mode RPC client binding that needs a dynamic endpoint calls into endpoint resolution: ResolveEndpointWithEpMapper (0x1C0031B04) → EP_LOOKUP_DATA::ResolveEndpoint (0x1C0030CE8).
  2. EP_LOOKUP_DATA::LookupNextChunk (0x1C0031288) issues the endpoint-map RPC, filling the tower array at this+0x2D8 and the count at this+0x2F8 (capacity 4).
  3. EP_LOOKUP_DATA::ProcessNextChunk (0x1C0030F18) consumes the towers; EP_LOOKUP_DATA::FreeOldLookupData (0x1C0033FEC) frees them on reset/cleanup.
  4. In the unpatched build the free loop's clamp is skipped when the WIL feature evaluates disabled.

Note that ProcessNextChunk iterates the same array using the same this+0x2F8 count with no clamp in either build, so the effective bound on the loop is the NDR validation in step 2, not the FreeOldLookupData clamp. This is the main reason the change is defense-in-depth rather than a fix for a reachable bug.


3. Pseudocode Diff

// ===== UNPATCHED: EP_LOOKUP_DATA::FreeOldLookupData @ 0x1C0033FEC =====
// (first loop over the +0xC0 array and the RpcSsDestroyClientContext /
//  RpcBindingFree cleanups are identical in both builds and omitted here)

bool feature_off = (Feature_666052921__private_IsEnabledDeviceUsage() == 0); // *** WIL gate ***
uint32_t count = *(uint32_t *)(this + 0x2F8);                                // tower-array count

if (!feature_off && count > 4) {                                            // *** clamp GATED ***
    *(uint32_t *)(this + 0x2F8) = 4;
    count = 4;
}

for (uint32_t i = 0; i < count; i++) {                                      // bound = possibly-unclamped count
    void *p = *(void **)(this + 0x2D8 + i * 8);
    if (p) ExFreePoolWithTag(p, 0x4D637052);                               // 'RpcM'
    *(void **)(this + 0x2D8 + i * 8) = 0;
}

// ===== PATCHED: EP_LOOKUP_DATA::FreeOldLookupData @ 0x1C00331EC =====
uint32_t count = *(uint32_t *)(this + 0x2F8);

if (count > 4) {                                                           // *** ALWAYS clamp ***
    *(uint32_t *)(this + 0x2F8) = 4;
    count = 4;
}
// (free loop unchanged)

Gate internals (Feature_666052921__private_IsEnabledDeviceUsage, 0x1C001B008):

if ((Feature_666052921__private_featureState & 0x10) != 0)   // bit 0x10 = enabled-state cached
    return Feature_666052921__private_featureState & 1;      // bit 0 = enabled
else
    return Feature_666052921__private_IsEnabledFallback(state, 3); // -> wil_details_IsEnabledFallback

This is Windows WIL (feature-staging / velocity) machinery, not a WNF query. In the patched build the feature 666052921 is removed entirely: its gate, its private descriptor, and the feature-specific WIL helper functions are gone, and references to Feature_666052921__private_descriptor are retargeted to the generic feature-descriptor table.


4. Assembly Analysis

UNPATCHED — EP_LOOKUP_DATA::FreeOldLookupData @ 0x1C0033FEC

; --- gate + conditional clamp region ---
00000001C003406B  call    Feature_666052921__private_IsEnabledDeviceUsage  ; *** WIL gate (removed in patch) ***
00000001C0034070  test    eax, eax
00000001C0034072  mov     eax, [rbx+2F8h]                 ; eax = tower-array count
00000001C0034078  jz      short loc_1C003408B             ; *** feature disabled -> SKIP clamp ***
00000001C003407A  mov     ecx, 4
00000001C003407F  cmp     eax, ecx
00000001C0034081  jbe     short loc_1C003408B             ; count <= 4 -> skip clamp
00000001C0034083  mov     [rbx+2F8h], ecx                 ; count = 4 (clamp, GATED)
00000001C0034089  mov     eax, ecx
; --- free loop over the 4-slot array at +0x2D8 ---
00000001C003408B  xor     edi, edi
00000001C003408D  test    eax, eax
00000001C003408F  jz      short loc_1C00340C2
00000001C0034091  mov     rcx, [rbx+rdi*8+2D8h]           ; array slot (OOB if edi >= 4)
00000001C0034099  test    rcx, rcx
00000001C003409C  jz      short loc_1C00340AF
00000001C003409E  mov     edx, 4D637052h                 ; 'RpcM'
00000001C00340A3  call    cs:__imp_ExFreePoolWithTag
00000001C00340AF  and     qword ptr [rbx+rdi*8+2D8h], 0
00000001C00340B8  inc     edi
00000001C00340BA  cmp     edi, [rbx+2F8h]                 ; loop bound = possibly-unclamped count
00000001C00340C0  jb      short loc_1C0034091
; --- tail: free single aux ptr at +0x2D0 (identical in both builds) ---
00000001C00340C2  mov     rcx, [rbx+2D0h]
00000001C00340C9  test    rcx, rcx
00000001C00340CC  jz      short loc_1C00340E7
00000001C00340CE  mov     edx, 4D637052h
00000001C00340D3  call    cs:__imp_ExFreePoolWithTag
00000001C00340DF  and     qword ptr [rbx+2D0h], 0
00000001C00340E7  mov     rbx, [rsp+28h+arg_0]
00000001C00340F6  retn

PATCHED — EP_LOOKUP_DATA::FreeOldLookupData @ 0x1C00331EC (clamp region)

00000001C003326B  mov     eax, [rbx+2F8h]                 ; load count
00000001C0033271  mov     ecx, 4
00000001C0033276  cmp     eax, ecx
00000001C0033278  jbe     short loc_1C0033282             ; count <= 4 -> skip clamp
00000001C003327A  mov     [rbx+2F8h], ecx                 ; *** ALWAYS clamp to 4 ***
00000001C0033280  mov     eax, ecx
00000001C0033282  xor     edi, edi                        ; free loop follows, unchanged

Key contrasts: - The call Feature_666052921__private_IsEnabledDeviceUsage at 0x1C003406B is gone. - The jz at 0x1C0034078 that skipped the clamp when the feature evaluated disabled is gone — the clamp is now straight-line.


5. Trigger Conditions

The pre-patch skip of the clamp requires all of:

  1. Endpoint resolution reached. A kernel-mode RPC client binding resolves a dynamic endpoint via the endpoint mapper (ResolveEndpointWithEpMapperResolveEndpointLookupNextChunk), which issues the endpoint-map RPC and populates the tower array at this+0x2D8 / count at this+0x2F8.
  2. WIL feature disabled. Feature_666052921__private_IsEnabledDeviceUsage() must return 0 (feature-state bit 0x10 clear routes to the WIL fallback; or bit 0x10 set with bit 0 clear). Only then is the clamp skipped in FreeOldLookupData.
  3. Count field > 4 at cleanup time. this+0x2F8 must exceed 4 when FreeOldLookupData runs.

Condition 3 is the limiting factor. The count is written only by NDR unmarshalling of the endpoint-map reply into a fixed 4-entry [out] array, whose returned length is validated by the NDR conformant/varying-array path (identical in both builds). No path was found that stores this+0x2F8 > 4 past that validation. ProcessNextChunk also iterates this array using the unclamped count in both builds, so the delivered clamp is not the load-bearing bound on the loop.


6. Impact Assessment

What the clamp guards. If this+0x2F8 ever held a value greater than 4 with the feature disabled, FreeOldLookupData would read array slots beyond index 3 (an out-of-bounds read of adjacent EP_LOOKUP_DATA fields and, for larger counts, bytes past the structure) and pass any non-zero 8-byte value to ExFreePoolWithTag. That would be a kernel out-of-bounds read whose read-back pointers feed a pool free — a pool-corruption hazard.

Why no reachable primitive is claimed. The count is not directly attacker-controlled from a local user-mode call: it is the tower count returned by the endpoint-mapper lookup and is bounded to the fixed capacity of 4 by NDR array validation present in both builds. Reaching count > 4 would require that validation to be bypassed, in which case the unclamped ProcessNextChunk loop (unchanged by this patch) would already read out of bounds first. This patch therefore hardens one of two consumers of the same count, closing a "what if the count is ever out of range" gap rather than a demonstrated exploitable condition.

Severity: Low. A genuine, correct-direction defensive change (a bounds clamp graduated from feature-staged to unconditional) with no demonstrated attacker-reachable out-of-bounds or arbitrary-free primitive.


7. Verification Notes (UNPATCHED target)

Addresses are file-relative to image base 0x1C000000; add the runtime module base of msrpc.sys before use.

Points of interest

Offset Instruction Significance
0x1C003406B call Feature_666052921__private_IsEnabledDeviceUsage WIL feature gate — removed by patch
0x1C0034078 jz 0x1C003408B Skips the clamp when the feature evaluates disabled
0x1C0034083 mov [rbx+2F8h], ecx The clamp store (count = 4)
0x1C0034091 mov rcx, [rbx+rdi*8+2D8h] Free-loop array read (out of bounds only if count > 4)
0x1C00340A3 call ExFreePoolWithTag Frees the array entry with tag 'RpcM'
0x1C00340BA cmp edi, [rbx+2F8h] Loop bound = count field

What to inspect

  • At FreeOldLookupData entry (0x1C0033FEC), rcx = the EP_LOOKUP_DATA object. Read [rcx+0x2F8] (tower count) and the four slots at [rcx+0x2D8 .. 0x2F0].
  • Return value of Feature_666052921__private_IsEnabledDeviceUsage (eax at 0x1C0034070): 0 means the clamp is skipped.
  • To confirm the count is bounded upstream, set a write watchpoint on [rcx+0x2F8] and observe that it is written only by the endpoint-map unmarshalling and never exceeds 4 on the normal path.

Struct / offset notes (EP_LOOKUP_DATA)

Offset Field
+0xB4 Count of the first pointer array
+0xC0 First pointer array (freed in a separate, identically-bounded loop)
+0x2C0 RpcBindingFree handle
+0x2C8 RpcSsDestroyClientContext context handle
+0x2D0 Single auxiliary pointer (freed separately)
+0x2D8, +0x2E0, +0x2E8, +0x2F0 The 4-element tower-pointer array
+0x2F8 Count for that array — the clamped field

Freed entries use tag 'RpcM' (0x4D637052). The gate Feature_666052921__private_IsEnabledDeviceUsage reads the global Feature_666052921__private_featureState; if bit 0x10 is clear it routes to wil_details_IsEnabledFallback. The sub_-style symbols shown are internal; use the raw offsets with the module base added at runtime.


8. Changed Functions — Full Triage

Independent content-based comparison of both builds (matching functions by name across relocation) shows exactly one function with a real logic change; every other difference is a mechanical consequence of removing the WIL feature 666052921.

  • EP_LOOKUP_DATA::FreeOldLookupData (0x1C0033FEC0x1C00331EC)
  • Similarity: ~0.95
  • Change type: security_relevant (defense-in-depth hardening)
  • Removed the call to the WIL feature-staging gate Feature_666052921__private_IsEnabledDeviceUsage().
  • Changed the count clamp on this+0x2F8 from gated (if (feature_enabled && count > 4) count = 4) to unconditional (if (count > 4) count = 4).
  • Callee set drops the gate (3 → 2); one basic block shorter.

  • Removed WIL feature-staging symbols (present only in the unpatched build, all part of feature 666052921's retirement): Feature_666052921__private_IsEnabledDeviceUsage, Feature_666052921__private_IsEnabledFallback, and the wil_details_* / wil_*StagingConfig* helpers that supported it (wil_details_IsEnabledFallback, wil_details_StagingConfig_Load, wil_details_StagingConfig_QueryFeatureState, wil_StagingConfig_QueryFeatureState, wil_RtlStagingConfig_QueryFeatureState, wil_details_FeatureStateCache_*, wil_details_GetCurrentFeatureEnabledState, wil_details_FeatureReporting_*, wil_details_MapReportingKind, wil_details_StagingConfigFeature_HasUniqueState). These are servicing/feature-flag infrastructure, not security logic.

  • Relocation-only differences in ~56 NDR and WIL functions: the removal of the feature shrank a global feature-descriptor table, shifting the base of the shared type-format/increment lookup tables by 0x10 bytes. This appears as displacement changes such as 0xB800 → 0xB7F0, 0xB5C0 → 0xB5B0, 0xB680 → 0xB670, 0xB740 → 0xB730, 0xB4E5 → 0xB4D5, and as shifted jump-table annotation addresses. The instruction logic of these functions is otherwise identical between builds; none is a security change. The remaining WIL functions that still exist in both builds differ only by retargeting Feature_666052921__private_descriptor to the generic descriptor table (wil_details_featureDescriptors_a).

No security-relevant change was omitted by this report.


9. Unmatched Functions

removed (unpatched-only): Feature_666052921 gate + its WIL staging helper functions (feature retirement)
added (patched-only):     []

The only removed functions are the WIL feature-staging machinery for feature 666052921. No functions were added.


10. Confidence & Caveats

Confidence: High on the mechanism and direction. The diff is a WIL-feature-gated bounds clamp made unconditional, confirmed in both the decompilation and the disassembly of EP_LOOKUP_DATA::FreeOldLookupData, together with the removal of the feature's staging code.

Caveats: - The tower count at this+0x2F8 originates from the endpoint-mapper lookup reply and is bounded by NDR conformant/varying-array validation present in both builds; no path was found that drives it above 4 past that validation. The clamp is therefore defense-in-depth, and no attacker-reachable out-of-bounds or arbitrary-free primitive is demonstrated. - ProcessNextChunk consumes the same array with the unclamped count in both builds, so the effective loop bound is the NDR validation, not this clamp. - Feature 666052921 is retired in the patched build; the change is consistent with graduating a staged mitigation to always-on.