Logo

CVE-2024-38063 - Remote over TCP/IP

Remote over TCP/IP


CVE-2024-38063 - Remote over TCP/IP

This was released in August 2024 - fixed recently by Microsoft

The CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Let's break this down

The CVSS describes a highly severe vulnerability:

  • It can be exploited over a network (AV:N).
  • It has a low attack complexity (AC:L).
  • No privileges are required for exploitation (PR:N).
  • No user interaction is necessary (UI:N).
  • The scope remains unchanged (S:U).
  • It results in high impacts on confidentiality (C:H), integrity (I:H), and availability (A:H).

Overall, this vector points to a critical vulnerability that is easy to exploit and can have severe consequences on the confidentiality, integrity, and availability of the affected system.

The security weakness is designated as CVE-2024–38063.

At its core, it represents a no-interaction Windows TCP/IP remote code execution vulnerability. The Microsoft Security Advisory states that the defect stems from an integer underflow issue within a component of the tcpip.sys driver tasked with processing IPv6 data packets. I have investigated the Windows TCP/IP network stack deficiency that could potentially allow malicious actors to gain remote entry with the highest level of permissions. Leveraging CVE-2024–38063 does not necessitate any user intervention. In this quick paper, I will detail how I managed to construct an attack sequence.

A priori

To identify the root cause of the vulnerability, I analyzed the driver files both before and after the patch.

There are essentially two approaches to accomplish this:

The first approach is to save the original file, then proceed with the Windows update to obtain the new file. This method is the most dependable, though it’s preferable that the original file isn’t too outdated. Otherwise, instead of a clear and manageable comparison in BinDiff, you might encounter a large number of changes, most of which will likely be unrelated to security.

The second approach is to use Winbindex, an outstanding resource for conducting various Windows-related research. Updated files appear there with a slight delay, but this isn’t usually a significant issue.

I opted for the second approach using Winbindex and downloaded the two most recent versions of the Windows 10 22H2 driver.

In this case, version 10.0.19041.4780 is the only file version where the vulnerability has been fixed. You can view the publication date for any of these files by selecting Show in the Extra column or simply by hovering over the update number.

When comparing the two files, the only function affected by the change was Ipv6pProcessOptions.

Here is the modified function;

In the July 23 version:

HasError = 0;
ICMPResponseCode = 2;
LABEL_35:
OptionsHeaderSize = OptionsSizeWithHeaders - OptionsSizeWithoutHeaders;
IppSendErrorFlag = v25;
Packet->IPv6_HeaderSize += OptionsHeaderSize;
IPv6_HeaderSize = Packet->IPv6_HeaderSize;
LABEL_36:
Packet->NextHeader = 0x3B;
Packet->NetBufferList->Status = 0xC0000218;
if ( IppDiscardReceivedPackets(&Ipv6Global, ErrorCode, Packet, 0LL) )
    return ErrorCode;
if ( HasError )
    IppSendErrorList(1, &Ipv6Global, Packet, 4u, ICMPResponseCode, _byteswap_ulong(IPv6_HeaderSize), IppSendErrorFlag);
return ErrorCode;

In the August 13 version:

HasError = 0;
ICMPResponseCode = 2;
LABEL_35:
Packet->IPv6_HeaderSize += OptionsSizeWithHeaders - OptionsSizeWithoutHeaders;
IPv6_HeaderSize = Packet->IPv6_HeaderSize;
LABEL_36:
Packet->NextHeader = 0x3B;
Packet->NetBufferList->Status = 0xC0000218;
if ( IppDiscardReceivedPackets(&Ipv6Global, ErrorCode, Packet, 0LL) || !HasError )
    return ErrorCode;
IPv6_HeaderSize_bswap = _byteswap_ulong(IPv6_HeaderSize);
if ( Feature_2365398330__private_IsEnabledDeviceUsage() )
    IppSendError(1, &Ipv6Global, Packet, 4u, ICMPResponseCode, IPv6_HeaderSize_bswap, IppSendErrorFlag);
else
    IppSendErrorList(1, &Ipv6Global, Packet, 4, ICMPResponseCode, IPv6_HeaderSize_bswap, IppSendErrorFlag);
return ErrorCode;

Here are the key differences between the two snippets:

  1. Condition in the first if statement:

    • First snippet: if ( IppDiscardReceivedPackets(&Ipv6Global, ErrorCode, Packet, 0LL) )
    • Second snippet: if ( IppDiscardReceivedPackets(&Ipv6Global, ErrorCode, Packet, 0LL) || !HasError )
  2. Error handling logic:

    • First snippet: Uses a simple if ( HasError ) check
    • Second snippet: Introduces a new function Feature_2365398330__private_IsEnabledDeviceUsage() and uses it in an if-else structure
  3. Byte swapping:

    • First snippet: Performs byte swapping inline in the function call
    • Second snippet: Stores the byte-swapped value in a separate variable IPv6_HeaderSize_bswap
  4. Error sending:

    • First snippet: Always uses IppSendErrorList
    • Second snippet: Conditionally uses either IppSendError or IppSendErrorList based on the feature flag

The updated function introduces a new feature flag and optimizes the error handling process by pre-computing the byte-swapped header size and using different error sending functions based on certain conditions.

The IppSendError routine is now employed in place of IppSendErrorList when issues arise during IPv6 option handling. The key distinction between these two is that IppSendErrorList dispatches error notifications to all packets within the sequence, whereas IppSendError targets just a single packet for error communication. You can see this if you look at the decompiled code of the IppSendErrorList function:

for ( ; Packet; Packet = Packet->Next )
    result = IppSendError(
        num_one,
        Protocol,
        Packet,
        ICMPType,
        ICMPResponseCode,
        IPv6_HeaderSize_bswap,
        IppSendErrorFlag);
return result;

This function will iterates through a linked list of packets, calling the IppSendError function for each packet, and then returns the result of the last function call.

A more in-depth examination of the code surrounding the altered segment uncovers the logic: Ipv6pProcessOptions is intended to handle just a single packet (or fragment), whereas IppSendErrorList iterates through all packets within the chain. This is a clear logical error that could result in a very problematic outcome. Let's investigate further, I will include a checker to verify our understanding of the logic.

Indeed, there were six responses to three packets with errors in IPv6 options:

  • three errors per Packet1 (Packet1 → Packet2 → Packet3)
  • two errors per Packet2 (Packet2 → Packet3)
  • one error per Packet3 (Packet3)
17152.793.. fe8.. fe8.. IPv6 78 IPv6 no next header
17152.794.. fe8.. fe8.. IPv6 78 IPv6 no next header
17152.795.. fe8.. fe8.. IPv6 78 IPv6 no next header
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)
17152.804.. fe8.. fe8.. ICMP.. 126 PARAM problem (erroneous header field encountered)

Packets with errors will reach the sender only if Windows Firewall is disabled on the system. However, having the firewall enabled does not affect whether the packets sent reach the vulnerable system, as they are processed at the OS kernel level even before being processed by the firewall.

An inconvenient truth

The following snippet from the IppSendError function is the most interesting;

NetioRetreatNetBufferList(NetBufferList, base_packet->IPv6_HeaderSize, 0LL);
IPv6_HeaderSize = base_packet->IPv6_HeaderSize;
base_packet->IPv6_HeaderSize = 0;
 

In essence, this behavior is appropriate since invoking the function will halt the processing of the current packet and trigger an error. However, considering that in the vulnerable driver version, this code fragment is executed for each packet in the chain, even for those that haven't been processed, there is a possibility that the IPv6_HeaderSize field might be used for handling subsequent packets. This field, in fact, corresponds to the size of the IPv6 header, encompassing all embedded option headers.

Logically - the size field should not be equal to zero in a working packet. Where exactly to apply this primitive is not an easy task, it took me many weeks of researching.

Here is an article exploring a similar vulnerability stemming from IPv6 fragmentation, CVE-2022–34718.

The aforementioned vulnerability was in the Ipv6pReassembleDatagram realm that uses the unfamous Packet_t and Reassembly_t objects.

The Interesting part of the Ipv6pReassembleDatagram hack is that it affects the fields of the Reassembly_t object in the parent Ipv6pReceiveFragment:

if ( Location == &assemble->ContiguousStartList )
{
    assemble->NextHeader = DataBuffer->VerTC;
    assemble->RoutingHeaderOptionLength = Packet->RoutingHeaderOptionLength;
    assemble->Flags = Packet->ReassemblyFlags;
    UnfragmentableLength = LOWORD(Packet->IPv6_HeaderSize) - 0x30;
    assemble->UnfragmentableLength = UnfragmentableLength;
}

This code snippet shows an if statement that checks if Location is equal to the address of assemble->ContiguousStartList. If true, it sets various fields of the assemble structure based on values from DataBuffer and Packet structures, and calculates an UnfragmentableLength.

Here we can see that 0x30 is subtracted from the value of the IPv6_HeaderSize field, the function assumes that the size cannot be less than 48 bytes at this stage. This snippet is a great entry point to inject the primitive we mentioned earlier.

But we still need to get here... LET ME COOK 🧑‍🍳

From here we need to pass two gates;

The first (included in the code above) checks whether the fragment is the first in the chain. Although this condition reduces the exploitation possibilities, it does not negate the opportunity.

The second win condition is to check that the value of the offset is not zero, but because of the processing in IppSendError, this condition actually checks that FlowLabel in the IPv6 header structure is not zero.

Here is the tcpip.sys code once more -

if ( !FlowLabel_Offset_1 && (*&DataBuffer->FlowLabel[1] & 0x100) == 0 )
{
    KeReleaseSpinLock(&Blink[1269], NewIrql);
    Packet->NextHeaderPosition = Location;
    ++v54[1];
    ++*(v9 + 136);
    result = DataBuffer->VerTC;
    Packet->NextHeader = result;
    return result;
}

Now this struct is used in the aforementioned Ipv6pReassembleDatagram function when copying data from the buffer.

reassembly->hdr.u16PayloadLen = __ROR2__(TotalLength, 8); *DataBuffer = reassembly->hdr; memmove(&DataBuffer[1], reassembly->UnfragmentableData, reassembly->UnfragmentableLength);

It's setting the payload length, copying header information, and moving unfragmentable data into a buffer. The use of __ROR2__ is a bitwise rotation operation.

The kernel did not reach the next breakpoint - executing the necessary code. Mainly because of the next check at the beginning of the function:

if ( TotalLength > 0xFFFF ) { if ( (BYTE4(WPP_MAIN_CB.Queue.Wcb.DeviceRoutine) & 0x40) != 0 && dword_1C01FECA4 == 1 ) McTemplateK0qq_EtwWriteTransfer( MICROSOFT_TCPIP_PROVIDER_Context, &TCPIP_IP_REASSEMBLY_FAILURE_PKT_LEN, &MICROSOFT_TCPIP_PROVIDER, *pstruct_v4_0x8->gap8, 23); goto LABEL_6; }

The code snippet performs a check to determine if the variable TotalLength is greater than 0xFFFF (65535 in decimal). If TotalLength exceeds this value, the code enters an if block that conducts two additional checks. First, it verifies if bit 6 (0x40) is set in the byte at offset 4 of WPP_MAIN_CB.Queue.Wcb.DeviceRoutine. Second, it checks if the variable dword_1C01FECA4 is equal to 1. If both of these conditions are true, the code proceeds to call the function McTemplateK0qq_EtwWriteTransfer with five arguments: MICROSOFT_TCPIP_PROVIDER_Context, the address of TCPIP_IP_REASSEMBLY_FAILURE_PKT_LEN, the address of MICROSOFT_TCPIP_PROVIDER, the value at *pstruct_v4_0x8->gap8, and the constant value 23 - obviously in reference to Michael Jordan

After the if block, the code unconditionally jumps to a label named LABEL_6 using a goto statement. The code employs some non-standard naming conventions and data types, such as BYTE4 (I guess a macro to access the 4th byte of a variable), dword_1C01FECA4 (appears to be a global variable), and pstruct_v4_0x8 (looks like a pointer to a struct).

Here, the TotalLength variable is equal to the sum of the size of the newly initialized Reassembly_t with our size in underflow (it always equals 0xffd8 after exploitation):

UnfragmentableLength = reassembly->UnfragmentableLength; TotalLength = UnfragmentableLength + reassembly->DataLength;
  1. The first line assigns the value of reassembly->UnfragmentableLength to UnfragmentableLength.
  2. The second line calculates TotalLength by adding UnfragmentableLength and reassembly->DataLength.

The money shot - close your eyes

Now concretely we need to abuse another function that use the Reassembly structure, here the function Ipv6pReassemblyTimeout would do. This function also copies data from our the aforementioned buffer using negative (large positive) size.

DataBuffer = ReassemblyHeader; *ReassemblyHeader = Reassembly->hdr; DataBuffer_P1 = DataBuffer + 1; memmove(&DataBuffer[1], Reassembly->UnfragmentableData, Reassembly->UnfragmentableLength);
  1. The ReassemblyHeader is assigned to DataBuffer.
  2. The hdr field of the Reassembly structure is dereferenced and assigned to the memory location pointed to by ReassemblyHeader.
  3. DataBuffer_P1 is set to point one byte beyond the start of DataBuffer.
  4. A memory move operation is performed, copying UnfragmentableLength bytes from Reassembly->UnfragmentableData to DataBuffer starting at the second byte (index 1).

In order to activate this code snippet, we need to set FlowLimit equal to one (to trigger IppSendError, the driver will interpret this field as FragOffset) and wait for one minute.

The pool buffer in the Windows kernel will now overflows with values almost completely controlled by me - because it is quite easy to spray the necessary chunks nearby.

As per my last email

Obviously, without the kernel addresses beyond KASLR its a bit of a waste. The above method would make sense only in a scenario where we can fully flex all the modern KASLR abuse methods with escalation & targets.

The primitive above provides excellent opportunities for local privilege escalation and remote BSOD. But the limitations on the size of the allocated memory section are still an obstacles that prevent a full RCE.

Key takeaways

The Windows networking module is a complex parts of the operating system. The exploitation primitive presented in this article can likely be used by other sophisticated attack methods that will lead to KASLR leakage and kernel compromise. We're only 1 kernel vulnerability away (that compromise KASLR addresses) - we then can chain it with the method detailed today and its a full RCE. Certainly one of the nastiest hack in the recent few years.

How to mitigate the attack vector

Disable IPv6 at the hosts where it is not used.

Jay ☕