msrpc.sys — Endpoint-map lookup tower-count clamp made unconditional (CWE-125), out-of-bounds free-loop hardening fixed
KB5094127
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 atthis+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.
- A kernel-mode RPC client binding that needs a dynamic endpoint calls into endpoint resolution:
ResolveEndpointWithEpMapper(0x1C0031B04) →EP_LOOKUP_DATA::ResolveEndpoint(0x1C0030CE8). EP_LOOKUP_DATA::LookupNextChunk(0x1C0031288) issues the endpoint-map RPC, filling the tower array atthis+0x2D8and the count atthis+0x2F8(capacity 4).EP_LOOKUP_DATA::ProcessNextChunk(0x1C0030F18) consumes the towers;EP_LOOKUP_DATA::FreeOldLookupData(0x1C0033FEC) frees them on reset/cleanup.- 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:
- Endpoint resolution reached. A kernel-mode RPC client binding resolves a dynamic endpoint via the endpoint mapper (
ResolveEndpointWithEpMapper→ResolveEndpoint→LookupNextChunk), which issues the endpoint-map RPC and populates the tower array atthis+0x2D8/ count atthis+0x2F8. - 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 inFreeOldLookupData. - Count field
> 4at cleanup time.this+0x2F8must exceed 4 whenFreeOldLookupDataruns.
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
FreeOldLookupDataentry (0x1C0033FEC),rcx= theEP_LOOKUP_DATAobject. Read[rcx+0x2F8](tower count) and the four slots at[rcx+0x2D8 .. 0x2F0]. - Return value of
Feature_666052921__private_IsEnabledDeviceUsage(eaxat0x1C0034070):0means 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(0x1C0033FEC→0x1C00331EC)- 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+0x2F8from 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 thewil_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 retargetingFeature_666052921__private_descriptorto 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.