tcpip.sys — Out-of-bounds pool write in FseProcessIncomingMessages (unbounded length-prefixed reassembly into a fixed 0x400 buffer)
KB5093998
1. Overview
| Item | Value |
|---|---|
| Unpatched binary | tcpip_unpatched.sys |
| Patched binary | tcpip_patched.sys |
| Overall similarity | 0.9925 |
| Matched functions | 5746 |
| Changed functions | 2 |
| Identical functions | 5744 |
| Unmatched (unpatched / patched) | 0 / 0 |
Verdict: The patch closes a network-reachable, kernel-pool out-of-bounds write in FseProcessIncomingMessages. The function reassembles 4-byte length-prefixed messages into a fixed 1024-byte (0x400) buffer embedded inside a pool-allocated flow-context object, and the streaming/leftover buffering path copies the full attacker-controlled incoming segment length with no bound against the buffer size.
Of the two changed functions, only one is the memory-safety fix. FseProcessIncomingMessages (similarity 0.697) receives the actual bounds-check additions and is the subject of this writeup. IppCreateForwardPath (similarity 0.8814) is an unrelated concurrency/lifetime hardening change (reordering of a "ready" flag under a lock) with no buffer-size or copy-length impact and is not attacker-triggerable as a memory-safety bug. The real fix is concentrated entirely in the streaming/append paths of FseProcessIncomingMessages and is gated behind Feature_4058244411__private_IsEnabledDeviceUsage().
2. Vulnerability Summary
2.1 HIGH — Out-of-bounds write / pool buffer overflow (CWE-787 -> CWE-120)
- Affected function:
FseProcessIncomingMessages (sub_1c01d1214)(unpatched),FseProcessIncomingMessages (sub_1c01d1214)(patched) — reassembles 4-byte length-prefixed messages arriving on the tcpip fast-path/segment engine into a fixed reassembly buffer inside the flow-context object. - Root cause: The per-message length prefix is range-validated to
[0x21,0x400]only in the header-parse branch. The streaming/leftover buffering path, reached when the buffered byte-count is 0, copies the entire remaining incoming segment length into the 0x400 buffer with no size check. Relevant object layout: flow_ctx = *(*(arg1+0x20)+0xa8)— pool-allocated flow-context objectflow_ctx+0x88— start of the fixed reassembly buffer, size0x400(1024 bytes), ends at+0x488flow_ctx+0x488—uint32buffered byte-countarg1+0x30— incoming data pointer (rsi)arg2+0x38— incoming byte length (edi, attacker-controlled)
The exact flawed operation:
0x1c01d14cc: mov r8d, edi ; copy length = full leftover, UNBOUNDED
0x1c01d14cf: lea rcx, [rbx+0x88] ; dest = flow_ctx+0x88 (0x400 buffer)
0x1c01d14d6: call 0x1c00d4d40 ; memcpy(rbx+0x88, rsi, edi) <== OOB WRITE
0x1c01d14db: mov dword [rbx+0x488], edi ; count = edi (can exceed 0x400)
- Why exploitable:
edi(the incoming length from*(arg2+0x38)) is fully attacker-controlled and is never compared against0x400. An attacker who sends a single segment longer than 1024 bytes whose leading 4-byte length prefix declares a value larger than the delivered segment length forces the "incomplete message" branch, resulting in a controlled-length, controlled-content copy that overflows the 0x400 buffer, clobbering the trailing count field at+0x488and any adjacent non-paged pool allocation. - What the patch does: Wraps every append
memcpyin guards gated byFeature_4058244411__private_IsEnabledDeviceUsage(). Before each copy it performs a checkedRtlUIntAdd(current_count, incoming_len, &sum)(call to0x1c00d490c) and explicitsum > 0x400/next_msg_len > 0x400 || incoming_len > 0x400comparisons, taking a reject path (result=0) and emitting WPP trace events (0x2c–0x31) when the accumulated size would exceed the buffer. Note the fix is feature-flag gated; if the feature is disabled the old path could still execute. - Attacker-reachable entry point: tcpip.sys FSE (fast-path/segment engine) receive indication — network-driven. Caller passes
arg1with incoming data pointer at+0x30and flow-context at*(arg1+0x20)+0xa8, andarg2with the incoming byte length at+0x38. - Call chain:
- Network receive indication (NBL) into tcpip.sys fast-path
- FSE receive/dispatch handler (indicates buffered segment data to the segment engine)
FseProcessIncomingMessages (sub_1c01d1214)memcpy(flow_ctx+0x88, data, len)at0x1c01d14cc/0x1c01d14d6(unbounded); also the header-branch partial append at0x1c01d12f7
3. Pseudocode Diff
3.1 FseProcessIncomingMessages (sub_1c01d1214)
// UNPATCHED — FseProcessIncomingMessages (sub_1c01d1214)
void* rsi = *(arg1 + 0x30); // incoming data ptr
uint64_t rdi = *(arg2 + 0x38); // incoming length (ATTACKER-CONTROLLED)
void* rbx = *(*(arg1 + 0x20) + 0xa8); // flow ctx; buffer @ rbx+0x88, count @ rbx+0x488
if (!*(rbx + 0x488)) goto label_1c01d1478; // buffered count == 0 -> streaming loop
// ... header-accumulation path omitted ...
label_1c01d1478:
if (!rdi) { result = 1; }
else while (true) {
void* rdx_11 = rsi;
if (rdi > 0x20) {
uint64_t r8_9 = *(uint32_t*)rsi; // next declared message length, range UNVALIDATED
if (r8_9 <= rdi) { // complete message -> consume it
FseProcessIncomingMessage(rbx, rdx_11, r8_9);
rsi += r8_9; rdi -= r8_9;
continue;
}
}
// incomplete trailing message -> buffer whatever remains:
memcpy(rbx + 0x88, rdx_11, rdi); // <== OOB: rdi NEVER checked <= 0x400
*(uint32_t*)(rbx + 0x488) = rdi; // count set to rdi (can exceed 0x400)
break;
}
// header-branch partial-append variant (rbp>=4, valid in-range len) is BOUNDED here:
// memcpy(rbx+0x88+rbp, rsi, rdi); *(rbx+0x488) += rdi;
// reached only when rdi < declared_len - rbp, and declared_len is range-checked to
// [0x21,0x400], so rbp+rdi < declared_len <= 0x400. Not an independent overflow.
// PATCHED — FseProcessIncomingMessages (sub_1c01d1214)
// Every append is now preceded by Feature_4058244411-gated bounds checks:
if (Feature_4058244411__private_IsEnabledDeviceUsage()) {
uint32_t next_msg_len = *(uint32_t*)rsi;
if (next_msg_len > 0x400 || rdi > 0x400) { // reject over-length units
WPP_trace(0x2c /* .. 0x31 */);
result = 0; goto reject;
}
uint32_t sum = 0;
if (RtlUIntAdd(*(uint32_t*)(rbx + 0x488), rdi, &sum) < 0) { // overflow-checked add
WPP_trace(...); result = 0; goto reject;
}
if (sum > 0x400) { // accumulated size exceeds buffer
WPP_trace(0x2e /* 0x2f / 0x31 */);
result = 0; goto reject;
}
}
memcpy(rbx + 0x88, rsi, rdi); // only reached when count + rdi <= 0x400
*(uint32_t*)(rbx + 0x488) = rdi;
Key changes:
- The dangerous operation is memcpy(rbx+0x88, rsi, rdi) at 0x1c01d14cc/0x1c01d14d6 with rdi = full incoming leftover length, never bounded to 0x400.
- The streaming-loop copy (the OOB site) is guarded directly: the patch rejects when next_msg_len > 0x400 or incoming_len > 0x400 before the copy (no checked-add on this path). The checked-add idiom RtlUIntAdd(current_count, incoming_len, &sum) with a sum > 0x400 reject is added to the header-accumulation partial-append paths (count < 4) as defense-in-depth; those paths were already bounded.
- Fallback caveat: the fix is gated by Feature_4058244411__private_IsEnabledDeviceUsage(). When the feature is disabled, execution can still reach the old unbounded append — verify feature default state before assuming the fix is universally active.
4. Assembly Analysis
4.1 FseProcessIncomingMessages (sub_1c01d1214)
Unpatched — routing to the streaming loop and the unbounded append:
0x1c01d123b: mov rsi, qword [rcx+0x30] ; rsi = incoming data ptr
0x1c01d123f: mov edi, dword [rdx+0x38] ; edi = incoming length (ATTACKER)
0x1c01d1242: mov rbx, qword [rax+0xa8] ; rbx = flow ctx (buf @ +0x88, count @ +0x488)
0x1c01d1282: cmp dword [rbx+0x488], 0x0 ; buffered count
0x1c01d1289: je 0x1c01d1478 ; count==0 -> streaming loop
0x1c01d1478: test edi, edi
0x1c01d147a: je 0x1c01d14e1 ; nothing to do -> return 1
0x1c01d147c: mov rdx, rsi
0x1c01d147f: cmp edi, 0x20
0x1c01d1482: jbe 0x1c01d14cc ; <=0x20 leftover -> buffer it
0x1c01d1484: mov r8d, dword [rsi] ; next message length (*rsi), UNVALIDATED
0x1c01d1487: cmp r8d, edi
0x1c01d148a: ja 0x1c01d14cc ; declared len > leftover -> buffer it
0x1c01d14cc: mov r8d, edi ; VULN: copy length = full leftover, no cap
0x1c01d14cf: lea rcx, [rbx+0x88] ; dest = flow_ctx+0x88 (0x400 buffer)
0x1c01d14d6: call 0x1c00d4d40 ; memcpy(rbx+0x88, rsi, edi) <== OOB WRITE
0x1c01d14db: mov dword [rbx+0x488], edi ; store count = edi (can exceed 0x400)
Annotations:
- 0x1c01d1282: the only routing check — buffered count == 0 sends control into the streaming loop where no size validation exists.
- 0x1c01d1484–0x1c01d148a: the "incomplete message" decision. Setting *rsi (declared length) greater than edi (leftover) forces the ja 0x1c01d14cc branch into the buffering path.
- 0x1c01d14cc–0x1c01d14d6: the vulnerable instruction sequence — r8d = edi (unbounded), then memcpy into the fixed 0x400 buffer. edi > 0x400 overflows immediately.
- 0x1c01d14db: writes the out-of-range count into +0x488, corrupting the length field itself.
The only per-message range check (header path, NOT the loop):
0x1c01d12d0: mov r14d, dword [r15] ; buffered message length (r15 = rbx+0x88)
0x1c01d12d3: lea eax, [r14-0x21]
0x1c01d12d7: cmp eax, 0x3df ; only [0x21,0x400] range check
0x1c01d12dc: ja 0x1c01d1339 ; out of range -> reject
; header-branch partial append (bounded by the [0x21,0x400] check above and rdi < declared_len - rbp):
0x1c01d12f1: mov r8, rdi ; partial append length = rdi
0x1c01d12f4: mov rdx, rsi
0x1c01d12f7: call 0x1c00d4d40 ; memcpy(rbx+0x88+rbp, rsi, rdi)
0x1c01d12fc: add dword [rbx+0x488], edi ; count += rdi
Annotations:
- 0x1c01d12d3–0x1c01d12dc: lea eax,[r14-0x21]; cmp eax,0x3df validates the declared header length to [0x21,0x400] — but this constraint is absent from the streaming loop path at 0x1c01d14cc.
- 0x1c01d12f7: the header-branch partial append copies rdi bytes at rbx+0x88+rbp, with count += rdi. This copy is bounded: it is only reached when rdi < declared_len - rbp (0x1c01d12ed cmp edi,edx; jnb), and declared_len was already range-checked to [0x21,0x400] at 0x1c01d12d3, so count+rdi < declared_len <= 0x400. It is not an independent overflow (the patch still adds a defense-in-depth RtlUIntAdd/<=0x400 guard to the analogous count<4 paths).
Patched — added safe checks at the append site:
; Feature_4058244411 gate wraps the append. Representative added sequence:
; mov eax, 0x400
; cmp r8d, eax ; next_msg_len > 0x400 ?
; ja <reject>
; lea r8, [rbp-0x30]
; and dword [rbp-0x30], 0
; call 0x1c00d490c ; RtlUIntAdd(count, len, &sum)
; js <reject> ; carry/overflow -> reject
; mov eax, 0x400
; cmp dword [rbp-0x30], eax ; sum > 0x400 ?
; ja <reject> ; take error path (result=0), WPP 0x2c-0x31
Annotations:
- call 0x1c00d490c (RtlUIntAdd): computes count + incoming_len with overflow detection; js bails on carry.
- cmp dword [rbp-0x30], 0x400; ja <reject>: the accumulated total must be <= 0x400 before any copy; otherwise the reject path runs (returns 0, emits WPP events 0x2e/0x2f/0x31).
- The memcpy thunk was relocated 0x1c00d4d40 -> 0x1c00d4e40 and an rbp frame pointer was introduced — incidental to the added checks. The fix path is gated on Feature_4058244411__private_IsEnabledDeviceUsage().
5. Trigger Conditions
- Reach the target. Establish a network flow that drives the tcpip.sys FSE receive fast-path such that a flow-context object is created and
FseProcessIncomingMessagesis invoked on received bytes. - Ensure the flow-context buffered-count at
flow_ctx+0x488is0— a fresh flow state that routes execution at0x1c01d1282into the streaming loop at0x1c01d1478. - Deliver a single segment whose total length
edi = *(arg2+0x38)is greater than0x400(must exceed 1024 bytes to overflow the buffer). - Set the leading 4-byte length prefix at the start of the payload (
*rsi) to a value greater than the delivered lengthedi. This makes the loop treat the segment as an "incomplete trailing message" (ja 0x1c01d148a) rather than consuming it, so control falls into the buffering path. - On receipt, the loop reaches
memcpy(rbx+0x88, rsi, edi)at0x1c01d14cc/0x1c01d14d6and copiesedi > 0x400bytes into the 1024-byte buffer. - Observable effect. The
edibytes beyond0x400land past the buffer end, clobberingflow_ctx+0x488(the count field) and the adjacent pool allocation. Expect a kernel bugcheck such asBAD_POOL_HEADER/0x139(KERNEL_SECURITY_CHECK_FAILURE) on the next pool operation, or an immediate access violation if the write runs off the allocation.
Notes: The header-accumulation partial-append at 0x1c01d12f7 is not a second overflow: it is gated by the [0x21,0x400] header-length range check plus the leftover < declared_len - count condition, so count + len stays < declared_len <= 0x400. The single out-of-bounds write is the streaming-loop memcpy at 0x1c01d14cc/0x1c01d14d6. The fix is feature-flag gated (Feature_4058244411), so on a build where the feature is disabled the vulnerable path may still be reachable even on the "patched" binary. Reachability depends on the specific FSE feature/offload being active on the target.
6. Exploit Primitive & Development Notes
- Primitive: Controlled-length, controlled-content out-of-bounds write into kernel non-paged pool starting at
flow_ctx+0x88, spilling pastflow_ctx+0x488. The attacker controls both the length (edi, up to a 32-bit value) and the full contents (the segment payload). Overflow begins exactly at the 0x400 boundary of the reassembly buffer. - Turning it into a full exploit:
- Groom the non-paged pool so a controllable victim object sits immediately after the flow-context allocation (spray flow contexts / adjacent same-size allocations, then free a hole in front of the victim).
- Overwrite an interesting field in the adjacent object — a function pointer, a
LIST_ENTRY, or an object-header/size field — with attacker data. Because contents are fully controlled, precise field values are achievable. - Defeat KASLR via a separate info leak (this bug itself is a blind write). Corrupting the
+0x488count and any embedded length/size fields may enable a follow-on relative read/write to bootstrap a leak. - Drive code execution by hijacking a corrupted function pointer or callback, or pursue a data-only escalation by corrupting a token/privilege structure reachable from the neighboring object.
- Mitigations to consider:
- kASLR: The write is blind; you need a separate leak to build a full exploit. As a pure DoS, KASLR is irrelevant.
- SMEP / SMAP: Do not block the pool write itself; matter only when redirecting execution to a controlled buffer — favor data-only or ROP/JOP within kernel image.
- CFG / CET: Forward-edge (kCFG) and shadow-stack constrain function-pointer hijacks; prefer corrupting data structures or CFG-valid targets.
- HVCI / VBS: Blocks unsigned code; a data-only variant (token/privilege or LIST_ENTRY corruption) survives.
- Pool hardening: Overwriting a pool header risks immediate bugcheck; prefer corrupting in-object fields of a groomed adjacent allocation over the header itself.
- Realistic outcome: Remote kernel pool corruption / DoS (bugcheck) is straightforward; remote or local privilege escalation is plausible but requires a separate info leak and careful pool grooming.
7. Debugger PoC Playbook
Assume WinDbg/KD attached to the UNPATCHED binary.
7.1 FseProcessIncomingMessages (sub_1c01d1214)
Breakpoints:
bp tcpip!FseProcessIncomingMessages ; 0x1c01d1214 entry: capture arg1/arg2, flow_ctx, edi
bp tcpip!FseProcessIncomingMessages+0x2b8 ; 0x1c01d14cc: unbounded copy length set (r8d/edi)
bp tcpip!FseProcessIncomingMessages+0x2c2 ; 0x1c01d14d6: the memcpy call (dest rcx, len edi)
bp tcpip!FseProcessIncomingMessages+0xe3 ; 0x1c01d12f7: header-branch partial append memcpy
Rebase note: the analysis base for these addresses is 0x1c0000000. If the loaded module base differs, subtract 0x1c0000000 from each analysis address to get the RVA (e.g. 0x1c01d14cc -> +0x1d14cc).
Inspect at each breakpoint:
- At entry (+0x0, 0x1c01d1214): rcx = arg1, rdx = arg2. Read rsi_data = poi(rcx+0x30), flow_ctx = poi(poi(rcx+0x20)+0xa8), and edi = dwo(rdx+0x38) (attacker length). Record flow_ctx to watch flow_ctx+0x88 (buffer) and flow_ctx+0x488 (count).
r @rcx, @rdx
dq poi(@rcx+0x20)+0xa8 L1 ; flow_ctx
dd poi(poi(@rcx+0x20)+0xa8)+0x488 L1 ; current buffered count
dd @rdx+0x38 L1 ; incoming length
- At +0x2b8 (0x1c01d14cc):
r8d/edi= copy length (full leftover, unbounded),rcx = flow_ctx+0x88(start of 0x400 buffer),rsi= source. Ifedi > 0x400the following memcpy overflows. - At +0x2c2 (0x1c01d14d6): the
call 0x1c00d4d40(memcpy). Readedi(length) andrcx(dest).edi > 0x400confirms the OOB write. Step over and inspect memory pastflow_ctx+0x488. - At +0xe3 (0x1c01d12f7): header-branch partial append;
r8 = rdi(copy length),rcx = flow_ctx+0x88+ebp. This copy is bounded (count+rdi < declared_len <= 0x400); it is a secondary copy site of interest, not the OOB write.
Trigger setup (from user mode / network):
1. Open a socket / establish the connection that lands on the tcpip FSE fast-path (segment-engine flow), so a flow-context is created and buffered-count +0x488 is 0.
2. Send one message unit whose declared 4-byte length prefix is larger than the delivered length, with the delivered length exceeding 1024 bytes:
payload = <declared_len: uint32 LE, e.g. 0x0000FFFF> ; > delivered length
+ <filler bytes so total delivered length > 0x400, e.g. 0x800 bytes>
This forces edi > 0x400 into memcpy(flow_ctx+0x88, data, edi).
3. Note: the header-accumulation partial-append at 0x1c01d12f7 cannot be primed into an overflow — it is bounded by the [0x21,0x400] length check and the leftover < declared_len - count condition. The only OOB write is the streaming-loop copy above.
Expected observation: The memcpy at 0x1c01d14d6 writes edi (>0x400) bytes starting at flow_ctx+0x88, clobbering the count field at +0x488 and the following pool allocation. Typical results: BAD_POOL_HEADER, DRIVER_OVERRAN_STACK_BUFFER, or 0x139 on the next pool operation, or an immediate access violation. On the patched binary the same input takes the reject path (returns 0, WPP event 0x2e/0x2f/0x31) with no memcpy.
Struct / offset notes:
+0x088 BYTE[0x400] reassembly buffer (ends at +0x488)
+0x488 UINT32 buffered byte-count
arg1+0x30 incoming data pointer
*(arg1+0x20)+0xa8 flow-context object base
arg2+0x38 incoming byte length (attacker-controlled)
memcpy thunk 0x1c00d4d40 (unpatched) / 0x1c00d4e40 (patched)
RtlUIntAdd (patched) 0x1c00d490c
FseProcessIncomingMessage (singular) 0x1c01d10f8
8. Changed Functions — Full Triage
FseProcessIncomingMessages (sub_1c01d1214)->FseProcessIncomingMessages (sub_1c01d1214)(similarity 0.697, security_relevant): The unbounded reassembly append is the vulnerability. The patch insertsFeature_4058244411-gated bounds checks — a checkedRtlUIntAdd(count, len, &sum)plussum > 0x400andnext_msg_len/incoming_len > 0x400comparisons before every append memcpy — rejecting oversized input (result=0) and emitting WPP trace IDs0x2c–0x31. Register/stack-frame reshuffle (rbp frame pointer, memcpy thunk relocation0x1c00d4d40 -> 0x1c00d4e40) is incidental.IppCreateForwardPath (sub_...)->IppCreateForwardPath (sub_...)(similarity 0.8814, behavioral):Feature_411806010-gated reordering of*(Entry+0x50) |= 1(path "valid/ready" flag) so the flag is set beforeIppPrunePathSetUnderLock/IppRestructureHashTableUnderLockrather than after, both still under the scalable write lock. Concurrency/lifetime hardening only — no buffer-size, copy-length, or bounds changes. Not the memory-safety fix and not independently shown to be attacker-triggerable.
Collapsed note on non-security changes: The IppCreateForwardPath diff is a lock-visibility ordering tweak, and the remaining diffs within FseProcessIncomingMessages (frame-pointer introduction, thunk relocation) are incidental register-allocation/relocation noise — the only meaningful security diff is the added bounds/overflow checks flagged in 2.1.
9. Unmatched Functions
- Removed (unpatched only): None
- Added (patched only): None
- Implication: The patch is purely additive-in-place — no new sanitizer function was introduced as a separate symbol; the bounds checks (
RtlUIntAddat0x1c00d490c, the>0x400comparisons, the WPP trace calls) were inlined intoFseProcessIncomingMessagesand gated behindFeature_4058244411__private_IsEnabledDeviceUsage(). Because the fix is feature-flag gated rather than unconditional, whether the vulnerable path is actually reachable on a given build depends on the feature's runtime state.
10. Confidence & Caveats
- Confidence: high
- Rationale:
- The arithmetic and data flow are unambiguous:
edi = *(arg2+0x38)flows directly into the copy length at0x1c01d14cc/0x1c01d14d6with no comparison against0x400, into a fixed buffer at+0x88that ends at+0x488. - The patch is a textbook fix idiom: checked
RtlUIntAddplus explicit> 0x400bounds checks inserted immediately before the copies, with dedicated telemetry (WPP0x2c–0x31) firing only on the newly-added rejection paths. - The header-path length check (
lea eax,[r14-0x21]; cmp eax,0x3df) demonstrates the developers' intended[0x21,0x400]invariant, which the loop path violates — confirming the bug is a missing check rather than intended behavior. - Assumptions made:
- The exact network protocol/offload that drives the FSE receive fast-path (and thus how an attacker sets
arg1+0x30,arg1+0x20,arg2+0x38) is inferred from the code layout, not fully proven end-to-end. - The pool type of the flow-context object is assumed non-paged (typical for network fast-path objects) but not directly confirmed.
- The default runtime state of
Feature_4058244411on shipping builds is unknown; the patched binary may still expose the old path if the feature is off. - To verify before a PoC:
- Confirm the exact FSE receive path and the traffic shape that yields a flow-context with
+0x488 == 0and reachesFseProcessIncomingMessageswitharg2+0x38 > 0x400. - Confirm the flow-context object layout (
+0x88buffer size0x400,+0x488count) and its pool allocation type/location by breaking at entry and dumping the object. - Reproduce the bugcheck on the unpatched binary with the breakpoints in 7.1 and confirm the patched binary takes the reject path (
result=0, WPP0x2e/0x2f/0x31) for the same input — and determine whether theFeature_4058244411gate is enabled by default.