tcpip.sys — FseProcessIncomingMessages Kernel Pool Out-of-Bounds Write in FSE Message Reassembly
KB5094126
1. Overview
| Item | Value |
|---|---|
| Unpatched binary | tcpip_unpatched.sys |
| Patched binary | tcpip_patched.sys |
| Overall similarity | 0.9924 |
| Matched functions | 6053 |
| Changed functions | 2 |
| Identical functions | 6051 |
| Unmatched (unpatched / patched) | 0 / 0 |
Verdict: The patch closes a critical, remotely-reachable kernel pool out-of-bounds write in FseProcessIncomingMessages (the FSE framed-message stream reassembler) by adding the missing > 0x400 bounds guards and an integer-overflow guard around three memcpy sites that copy attacker-controlled data into a fixed 1024-byte pool buffer. It matters because the write length and contents are fully attacker-controlled and the code is reached directly from the network receive path.
Of the 2 changed functions, only 1 is security-relevant. FseProcessIncomingMessages (similarity 0.6679) carries the actual fix. IppCreateForwardPath (similarity 0.8227) is a benign feature-flag reordering of a flag-set (Entry+0x70 |= 1) and a field store — no allocation/bounds/lifetime change — and is not the patched vulnerability. The real fix is concentrated entirely in the FSE reassembly accumulator and is gated behind the staged velocity flag Feature_3924026683__private_IsEnabledDeviceUsageNoInline().
2. Vulnerability Summary
2.1 CRITICAL — Kernel pool out-of-bounds write in FSE reassembly (CWE-787 / CWE-120; accumulation path also CWE-190 -> CWE-787)
- Affected function:
FseProcessIncomingMessages (sub_1401bd224)(unpatched),FseProcessIncomingMessages (sub_1401bd318)(patched) — reassembles a length-prefixed FSE message stream into a fixed-size pool buffer inside the FSE context object. - Root cause: The function copies an attacker-controlled number of inbound bytes into a fixed 0x400-byte buffer without ever bounding the copy length to the buffer capacity. Object/field layout of the FSE context (
ctx = *(*(arg1+0x20)+0xa8)): ctx+0x88— reassembly buffer base (fixed capacity 0x488 − 0x88 = 0x400 = 1024 bytes)ctx+0x488— 32-bit accumulated-byte counter (rbp_1)- Inbound data pointer:
rsi = *(arg1+0x30) - Inbound length:
rdi = *(arg2+0x38)(attacker-controlled)
The only length check present pre-patch validates the internal per-message length field, not the raw fragment size:
0x1401bd2e0 mov r14d, dword [r15] ; internal per-message length
0x1401bd2e3 lea eax, [r14-0x21]
0x1401bd2e7 cmp eax, 0x3df ; validates msg len to [0x21,0x400] ONLY
The trailing-fragment write is unbounded:
memcpy(rbx + 0x88, rsi, rdi); // rdi never checked <= 0x400
*(rbx + 0x488) = rdi; // counter set past capacity, no cap
- Why exploitable: The attacker controls both the copy length (
rdi = *(arg2+0x38)) and the copied contents (the inbound network buffer). The destination is a fixed 1024-byte pool allocation embedded in the FSE context. A fragment longer than 0x400, or a partial message (rbp_1) combined with a new fragment such thatrbp_1 + rdi > 0x400, writes attacker bytes linearly past the buffer into adjacent pool memory — a controlled contiguous pool overflow. The accumulate counter*(ctx+0x488) += rdiis a 32-bit add that can also wrap. - What the patch does: Adds explicit upper-bound guards before the buffering/accumulate copies:
if (*r14 > 0x400 || rsi > 0x400)for the trailing-fragment pathr8_6 = *(ctx+0x488) + rsi; if (r8_6 < result || r8_6 > 0x400)— integer-overflow AND 0x400 cap for the accumulate path
On violation the code drops the data (result = 0) and emits WPP trace ids 0x2b/0x2f/0x32. All guards are gated behind Feature_3924026683 (call at sub_1401bd084); if the feature is disabled the old unchecked path can still run.
- Attacker-reachable entry point: Inbound data on the FSE transport channel → registered receive-completion callback OnReceiveComplete (sub_1401bdf50) → call at 0x1401bdfb9.
- Call chain:
1. Network transport receive indication (inbound bytes)
2. OnReceiveComplete (sub_1401bdf50) — receive-completion callback, invokes vuln at 0x1401bdfb9
3. FseProcessIncomingMessages (sub_1401bd224)
4. memcpy at 0x1401bd4e4 / 0x1401bd307 / 0x1401bd38b / 0x1401bd419 (OOB write)
3. Pseudocode Diff
3.1 FseProcessIncomingMessages (sub_1401bd224)
// UNPATCHED — FseProcessIncomingMessages (sub_1401bd224)
uint64_t rbp_1 = *(rbx + 0x488); // accumulated bytes so far
// internal per-message length validated only:
// if (r14_1 - 0x21 > 0x3df) { error } // [0x21,0x400] on the message header
// trailing-fragment path (loop label_1401bd48a):
while (rdi) {
int64_t* rdx_11 = rsi;
if (rdi > 0x20) {
int32_t r8_12 = *rsi; // next-message 4-byte length prefix (attacker)
if (r8_12 <= rdi) {
FseProcessIncomingMessage(rbx, rdx_11, r8_12);
result = *rsi; rsi += result; rdi = rdi - result; continue;
}
}
memcpy(rbx + 0x88, rdx_11, rdi); // <-- OOB WRITE: rdi never checked <= 0x400
*(rbx + 0x488) = rdi; // counter set past capacity
break;
}
// accumulate path:
memcpy(rbx + 0x88 + rbp_1, rsi, rdi); // <-- OOB WRITE: rbp_1+rdi never checked <= 0x400
*(rbx + 0x488) += rdi; // 32-bit counter can wrap
// PATCHED — FseProcessIncomingMessages (sub_1401bd318)
if (Feature_3924026683__private_IsEnabledDeviceUsageNoInline()) {
// trailing-fragment guard
if (*r14 > 0x400 || rsi > 0x400) { // reject oversize fragment
result = 0; // drop data
WppTrace(0x32); // (also 0x2b / 0x2f on related drops)
goto drop;
}
// accumulate guard (integer-overflow AND cap)
r8_6 = *(ctx + 0x488) + rsi;
if (r8_6 < result || r8_6 > 0x400) { // wrap OR exceeds 0x400
result = 0;
WppTrace(0x2f);
goto drop;
}
}
// only reached when within bounds:
memcpy(rbx + 0x88 + rbp_1, rsi, rdi);
*(rbx + 0x488) += rdi;
Key changes:
- Unpatched: memcpy(rbx+0x88, rsi, rdi) with rdi unbounded, and memcpy(rbx+0x88+rbp_1, rsi, rdi) with rbp_1+rdi unchecked.
- Patched: rejects any fragment whose size exceeds 0x400 and any accumulation that overflows or crosses the 0x400 capacity; on failure it zeroes result, drops the data, and traces 0x2b/0x2f/0x32.
- The entire guard set is behind Feature_3924026683 — if the velocity flag is off, the unchecked path remains reachable (fallback risk).
4. Assembly Analysis
4.1 FseProcessIncomingMessages (sub_1401bd224)
Unpatched — context load and the trailing-fragment OOB write:
0x1401bd247 mov rax, qword [rcx+0x20]
0x1401bd24b mov rsi, qword [rcx+0x30] ; rsi = inbound data ptr (arg1+0x30)
0x1401bd24f mov edi, dword [rdx+0x38] ; edi = inbound length (arg2+0x38) ATTACKER
0x1401bd252 mov rbx, qword [rax+0xa8] ; rbx = FSE ctx (buf @ +0x88, count @ +0x488)
...
0x1401bd48c mov rdx, rsi
0x1401bd48f cmp edi, 0x20
0x1401bd492 jbe 0x1401bd4da
0x1401bd494 mov r8d, dword [rsi] ; next-message 4-byte length prefix
0x1401bd497 cmp r8d, edi
0x1401bd49a ja 0x1401bd4da ; prefix > remaining => incomplete => buffer remainder
0x1401bd4da mov r8d, edi ; r8 = rdi (UNBOUNDED length)
0x1401bd4dd lea rcx, [rbx+0x88] ; dst = 0x400-byte buffer base
0x1401bd4e4 call 0x1401c2880 ; memcpy(rbx+0x88, rsi, rdi) <-- OOB WRITE
0x1401bd4e9 mov dword [rbx+0x488], edi ; *(ctx+0x488)=rdi (no 0x400 cap)
Annotations:
- 0x1401bd2e7 cmp eax, 0x3df: the ONLY length bound present pre-patch — validates the internal per-message header to [0x21,0x400], not the raw fragment copy size.
- 0x1401bd49a ja 0x1401bd4da: decides "incomplete fragment" (next prefix > rdi) and routes to the unbounded buffering memcpy.
- 0x1401bd4e4 call memcpy: the primary OOB write, rdi copy length with no 0x400 cap; 0x1401bd4e9 then records the oversize length in ctx+0x488.
Unpatched — accumulate-path OOB write:
0x1401bd2ca mov ebp, dword [rbx+0x488] ; rbp_1 = accumulated bytes
0x1401bd2f1 lea rcx, [rbx+0x88]
0x1401bd2f8 sub edx, ebp
0x1401bd2fa add rcx, rbp ; dst = rbx+0x88+rbp_1
0x1401bd2fd cmp edi, edx
0x1401bd2ff jae 0x1401bd336
0x1401bd301 mov r8, rdi
0x1401bd304 mov rdx, rsi
0x1401bd307 call 0x1401c2880 ; memcpy(rbx+0x88+rbp_1, rsi, rdi) <-- OOB WRITE
0x1401bd30c add dword [rbx+0x488], edi ; *(ctx+0x488)+=rdi (no cap, 32-bit wrap)
Annotations:
- 0x1401bd2fa add rcx, rbp: destination is offset by the previously-buffered byte count, with no check that rbp_1 + rdi <= 0x400.
- 0x1401bd307 call memcpy: second OOB write site; 0x1401bd30c blindly adds rdi to the 32-bit counter (integer wrap).
Patched — safe path (guards gated by Feature_3924026683):
; conceptual layout of inserted guards (from diff_function_code, side=patched):
call 0x1401bd084 ; Feature_3924026683 gate
test <flag>, <flag>
je <old_unchecked_path> ; feature OFF -> legacy path
mov eax, 0x400
cmp r8d, eax ; fragment length > 0x400 ?
ja 0x1401bd705 ; -> drop (result=0, WPP 0x2b/0x2f/0x32)
lea r8d, [rax+rsi] ; acc + incoming
cmp r8d, eax ; > 0x400 ? (and jb below covers wrap)
jb <overflow-check> ; only proceed when within capacity
Annotations:
- call 0x1401bd084: the Feature_3924026683 velocity gate — guards only active when enabled.
- cmp r8d, 0x400 / ja <drop>: rejects any single fragment larger than the buffer.
- lea r8d,[rax+rsi] / cmp / jb: the integer-overflow-and-cap check on the accumulated length; the drop path emits WPP ids 0x2b/0x2f/0x32.
5. Trigger Conditions
- Reach the target. Establish an FSE channel/connection over the connected tcpip transport so inbound bytes drive
OnReceiveComplete (sub_1401bdf50)and thusFseProcessIncomingMessages. - Deliver a crafted inbound, length-prefixed FSE byte stream. Framing: 4-byte length prefix at buffer start; internal message length is checked only against
[0x21,0x400]. - Trailing-fragment overflow (primary): Send a stream whose consumed framed messages leave a trailing remainder of length
rdiin the range0x401 .. (pool slack), withrdi > 0x20, and set the remainder's leading 4-byte length prefix*rsistrictly greater thanrdi(so the message is "declared but not fully present"). - This forces the
ja 0x1401bd4dabranch, routing tomemcpy(ctx+0x88, remainder, rdi)withrdi > 0x400— the boundary is crossed and the 1024-byte buffer is exceeded. - The single firing action is the receive completion of that trailing fragment; the copy at
0x1401bd4e4runs immediately. - Observable effect. Adjacent pool corruption;
dword [ctx+0x488]holds a value> 0x400after return. Subsequent pool operations bugcheck — typicallyBAD_POOL_HEADER (0x19),KERNEL_MODE_HEAP_CORRUPTION (0x13A), orDRIVER_OVERRAN_STACK_BUFFER (0x139)if the cookie is tripped — with the fault trail intotcpip!FseProcessIncomingMessages.
Alternative — accumulation overflow: First send a fragment that leaves ctx+0x488 partially filled (e.g., near 0x400), then send a second fragment of length rdi so rbp_1 + rdi > 0x400 while each per-message header still passes the [0x21,0x400] check; memcpy(ctx+0x88+rbp_1, data, rdi) at 0x1401bd307 overflows, and *(ctx+0x488) += rdi can 32-bit-wrap.
Notes: The overflow length and content are fully attacker-controlled. Reliable exploitation requires pool grooming so a useful victim object sits adjacent to the FSE context allocation. Because the fix is behind Feature_3924026683, on builds where the flag is disabled even the patched binary can still reach the unchecked path.
6. Exploit Primitive & Development Notes
- Primitive: Linear kernel pool out-of-bounds write with fully attacker-controlled length (
rdi, up to the fragment size) and contents (the inbound bytes), starting atctx+0x88and running past the 1024-byte buffer into adjacent pool memory. The accumulate variant additionally offers a 32-bit counter wrap atctx+0x488. - Turning it into a full exploit:
- Groom the pool so that the FSE context allocation is immediately followed by a controllable victim object (another pool block with a function pointer,
LIST_ENTRY, or object header of interest). - Overflow into and corrupt the adjacent object — e.g., overwrite a function pointer or a
LIST_ENTRYused in a later dereference — with attacker bytes, converting the linear overflow into a write-what-where or hijacked control transfer. - Defeat kASLR with a separate info leak (remote overflow gives no leak by itself); target values that survive KASLR randomization once a base is known.
- Redirect execution (via a corrupted call target) or perform data-only privilege escalation (e.g., corrupt a token/security field reachable through the adjacent object).
- Mitigations to consider:
- kASLR: Full impact — a remote-only overflow yields no base leak; a companion info-leak primitive is required to place absolute pointers.
- SMEP / SMAP: Standard kernel mitigations; a data-only corruption path (fixing an adjacent structure, not jumping to user memory) avoids them.
- CFG / CET: Constrains control-flow hijack via corrupted indirect call targets; prefer data-only corruption or ROP within valid targets.
- HVCI / VBS: No unsigned kernel code execution; a data-only variant (privilege escalation via corrupted kernel data) survives.
- Pool hardening: Modern pool cookies/quarantine make header corruption prone to detected bugchecks; prefer corrupting adjacent object fields rather than the pool header itself.
- Realistic outcome: Remote kernel crash / DoS is straightforward; remote LPE/code execution 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_1401bd224)
Breakpoints:
bp tcpip!FseProcessIncomingMessages ; 0x1401bd224 (RVA 0x1bd224) - entry, capture args/ctx
bp tcpip+0x1bd4e4 ; trailing-fragment memcpy - watch r8d vs 0x400
bp tcpip+0x1bd307 ; accumulate memcpy - watch rcx dst, r8 length
bp tcpip!OnReceiveComplete ; 0x1401bdf50 - see network buffer marshalling
Rebase note: the analysis base is 0x140000000; if the live module base differs, subtract 0x140000000 from the addresses above to get the RVA and add the live base.
Inspect at each breakpoint:
- At FseProcessIncomingMessages (entry, 0x1401bd224): rcx = arg1, rdx = arg2. Read the data pointer and length, and resolve the context:
r @rcx, @rdx
dq @rcx+0x30 L1 ; rsi = inbound data ptr
dd @rdx+0x38 L1 ; edi = attacker-controlled inbound length
dq poi(@rcx+0x20)+0xa8 L1 ; rbx = FSE context base
- At
+0x1bd4e4(trailing-fragment memcpy): watchr8d(copy length) andrcx(= rbx+0x88). Ifr8d > 0x400the copy overflows. Step to0x1401bd4e9to see*(rbx+0x488)set to the oversize length.
r r8, rcx
dd @rbx+0x488 L1 ; accumulated counter (before/after)
- At
+0x1bd307(accumulate memcpy): watchrcx(= rbx+0x88+rbp_1) andr8(length). Overflow ifrbp_1 (dword [rbx+0x488] on entry) + rdi > 0x400.0x1401bd30cperforms the uncheckedadd dword [rbx+0x488], edi. - At
OnReceiveComplete(0x1401bdf50): observe how the inbound network buffer/length are placed intoarg1+0x30/arg2+0x38before the call at0x1401bdfb9.
Trigger setup (from user mode / peer):
1. Bring up the FSE channel that feeds OnReceiveComplete (an inband framed-message stream over the connected tcpip transport).
2. From the peer, send a message stream so that after the framed messages are consumed a trailing remainder of length > 0x400 remains, with that remainder's leading 4-byte length prefix set greater than the remainder length (forces the incomplete-fragment buffering path):
[complete framed messages ...][trailing remainder]
trailing remainder layout:
+0x00 DWORD prefix = value > (remainder_len) ; forces *rsi > rdi
+0x04 ... filler = attacker bytes, total remainder_len > 0x400
- Alternative: send a first fragment leaving
dword [ctx+0x488]near0x400, then a second fragment whose length pushesctx+0x488past0x400.
Expected observation: At 0x1401bd4e4 (or 0x1401bd307) the memcpy length register r8d exceeds 0x400 while rcx is inside the ctx+0x88 buffer; stepping over the call corrupts adjacent pool memory. Expect a pool-corruption BSOD (0x19 BAD_POOL_HEADER, 0x13A KERNEL_MODE_HEAP_CORRUPTION, or 0x139 if the cookie trips) on the next allocation/free, faulting back into tcpip!FseProcessIncomingMessages. A value > 0x400 left in dword [ctx+0x488] after return is direct confirmation the bound was never enforced.
Struct / offset notes:
FSE context = *(*(arg1+0x20)+0xa8) ; rbx
+0x088 BYTE[0x400] reassembly buffer (capacity 0x488-0x88 = 0x400 = 1024)
+0x488 DWORD accumulated-byte counter (rbp_1; 32-bit, can wrap)
arg1+0x30 = inbound data pointer (rsi)
arg2+0x38 = inbound fragment length (edi/rdi)
Only pre-patch bound: cmp eax,0x3df @0x1401bd2e7 (internal msg len [0x21,0x400], NOT the copy)
Route to unbounded memcpy: cmp r8d,edi / ja @0x1401bd49a (next prefix > remaining)
Helpers: FseProcessIncomingMessage (single-msg) = 0x1401bd108
OnReceiveComplete = 0x1401bdf50 (invokes vuln at 0x1401bdfb9)
Patched gate: call 0x1401bd084 (Feature_3924026683) + cmp ...,0x400 + WPP 0x2b/0x2f/0x32
8. Changed Functions — Full Triage
FseProcessIncomingMessages (sub_1401bd224)->FseProcessIncomingMessages (sub_1401bd318)(similarity 0.6679, security_relevant): The FSE reassembly accumulator gained> 0x400fragment-size guards and an integer-overflow-plus-cap check on the accumulated length (ctx+0x488) before threememcpysites (0x1401bd4e4,0x1401bd307,0x1401bd38b). Pre-patch these copied attacker-controlledrdi/rbp_1+rdibytes into a fixed 1024-byte pool buffer with no size limit — a linear pool OOB write. Fix is gated behindFeature_3924026683and drops data (WPP0x2b/0x2f/0x32) on violation.IppCreateForwardPath (sub_...)->IppCreateForwardPath (sub_...)(similarity 0.8227, behavioral): Only relocates a flag-set (*(Entry+0x70) |= 1) into twoFeature_277588282-gated sites (before/afterRtlRestructureHashTable) and moves the*(Entry+0x78) = gsbase+0x1a4store earlier; net effect preserved. Remainder is pure register renaming. No allocation/bounds/lifetime change — not a memory-safety fix.
Collapsed note on non-security changes: Aside from the FSE fix, the only other diff is a benign feature-flag reordering and register renaming in IppCreateForwardPath; the sole meaningful, security-relevant change is FseProcessIncomingMessages.
9. Unmatched Functions
- Removed (unpatched only): None
- Added (patched only): None
- Implication: The patch is purely additive/in-place: the fix consists of inserted bounds/overflow checks inside the existing
FseProcessIncomingMessages, gated behind theFeature_3924026683velocity flag rather than a new sanitizer routine. No functions were added or removed. Because the guards are feature-flag-gated, the unchecked legacy path may still be reachable on builds where the flag is disabled.
10. Confidence & Caveats
- Confidence: critical
- Rationale:
- The buffer geometry is unambiguous: capacity
0x488 − 0x88 = 0x400, counter atctx+0x488, threememcpysites copyingrdi/rbp_1+rdibytes with no0x400bound in the unpatched code. - The patch is a textbook fix idiom — added
cmp ...,0x400 / ja <drop>andlea r8d,[rax+rsi] / cmp / jboverflow-and-cap guards gated by a feature flag, dropping data with WPP trace codes. - Data flow from
arg1+0x30/arg2+0x38through the FSE context to the copy sites is fully traced in the disassembly. - Assumptions made:
- The FSE channel is reachable such that attacker-influenced bytes drive
OnReceiveCompleteand thus setarg1+0x30(data) andarg2+0x38(length) — the exact network conditions/protocol to bring up an FSE channel are not fully enumerated here. - The pool type of the FSE context allocation and the identity/adjacency of a useful victim object are not determined and must be established for a controlled overflow.
- To verify before a PoC:
- Confirm the FSE context object layout (buffer at
+0x88, counter at+0x488) and its pool allocation size/type against a live/unpatched target. - Reproduce the trailing-fragment path: craft a stream leaving a trailing remainder
> 0x400with next-prefix> rdi, break at0x1401bd4e4, and confirmr8d > 0x400and adjacent pool corruption / bugcheck.