CVE-2021-24086 ends in a null-pointer write, but the more revealing question comes earlier: how can extension headers normally constrained by an MTU approach 64 KiB inside reassembly? The historical Windows path accepts nested fragmentation, turning the output of one reassembly into a large input object for another. The outer packet-size limit no longer constrains the inner header region.
Microsoft's February 9, 2021 announcement classifies this issue as denial of service, separately from the two RCE issues fixed in the same release. The analysis below concerns that historical receive path before and after the fix. The exact Windows build, tcpip.sys hash, and complete dump have not been matched; function-relative offsets are not cross-version addresses.
Packet size and reassembled size have separate boundaries
IPv6 fragmentation is not arbitrary slicing of an entire packet. Each fragment carries the appropriate preceding headers and a Fragment Header, whose fields describe Next Header, Fragment Offset, the M flag, and Identification. Reassembly uses the relevant headers from the first fragment, joins data by offset, and removes the corresponding Fragment Header.
| Object | Contents and constraints |
|---|---|
| Original datagram | Fixed IPv6 header, extension headers, upper-layer header, and payload |
| Transmitted fragment | Preceding headers, Fragment Header, and fragment data; constrained by the path MTU |
| Reassembled object | Restored payload; length requires a separate check |
RFC 8200 Section 4.5 requires the first fragment to contain the complete header chain through the upper-layer header. It also specifies rejection when the reassembled Payload Length would exceed 65,535 bytes. That length excludes the 40-byte fixed IPv6 header.
A valid first fragment's header region is therefore MTU-constrained, but this is an acceptance rule, not an automatic property of every implementation's memory. With nested input, inspect the object created after each reassembly instead of carrying the outer packet-size assumption forward.
What the two patch checks enforce
The version comparison for tcpip!Ipv6pReassembleDatagram adds a check combining the extension headers in the unfragmentable portion with the reassembled fragmentable portion. These are selected instruction excerpts, not a complete contiguous listing; stack-variable descriptions are analysis annotations.
movzx r9d, word ptr [rdx+88h]
mov edx, [rdx+8Ch]
add edx, r9d
cmp edx, 0FFFFh
jbe short loc_1C019A186When the total payload exceeds 0xffff, the new branch enters early-exit and reassembly-set cleanup handling. Checking either constituent alone is not equivalent. The fixed IPv6 header is outside this Payload Length comparison.
The other difference appears in tcpip!Ipv6pReceiveFragment and tests Jumbogram state:
test byte ptr [rdi+0B1h], 4
jz short loc_1C019A8C7The analysis annotation associates this bit with state set during Jumbo Payload option processing. The set-bit path stops fragment processing and enters error handling. RFC 2675 already prohibits combining a Jumbo Payload option with a Fragment Header.
These differences guide investigation, but do not by themselves prove that each check independently blocks the same input. Branch reachability, the flag's origin, and the computed reassembly length still require separate tracing.
One reassembly produces the next parser's input
"Nested" describes the historical Windows receive behavior here. It is not an ordinary protocol construction or a claim that other operating systems accept the same input.
The illustrations distinguish outer Identification 0x11111111 from inner Identification 0x22222222. These labels explain ownership; they are not a packet capture with real source addresses, offsets, and checksums.
flowchart TD A["Outer fragment set / ID 0x11111111"] --> B["Outer reassembly completes"] B --> C["Long extension chain, inner Fragment Header, first data portion"]
The outer payload holds a byte sequence: many Routing Headers, an inner Fragment Header, and the first portion of inner upper-layer data. Outer fragmentation transports that sequence in pieces. Only after those pieces form a larger object does inner parsing begin. This does not mean every transmitted outer packet repeats a complete inner header.
flowchart TD A["Outer result: first inner fragment"] --> C["Inner set / ID 0x22222222"] B["Final inner fragment arrives separately"] --> C C --> D["Second reassembly"]
The final inner fragment in the case arrives separately rather than inside the same outer fragment set. Nesting that final portion in the same way as the first did not reach the intended recursive reassembly path. Layering and arrival relationships are therefore experimental conditions; copying the header count alone is insufficient.
The inner layer still has a logical first fragment. It is now an outer-reassembly product rather than one MTU-sized frame. Its length, header validity, and backing memory all require validation as a newly formed object.
Header arithmetic near the limit
The case uses 0x1ffa Routing Headers of eight bytes each, totaling 0xffd0 bytes. Adding the 0x28-byte fixed IPv6 header produces a 0xfff8-byte contiguous-header request to NdisGetDataBuffer.
| Quantity | Hexadecimal | Decimal | Scope |
|---|---|---|---|
| Routing Header count | 0x1ffa |
8,186 | Number of headers |
| Extension-header length | 0xffd0 |
65,488 | Excludes the fixed IPv6 header |
| Contiguous-header request | 0xfff8 |
65,528 | Extensions plus fixed header |
Next Header links continue through Routing Headers and end at the inner Fragment Header. The first inner upper-layer portion is eight bytes. Outer transmission uses 0x400-byte payload chunks, while the remaining inner data arrives in a separate final fragment. The chunk size is a parameter of this case, not a universal vulnerability constant.
The offline model below checks both the header arithmetic and the added total-payload boundary. Values 0x2f and 0x30 are chosen boundary-test inputs, not asserted lengths of the historical final fragment.
routing_headers = 0x1ffa
extension_bytes = routing_headers * 8
ipv6_header_bytes = 0x28
requested_bytes = extension_bytes + ipv6_header_bytes
assert routing_headers == 8186
assert extension_bytes == 0xffd0 == 65488
assert requested_bytes == 0xfff8 == 65528
# A model of the observed payload-length check, not a Windows emulator.
def payload_allowed(extension_size, fragmentable_size):
assert extension_size >= 0 and fragmentable_size >= 0
return extension_size + fragmentable_size <= 0xffff
assert payload_allowed(extension_bytes, 0x2f)
assert not payload_allowed(extension_bytes, 0x30)
assert payload_allowed(0, 0xffff)
assert not payload_allowed(0, 0x10000)
print("PASS: 8186 routing headers; extensions=65488; requested=65528")
print("PASS: payload total 0xffff accepted; 0x10000 rejected")$ python fragment_length_model.py
PASS: 8186 routing headers; extensions=65488; requested=65528
PASS: payload total 0xffff accepted; 0x10000 rejectedThe model sends no fragments and does not emulate Windows reassembly state. It validates only these numerical relationships and boundary conditions.
Which return value actually goes unchecked
Microsoft's NdisGetDataBuffer contract distinguishes logical data length from physical contiguity. Sufficient contiguous data can yield a direct pointer. If the requested bytes need to be assembled, the caller must supply adequate Storage. Noncontiguous data with Storage == NULL returns null; insufficient data or resource failures can also do so.
On the second reassembly, the case requests 0xfff8 contiguous header bytes without fallback storage. The essential call and saved-return relationship is shown below. The long stack-variable name is shortened to the analysis alias SavedHeaderPtr.
xor r8d, r8d
mov r9d, 1
mov rcx, r14
call cs:__imp_NdisGetDataBuffer
mov qword ptr [rsp+98h+SavedHeaderPtr], rax
call IppCopyPacket
mov rbx, rax
test rax, raxArgument preparation and intervening instructions are omitted to isolate return-value ownership. The later test rax, rax checks IppCopyPacket's result, not the earlier NDIS result already saved in a stack slot. Finding a null check nearby does not establish that every preceding fallible call was checked.
Later code reloads the saved header pointer:
mov rax, qword ptr [rsp+98h+SavedHeaderPtr]
movups xmm0, xmmword ptr [rdi+90h]
movups xmmword ptr [rax], xmm0Trace the NDIS return register into its saved location and then into the first use. A search for nearby test instructions is not a substitute for that dataflow.
The crash evidence supports denial of service
| Item | Value |
|---|---|
| BugCheck | DRIVER_IRQL_NOT_LESS_OR_EQUAL (0xd1) |
| Referenced address | 0x0 |
| IRQL | 2 |
| Operation | Write |
| Fault location | tcpip!Ipv6pReassembleDatagram+0x14f |
| Instruction | movups xmmword ptr [rax], xmm0 |
RAX |
0x0 |
The call path also passes through Ipv6pReceiveFragment, Ipv6pReceiveFragmentList, and receive-batch processing. That agrees with a reassembly function writing through a null header pointer. Microsoft's BugCheck 0xD1 documentation explains the address, IRQL, and access-type parameters. Offset +0x14f belongs only to the observed build.
Both this issue and the RDNSS eight-byte mismatch involve NdisGetDataBuffer, but their memory primitives differ. The RDNSS case supplies caller-stack storage and reaches an oversized copy. This case supplies no Storage and later uses a null failure result. A shared API name does not establish a shared bug class.
The recorded impact is a kernel crash and denial of service; no chain from the null write to controlled execution is established. Reachability is separate again: link-local versus global addresses, routing, and filtering determine which inputs actually reach the target receive path.
Recheck boundaries when a new object is formed
Apply the applicable security update identified by Microsoft's advisory. Microsoft also describes blocking IPv6 fragments as a temporary exposure mitigation, while noting that it can disrupt services that depend on IPv6. It should be distinguished from the lasting fix.
- 1
Static differences
Keep the total-payload limit and Jumbogram branch comparisons, including field meanings and the applicable build.
- 2
Reassembly structure
Record outer and inner Identification, Fragment Offset, M flag, and Next Header relationships separately. Confirm the object actually produced by each round.
- 3
Runtime dataflow
Record requested length, contiguity, Storage, the return value, and the first invalid access. Check the relevant return value rather than a later call's result.
The engineering requirement follows from the root cause: restore length invariants whenever reassembly produces a new object, and handle failure whenever requesting a contiguous view. Small outer inputs do not guarantee a small decoded, decompressed, or reassembled object. Earlier validation does not automatically cover that new object.