- Affected binary:
tcpip.sys10.0.19041.6926 → 10.0.19041.7181 - CVE: CVE-2026-33827
- Patch date: 2026-04-14
- CVSS: 8.1
- Patch KB: KB5082052 and related
TL;DR
tcpip.sys processes the IPv4 Loose/Strict Source and Record Route (LSRR/SSRR) option inside Ipv4pReceiveRoutingHeader. When the host is configured to forward a source-routed datagram, the routine looks up an IPP_PATH to the next address in the route option, pulls the next hop out of it, and then clones and forwards the packet. In the vulnerable build the code dropped its reference on that path object — including calling IppCleanupPathPrimitive when the count reached zero — immediately after extracting the next hop, but then kept dereferencing the same path pointer (IppGetRouteFromPath, next-hop comparisons, clone construction) further down. That is a classic release-then-use, and because receive processing runs concurrently across RSS processors the last-reference drop races other holders, turning it into an exploitable use-after-free on a pool-backed network structure. The patch introduces a proper IppDereferencePath helper that performs the interlocked decrement plus teardown as one operation, and — gated behind Feature_221233465 — holds the path (and route) reference for the entire lifetime of the forwarding operation, releasing it only in a unified cleanup epilogue.
Background
IPv4 source routing lets the sender embed a list of intermediate hops in an IP option (Loose Source and Record Route, type 131 / Strict Source and Record Route, type 137). A router that honors these options must, for each datagram, pick the next address from the option, resolve a route toward it, rewrite the option pointer, and forward the packet out toward that hop. Windows disables source-route forwarding by default, but the code path is still present and reachable when the relevant global/interface knobs are set — and the vulnerable logic is guarded by a global (DAT_2 == 2 short-circuits it) plus the per-interface forwarding flags checked at the top of the function.
The unit of work in Ipv4pReceiveRoutingHeader is a receive-batch element (param_1), a large structure that carries the current NBL (param_1[1]), parsed header offsets, the source/destination addresses, and status fields. The interesting object for this bug is the path, IPP_PATH, returned indirectly through IppRouteToDestinationInternal. In the decompilation the path is a pointer with:
- a reference count at offset
+0x50, - a “last used” timestamp field at
+0x54(written from the global tick counter_DAT_5 / 10000), - a cached next hop obtained via
IppGetNextHopFromPath, and - a cached route obtained via
IppGetRouteFromPath.
The next hop is a discriminated object identified by a 4-byte signature at offset 0: 0x616c7049 ("Ipla", a local address) or 0x656e7049 ("Ipne", a neighbor). The forwarding code takes a reference on whichever it is (CarAcquireCacheAwareReference for a local address, an interlocked increment for a neighbor) and later releases via IppDereferenceLocalAddress / IppDereferenceNeighbor. Paths are freed through IppCleanupPathPrimitive, which tears down the embedded route, next hop, and source address and returns the block to its FSB pool (FsbFree).
Root Cause
In the 6926 build, once a route to the next source-route hop is resolved, the function does this (edited for clarity):
iVar16 = IppRouteToDestinationInternal(local_80, piVar3, lVar15, lVar10);
if (iVar16 < 0) {
plVar11 = (longlong *)0x0;
}
else {
plVar11 = (longlong *)IppGetNextHopFromPath(local_a0); // cache next hop
// stamp path "last used" time at +0x54
*(int *)(local_a0 + 0x54) = /* current ticks */;
if (*(int *)(local_a0 + 0x50) == 0) {
KeBugCheck(0x1c); // refcount sanity
}
LOCK();
piVar1 = (int *)(local_a0 + 0x50);
iVar16 = *piVar1;
*piVar1 = *piVar1 + -1; // drop path reference
UNLOCK();
if (iVar16 == 1) {
IppCleanupPathPrimitive(local_a0); // ...and free it if last
}
}
if (plVar11 != (longlong *)0x0) {
if (((int)*plVar11 == 0x616c7049) && ((int)plVar11[3] != 1)) goto LAB_1;
if ((*(byte *)((longlong)param_1 + 0xb1) & 0x10) != 0) {
lVar10 = IppGetRouteFromPath(local_a0); // USE-AFTER-FREE
...
local_a0 is the path. The block above decrements its reference count at +0x50 and, when that count transitions 1 -> 0, calls IppCleanupPathPrimitive(local_a0), which frees the object back to its pool. Yet the very next if (plVar11 != NULL) branch keeps operating on local_a0: IppGetRouteFromPath(local_a0) reads the route pointer out of the path it may have just freed, and the broader forwarding path continues to depend on the route/next-hop state that belongs to that path.
Two things make this a real, exploitable defect rather than a theoretical one:
-
The reference being dropped is the working reference. The path here is the one produced by the route lookup on this code path; the
KeBugCheck(0x1c)guard confirms the developers already knew hitting a zero count here is fatal. Nothing re-acquires the reference before the later uses. -
Receive processing is concurrent. IPv4 receive runs on multiple RSS processors, and paths are shared, cached objects in the compartment’s path set. Another context validating, garbage-collecting (
IppGarbageCollectPaths,IppFlushPaths), or otherwise dereferencing the same path can drive the count to zero in a window around this decrement. The MSRC classification — “concurrent execution using shared resource with improper synchronization” — is precisely this: the lifetime of the path spans a clone/forward sequence, but the code released it mid-sequence, so a racing releaser (or simply being the last holder) frees the block whileIppGetRouteFromPathand the clone logic still read through it.
Because IppGetRouteFromPath returns *(path + 0x14) and the subsequent forwarding logic chases route/next-hop/interface pointers out of that freed allocation, an attacker who can reclaim the freed IPP_PATH pool block with controlled data steers those reads — and the reference-count manipulations on the “route” and “next hop” it hands back — into attacker-controlled memory.
The Patch
The 7181 build restructures the whole tail of Ipv4pReceiveRoutingHeader around a new reference-counting helper and a feature gate. There are two code-changed functions plus a cluster of newly imported WIL/feature-staging helpers.
IppCleanupPathPrimitive → IppDereferencePath
The old IppCleanupPathPrimitive (which unconditionally tore down and freed a path) is joined by a new function, IppDereferencePath, that folds the interlocked decrement and the sanity KeBugCheck into the teardown:
int IppDereferencePath(longlong *param_1)
{
// stamp last-used time at +0x54
*(int *)((longlong)param_1 + 0x54) = /* current ticks */;
if ((int)param_1[10] == 0) { // count at +0x50
KeBugCheck(0x1c);
}
LOCK();
lVar4 = *(param_1 + 10);
*(int *)(param_1 + 10) = (int)lVar4 + -1;
UNLOCK();
iVar5 = (int)lVar4 + -1;
if (iVar5 == 0) {
// exact body of the old IppCleanupPathPrimitive:
// drop route, drop local-addr/neighbor next hop, ExFreePoolWithTag, FsbFree
}
return iVar5;
}
This is the key hygiene change: the “decrement, check, and free-if-last” dance that was open-coded inline (and misplaced) is now a single primitive that callers invoke when they are actually done with the path. The standalone IppCleanupPathPrimitive still exists for the paths that genuinely need unconditional teardown (its caller list loses only Ipv4pReceiveRoutingHeader), while every place that used to hand-roll the decrement now calls IppDereferencePath — note the new IppDereferencePath entries appearing in the caller lists of IppDereferenceRoute, IppDereferenceNeighbor, IppDereferenceLocalAddress, FsbFree, and ExFreePoolWithTag.
Ipv4pReceiveRoutingHeader: hold the reference to the end
In the patched routine, the early inline decrement/cleanup is gone. After IppRouteToDestinationInternal succeeds, the next hop is fetched and the path reference is retained:
pppppppiVar10 = (int *******)IppGetNextHopFromPath(local_b8);
iVar15 = Feature_221233465__private_IsEnabledDeviceUsage();
pppppppiVar20 = pppppppiVar18; // keep the path reference
if (iVar15 == 0) {
IppDereferencePath(pppppppiVar18); // legacy behavior only when feature OFF
}
When the feature is enabled, pppppppiVar20 keeps the live path reference and the code proceeds through the full forwarding sequence — IppGetRouteFromPath(pppppppiVar20), the next-hop signature checks, NetioAllocateAndReferenceCloneNetBufferList, RtlCopyMdlToMdl, option rewrite, IppCopyPacket, and IppForwardPackets — all while the path is still referenced. Only in the shared epilogue is everything released, in order:
// drop the next hop (local addr or neighbor)
if (pppppppiVar10 != 0) {
if (*(int *)pppppppiVar10 == 0x616c7049) IppDereferenceLocalAddress(pppppppiVar10);
else if (*(int *)pppppppiVar10 == 0x656e7049) IppDereferenceNeighbor(pppppppiVar10);
}
iVar15 = Feature_221233465__private_IsEnabledDeviceUsage();
if (iVar15 != 0) {
if (pppppppiVar20 != 0) { // now release the path
IppDereferencePath(pppppppiVar20);
local_b8 = 0;
}
if (local_a8 != 0) { // and the route, once
IppDereferenceRoute(local_a8);
local_a8 = 0;
}
}
The route obtained via IppGetRouteFromPath is likewise cached in local_a8 and released exactly once at the end, instead of the old pattern where the route was dereferenced inline (IppDereferenceRoute(lVar10)) in several branches while the path had already been dropped. The result is a single, well-defined ownership window: the path and its route are live from lookup through forward completion, and torn down together afterward — eliminating both the premature free and the double-handling of the route pointer.
Feature_221233465 gating
The fix is wrapped in the WIL feature-staging machinery newly pulled into tcpip.sys: Feature_221233465__private_IsEnabledDeviceUsage (and its fallback → wil_details_IsEnabledFallback → wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState → RtlQueryFeatureConfiguration / ZwQueryWnfStateData). Per the standard reading of these gates, adding a Feature_* check around new logic marks a newly staged fix: with the feature off, Ipv4pReceiveRoutingHeader still performs an early IppDereferencePath (the legacy-shaped behavior), and only with the feature on does the corrected “hold to the end” lifetime take effect. This lets Microsoft roll the corrected reference discipline out under Velocity/A-B control rather than flipping it unconditionally. The many added wil_details_* functions (StagingConfig_Load, StagingConfig_QueryFeatureState, FeatureReporting_*) are the supporting cache/telemetry plumbing for that gate, not part of the bug itself.
Exploitability
The primitive is a use-after-free of a pool-backed IPP_PATH driven entirely by a received IPv4 packet, giving CVSS 8.1 / “remote code execution.” Reachability:
- Remote, unauthenticated. The trigger is an IPv4 datagram carrying a source-route option (type 131/137) whose option header passes the parsing checks at the top of
Ipv4pReceiveRoutingHeader(correct option length,pbVar4[9] != 0x2b, header length equal to(*pbVar4 & 0xf) << 2, etc.). No credentials or local access are needed — the packet just has to reach a host that honors source-route forwarding. - Configuration gate. The vulnerable branch requires the interface to be in a forwarding/source-routing configuration (
DAT_2 != 2, forwarding flags set onparam_1). Default installs disable source-route forwarding, which is the main practical mitigation and why this is 8.1 rather than a wormable 9.8; routers, gateways, ICS/appliance images, and hosts with source routing explicitly enabled are the exposed population. - The race window. Two ways to reach the freed state: (a) be the last reference holder so the inline
1 -> 0decrement itself frees the path and the immediately followingIppGetRouteFromPathreads freed memory; or (b) win the concurrent race — a second receive/GC context on another RSS processor drops the path’s last reference during the window between the decrement and the later dereferences. Attackers can widen this window and improve reclamation odds by flooding source-routed packets across multiple queues (RSS spreads them by flow hash), keeping the compartment’s path set churning while grooming the FSB/pool with same-sized allocations to reclaim the freedIPP_PATHwith controlled bytes. - Why it’s practical despite “high complexity.” The freed object is chased for pointer fields that the code then dereferences and reference-counts:
IppGetRouteFromPathyieldspath+0x14(the route), the next-hop signature is read from offset 0 to decide betweenCarAcquireCacheAwareReferenceand an interlocked increment, and the clone path copies attacker-influenced option bytes. Controlling the reclaimed contents converts the UAF into a controlled fake-route / fake-next-hop dereference and reference-count manipulation on attacker memory — the standard on-ramp from a network-reachable kernel UAF to arbitrary write and code execution. The pre-patchKeBugCheck(0x1c)only catches an already-zero count before the decrement; it does nothing about the count reaching zero at the decrement or a racing releaser, which is exactly the exploited condition.
The patch closes the window completely by never dropping the path (or its route) until the forwarding operation that depends on them has fully finished, and by centralizing the decrement-and-free in IppDereferencePath so the “free if last” transition can no longer be separated from the point where the caller relinquishes ownership.