~/posts/binary/cve-2021-24086-nested-ipv6-reassembly.md

CVE-2021-24086: nested IPv6 reassembly and a null write

Trace nested IPv6 fragments from patch checks to a 65,528-byte header request. Follow the unchecked NDIS result separately from a later call, and connect memory layout, protocol limits, and the denial-of-service crash.

date[31:24]
read[23:16]
8 min
cat[15:8]
Binary
Contents
  1. 0x00Packet size and reassembled size have separate boundaries
  2. 0x01What the two patch checks enforce
  3. 0x02One reassembly produces the next parser's input
  4. 0x03Header arithmetic near the limit
  5. 0x04Which return value actually goes unchecked
  6. 0x05The crash evidence supports denial of service
  7. 0x06Recheck boundaries when a new object is formed
  8. 0x07References

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.

Objects in ordinary fragmentation3 rows
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.

Reassembly length-check excerptsasm
movzx r9d, word ptr [rdx+88h]
mov   edx, [rdx+8Ch]
add   edx, r9d

cmp   edx, 0FFFFh
jbe   short loc_1C019A186

When 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:

Fragment receive check excerptasm
test  byte ptr [rdi+0B1h], 4
jz    short loc_1C019A8C7

The 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.

Keep three quantities distinct3 rows
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.

fragment_length_model.pypython
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")
Actual length-model output
$ python fragment_length_model.py
PASS: 8186 routing headers; extensions=65488; requested=65528
PASS: payload total 0xffff accepted; 0x10000 rejected

The 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.

Saved header pointer and a later independent checkasm
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, rax

Argument 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:

Dereference of the saved pointerasm
mov   rax, qword ptr [rsp+98h+SavedHeaderPtr]
movups xmm0, xmmword ptr [rdi+90h]
movups xmmword ptr [rax], xmm0

Trace 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

Key crash values in the case7 rows
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.

Three evidence layers to retain3 steps
  1. 1

    Static differences

    Keep the total-payload limit and Jumbogram branch comparisons, including field meanings and the applicable build.

  2. 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. 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.

References

NORMAL~/posts/binary/cve-2021-24086-nested-ipv6-reassembly.md§--
0%en