---
key: "subdomain-takeover"
title: "Prevent subdomain takeover from dangling DNS records"
url: "https://softwaresecurity.com/domains/runtime/cloud-posture/subdomain-takeover/"
api: "https://softwaresecurity.com/api/v1/articles/subdomain-takeover.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["A02:2025","V3.3.3","V3.4.2","V10.4.1","WSTG-CONF-10"]
tags: ["TLS","Cloud security","Infrastructure as code"]
stages: ["operate"]
roles: ["platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Prevent subdomain takeover from dangling DNS records

A DNS record that outlives its cloud resource lets whoever claims the name serve content on your domain. Bind records to resources in IaC, remove the record first, verify domains and scan for dangling records.

Canonical URL: https://softwaresecurity.com/domains/runtime/cloud-posture/subdomain-takeover/

## In short

Never let a DNS record outlive the resource it names. Create the resource and verify the domain with the provider before you publish the record, and remove the record, wait out its TTL, and only then delete the resource. Encode that order in infrastructure as code by building each record from its resource's attributes, and prefer alias records and domain-verification features (Azure's `asuid` TXT record, GitHub Pages verified domains, CloudFront's certificate check, S3's account regional namespace) that stop a stranger from binding your name. Keep cookies host-only with `__Host-`, list exact origins in CORS, CSP and OAuth instead of `*.example.com`, and scan every zone on a schedule for CNAME targets that do not exist, delegations that are refused and addresses you no longer own.

- A takeover needs two things: a record still pointing at a provider name or address, and a provider that lets a stranger claim that name once you release it.
- CNAMEs are the usual case, but A records to released IP addresses and NS delegations to deleted hosted zones are takeovers too, and an NS takeover hands over the whole subzone.
- Delete the record first: the Route 53 documentation says to remove a delegation, wait for its TTL to expire and only then delete the child zone, or someone could hijack it.
- Domain verification closes the provider side: with an `asuid` TXT record in place, Microsoft says no other Azure subscription can bind the custom domain.
- A taken-over subdomain receives cookies scoped to the parent domain, passes `*.example.com` allowlists and can obtain a publicly trusted certificate for itself.
- CAA records with `accounturi` limit which ACME account can get certificates for your names; they do not prevent the takeover itself.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Order on creation | Resource, then domain verification, then the DNS record | A record that exists before its resource is dangling from the start. | OWASP Subdomain Takeover Prevention Cheat Sheet |
| Order on removal | Remove the record, wait one TTL, then delete the resource | For a delegated subzone, remove the NS record in the parent first. | Route 53 Developer Guide: Deleting a public hosted zone |
| Record definition | Built from the resource's attributes in IaC, as an alias record where the provider offers one | The dependency graph then orders creation and deletion for you. | Microsoft Learn: Prevent dangling DNS entries |
| Domain verification | On wherever the provider supports it, and kept in place | Azure App Service `asuid.<sub>` TXT, GitHub Pages verified domains, a certificate for every CloudFront alternate domain name. | Microsoft Learn; GitHub Docs; CloudFront Developer Guide |
| Reusable resource names | Keep the resource, or use a namespace only you can own | Empty an S3 bucket instead of deleting it, or create buckets in the account regional namespace. | Amazon S3 User Guide: bucket naming rules |
| Wildcard records to third-party services | None | GitHub warns that a wildcard leaves deeper names open even on a verified domain. | GitHub Docs: Verifying your custom domain |
| Cookies and allowlists | `__Host-` cookies; exact origins in CORS, CSP and OAuth redirect URIs | A taken-over sibling then gets no session and no trust. | ASVS 5.0 V3.3.3, V3.4.2, V10.4.1 |
| Certificate issuance | CAA with `accounturi` (and `validationmethods`) where your CA supports them; Certificate Transparency monitoring | Limits who can get certificates; does not prevent the takeover. | RFC 8657; Let's Encrypt CAA documentation |
| Scanning | Every zone, daily and after each DNS or teardown change | Check CNAME targets, NS delegations and A records against your inventory. | OWASP Subdomain Takeover Prevention Cheat Sheet |

## Verify it

1. Export every public zone you own and run a dangling-record scan over CNAME, NS, A, AAAA and MX records; confirm it reports zero findings, and that it does report one when you point a test record at a `.invalid` name.
2. For each CNAME to a cloud or SaaS provider, confirm the provider's domain verification is in place (Azure `asuid` TXT, GitHub Pages verified domain, CloudFront certificate).
3. Destroy a test stack built with your IaC module and confirm the DNS record is deleted before the resource, and the delegation before the child zone, with the wait in between.
4. Confirm session cookies use the `__Host-` prefix and that CORS, CSP and OAuth redirect allowlists contain no `*.example.com` patterns.
5. Confirm CAA records exist for your domains and, if your CA supports it, use `accounturi`; confirm Certificate Transparency alerts reach someone.
6. Check that your decommissioning checklist lists DNS removal, the TTL wait and allowlist cleanup before resource deletion.

## What you will learn

- Explain how a dangling CNAME, A or NS record becomes a takeover, and which provider names can be reclaimed.
- Order creation and deletion of records and resources so no dangling window opens, and encode that order in Terraform.
- Use domain verification, alias records and reserved namespaces where providers offer them.
- Limit what a taken-over subdomain can reach: cookies, CORS, CSP, OAuth redirects and certificates.
- Scan zones for dangling records and respond when one is found.

Audience: Platform, cloud and DNS engineers who create and retire public endpoints, and application teams whose cookies, CORS and OAuth settings trust their subdomains.

Before you start:

- Know the DNS record types CNAME, A, AAAA, NS and MX, and what a TTL does.
- Manage DNS records in a provider console or with Terraform or OpenTofu.
- For the examples: Python 3.13 or later, and Docker for `terraform validate`.

Time: About 50 minutes: 20 to read, 10 to run the scanner and 20 for the exercise

## A record that outlives its resource

Microsoft's definition is the practical one: a subdomain takeover can occur when a DNS record points to a deprovisioned resource, and such records are called dangling DNS entries. The record still says `shop.example.com` lives at `shop-old.azurewebsites.net`, but nothing of yours answers there. If the provider lets a new customer create a resource with that same name, and bind your host name to it, that customer now serves `shop.example.com`.

The OWASP cheat sheet explains why it keeps happening: cloud resources are short-lived while DNS records persist, different teams often own the resource and the zone, nothing links a record to the existence of its target, and acquisitions bring zones nobody documented. OWASP WSTG-CONF-10 describes how testers look for it. The record types below can all dangle.

| Record | Dangles when | What the claimant gets |
| --- | --- | --- |
| CNAME (most common) | The target host name at a cloud or SaaS provider is released and can be re-created by anyone | The subdomain's web traffic |
| A or AAAA | A released elastic or public IP address is reassigned to another customer | All traffic to that name on every port |
| NS (delegation) | The delegated zone is deleted at the DNS host, and someone can create it again on the same name servers | The whole subzone: any record under it, including MX and TXT |
| MX | The mail service behind the record is released | Mail for the name, including password resets, and mail-based certificate validation |
| Wildcard to a provider | Any name under it is unclaimed at the provider | Every unused subdomain under the wildcard |

## Why it is an application security problem

A taken-over subdomain is a same-site origin under your registrable domain, so everything that trusts "our subdomains" trusts the attacker. Cookies set with `Domain=example.com` are sent to it, and it can set cookies for the parent that your application will read. CORS policies that accept any `https://*.example.com` origin, CSP sources such as `*.example.com`, `postMessage` checks on the domain suffix, and OAuth or OIDC clients that register wildcard redirect URIs all give it access. Microsoft notes that the attacker can also apply for and receive a valid certificate for the hijacked name, which defeats the assumption that HTTPS protects secure cookies.

The certificate part follows from how domain validation works. ACME's HTTP challenge (RFC 8555) proves control of a name by serving a file from it, and whoever controls the content behind the name can do that. MX takeovers add mail: password reset links and mail-based validation for certificates. Phishing on a real company host name is the least technical consequence and often the first one reported.

Treat these as the blast radius to shrink, independently of preventing takeovers: name session cookies `__Host-` so they never leave the exact host (ASVS 5.0 V3.3.3, and /domains/appsec/browser-security/security-headers/), validate CORS origins against an exact allowlist (V3.4.2, /domains/appsec/browser-security/cors-csp-browser-boundaries/), and register exact redirect URIs (V10.4.1, /domains/identity-crypto/authentication/oauth-oidc-attacks/).

## Which provider names can be reclaimed

Whether a dangling record is exploitable depends on the provider. The question is whether a new customer can claim a name you released and bind your host name to it. The table summarizes what each provider documents today; the OWASP cheat sheet keeps a broader list and points to the community project can-i-take-over-xyz, which is third-party and changes often, so check the provider's own documentation before you rely on either.

| Service | Reclaimable? | Provider control that helps |
| --- | --- | --- |
| Amazon S3 buckets (website endpoints) | Yes: AWS says another account can create a deleted bucket's name and receive requests meant for it | Keep the bucket and empty it, or create buckets in the account regional namespace (`-<account>-<region>-an`); website buckets must match the host name, so front them with CloudFront |
| Amazon CloudFront | Only with a certificate | Adding an alternate domain name requires a trusted certificate covering it |
| Route 53 hosted zones (NS delegation) | Yes: AWS warns that deleting a hosted zone lets someone hijack the domain | Delete the parent's NS record first, wait for its TTL, then delete the child zone |
| Azure App Service, Front Door, Traffic Manager, CDN, Blob Storage, public IP DNS labels | Yes for released names | `asuid` TXT verification for App Service; alias records for Front Door, Traffic Manager, CDN and public IPs; Defender for App Service alerts |
| Azure classic cloud services (`cloudapp.net`) | After a reservation period | Only subscriptions in the original Microsoft Entra tenant can reclaim the name during the reservation |
| GitHub Pages | Yes for unverified custom domains | Verify the domain for the user or organization; it also covers immediate subdomains |
| Elastic and public IP addresses | Yes once released to the pool | Keep allocations you still publish, or use provider alias records |

## Order the record and resource lifecycle

Most takeovers are an ordering bug in a manual process. On creation, create the resource, complete the provider's domain verification, and publish the DNS record last. On removal, do the reverse: point the name at a maintenance page or delete the record, wait at least one TTL so resolvers drop the cached answer, and only then delete the resource. The OWASP cheat sheet warns that deleting the resource first opens an immediate window for takeover.

Delegations need the same care at a larger scale. The Route 53 guide says to delete the parent's NS record for a child zone first and wait for that record's TTL to expire before deleting the child hosted zone, so no one can hijack the child zone while resolvers still have its name servers cached. Make "remove DNS records" a required step in every decommissioning checklist, together with removing the host name from OAuth clients, CORS and CSP allowlists, and revoking its certificates. Microsoft suggests a delete lock on any resource with a custom DNS entry, as a reminder that the record must go first.

Some names should never be released. AWS recommends emptying a bucket and keeping it rather than deleting it if you want to stop another account from receiving its requests. Where the provider offers a namespace only your account can own, such as S3's account regional namespace, use it for new resources.

## Terraform on AWS: alias records and a delegation that waits

Infrastructure as code turns the lifecycle rule into a dependency graph. A record whose value is computed from the resource (`aws_lb.shop.dns_name`, `aws_route53_zone.dev.name_servers`) is created after the resource and destroyed before it. Route 53 alias records also track the target's current address, so no raw host name or IP is copied into the zone. For a delegated subzone, the `time_sleep` resource from the `hashicorp/time` provider makes destruction wait 300 seconds, the NS record's TTL, between removing the delegation and deleting the child zone. The configuration passed `terraform validate` with Terraform 1.16.5 and the AWS provider 6.68.0; it was not applied to a real account.

```hcl
terraform {
  required_providers {
    aws  = { source = "hashicorp/aws", version = "6.68.0" }
    time = { source = "hashicorp/time", version = "0.14.2" }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "public_subnet_ids" {
  type = list(string)
}

resource "aws_route53_zone" "main" {
  name = "example.com"
}

resource "aws_lb" "shop" {
  name               = "shop"
  load_balancer_type = "application"
  subnets            = var.public_subnet_ids
}

# An alias record built from the load balancer's own attributes. Terraform creates the load
# balancer first and, on destroy, deletes this record before the load balancer.
resource "aws_route53_record" "shop" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "shop.example.com"
  type    = "A"

  alias {
    name                   = aws_lb.shop.dns_name
    zone_id                = aws_lb.shop.zone_id
    evaluate_target_health = true
  }
}

# A delegated subdomain. On destroy the NS record goes first, then Terraform waits out its
# TTL before deleting the child zone, as the Route 53 documentation recommends.
resource "aws_route53_zone" "dev" {
  name = "dev.example.com"
}

resource "time_sleep" "dev_ns_ttl" {
  depends_on       = [aws_route53_zone.dev]
  destroy_duration = "300s"
}

resource "aws_route53_record" "dev_delegation" {
  depends_on = [time_sleep.dev_ns_ttl]
  zone_id    = aws_route53_zone.main.zone_id
  name       = "dev.example.com"
  type       = "NS"
  ttl        = 300
  records    = aws_route53_zone.dev.name_servers
}
```

## Terraform on Azure: domain verification and alias records

On Azure, the App Service custom domain is protected by the `asuid.<subdomain>` TXT record holding the app's domain verification ID: Microsoft states that while that record exists, no other Azure subscription can validate the custom domain or take it over. Someone can still create an app with the old name, but they cannot bind your host name to it. For public IPs, Front Door, Traffic Manager and CDN endpoints, an Azure DNS alias record (`target_resource_id`) couples the record to the resource, so deleting the resource leaves an empty record set instead of a dangling one. The configuration passed `terraform validate` with the AzureRM provider 5.9.0. The OWASP cheat sheet suggests keeping the verification TXT record even after decommissioning; if you do, manage it outside the app's lifecycle.

```hcl
terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "5.9.0" }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "shop" {
  name     = "rg-shop"
  location = "westeurope"
}

resource "azurerm_dns_zone" "main" {
  name                = "example.com"
  resource_group_name = azurerm_resource_group.shop.name
}

resource "azurerm_service_plan" "shop" {
  name                = "asp-shop"
  resource_group_name = azurerm_resource_group.shop.name
  location            = azurerm_resource_group.shop.location
  os_type             = "Linux"
  sku_name            = "B1"
}

resource "azurerm_linux_web_app" "shop" {
  name                = "app-shop-7f3c"
  resource_group_name = azurerm_resource_group.shop.name
  location            = azurerm_resource_group.shop.location
  service_plan_id     = azurerm_service_plan.shop.id
  site_config {}
}

# Domain verification: with asuid.shop in place, no other subscription can bind shop.example.com.
resource "azurerm_dns_txt_record" "shop_asuid" {
  name                = "asuid.shop"
  zone_name           = azurerm_dns_zone.main.name
  resource_group_name = azurerm_resource_group.shop.name
  ttl                 = 3600
  record {
    value = azurerm_linux_web_app.shop.custom_domain_verification_id
  }
}

resource "azurerm_dns_cname_record" "shop" {
  name                = "shop"
  zone_name           = azurerm_dns_zone.main.name
  resource_group_name = azurerm_resource_group.shop.name
  ttl                 = 300
  record              = azurerm_linux_web_app.shop.default_hostname
}

resource "azurerm_app_service_custom_hostname_binding" "shop" {
  hostname            = "shop.example.com"
  app_service_name    = azurerm_linux_web_app.shop.name
  resource_group_name = azurerm_resource_group.shop.name
  depends_on          = [azurerm_dns_txt_record.shop_asuid, azurerm_dns_cname_record.shop]
}

# An alias A record: if the public IP is deleted, the record set becomes empty instead of dangling.
resource "azurerm_public_ip" "api" {
  name                = "pip-api"
  resource_group_name = azurerm_resource_group.shop.name
  location            = azurerm_resource_group.shop.location
  allocation_method   = "Static"
}

resource "azurerm_dns_a_record" "api" {
  name                = "api"
  zone_name           = azurerm_dns_zone.main.name
  resource_group_name = azurerm_resource_group.shop.name
  ttl                 = 300
  target_resource_id  = azurerm_public_ip.api.id
}
```

## Limit certificate issuance and watch for it

A CAA record (RFC 8659) names the certificate authorities allowed to issue for a domain. On its own it does not stop a takeover, because the attacker can usually use the same CA you do. RFC 8657 adds two parameters that do help: `accounturi` restricts issuance to a specific ACME account, and `validationmethods` restricts the challenge types. Let's Encrypt documents `accounturi` as a way to ensure that someone who temporarily hijacks your domain but lacks your ACME account key cannot issue certificates. Support differs between CAs, and CAA is checked on the exact name and then its parents, so test the records with your CA before relying on them.

Monitor Certificate Transparency logs for your domains, through crt.sh, a CT monitoring service or your CA's notifications, and alert on certificates issued to names or accounts you do not recognise. An unexpected certificate for a forgotten subdomain is often the first sign of a takeover. TLS operations in general are covered in /domains/identity-crypto/key-management-pki/tls-pki-operations/.

```text
; Only Let's Encrypt, only from your ACME account, only by DNS-01 validation.
example.com.  3600  IN  CAA  0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890;validationmethods=dns-01"
example.com.  3600  IN  CAA  0 issuewild ";"
example.com.  3600  IN  CAA  0 iodef "mailto:security@example.com"
```

## Scan your zones for dangling records

Detection compares each record with the thing it names. For a CNAME, resolve the target: NXDOMAIN means the provider name no longer exists, and some providers, such as S3 website endpoints, answer for every name, so you must also check for their "nothing here" response, such as `NoSuchBucket`. For an NS delegation, ask each listed name server for the zone's SOA: REFUSED or SERVFAIL means the zone is gone from that server. For an A or AAAA record, compare the address with the inventory of addresses your accounts hold. Run the scan daily and after every DNS change or teardown.

The script below implements those checks with dnspython. Its resolver, name-server query and HTTP fetch are injectable, so the tests run offline against a fixed table, plus one live lookup of a `.invalid` name, which RFC 6761 reserves so that it never exists. All eight tests pass on Python 3.14.8 with dnspython 2.9.0. For Azure, Microsoft publishes the `Get-DanglingDnsRecords` PowerShell tool, and the Defender for App Service plan alerts when an App Service is decommissioned while its custom domain is still in DNS. Scanners with large provider fingerprint lists exist as community projects; treat their lists as hints and confirm with the provider.

```python
"""Find DNS records in your zones that point at things that no longer exist or are not yours.

Input: a JSON list of records, e.g. exported from your DNS provider or IaC state:
  [{"name": "shop.example.com", "type": "CNAME", "values": ["shop-prod.azurewebsites.net"]}, ...]
Usage: python3 dangling.py records.json --owned-ips owned-ips.txt
"""
import json
import sys

import dns.exception
import dns.message
import dns.query
import dns.rcode
import dns.resolver

# Provider host suffixes whose names a stranger can claim once you release them, and the text
# their "nothing here" page shows. Keep your own list current; these two are examples.
FINGERPRINTS = {
    ".s3-website": "NoSuchBucket",
    ".github.io": "There isn't a GitHub Pages site here.",
}


class Checker:
    def __init__(self, resolver=None, ns_rcode=None, http_get=None, owned_ips=frozenset()):
        self.resolver = resolver or dns.resolver.Resolver()
        self.ns_rcode = ns_rcode or self._ns_rcode
        self.http_get = http_get
        self.owned_ips = owned_ips

    def _ns_rcode(self, server_name, zone):
        """Ask one of the delegated name servers for the zone's SOA; return the DNS rcode."""
        address = next(iter(self.resolver.resolve(server_name, "A"))).to_text()
        query = dns.message.make_query(zone, "SOA")
        return dns.query.udp(query, address, timeout=3).rcode()

    def check(self, record):
        name, rtype, values = record["name"], record["type"].upper(), record["values"]
        if rtype == "CNAME":
            target = values[0].rstrip(".")
            try:
                self.resolver.resolve(target, "A")
            except dns.resolver.NXDOMAIN:
                return f"{name}: CNAME target {target} does not exist (NXDOMAIN)"
            except dns.resolver.NoAnswer:
                pass  # the name exists; it may have only AAAA or other records
            for suffix, marker in FINGERPRINTS.items():
                if suffix in target and self.http_get and marker in self.http_get(f"http://{name}/"):
                    return f"{name}: {target} answers with the provider's unclaimed page ({marker!r})"
        elif rtype == "NS":
            for server in values:
                try:
                    rcode = self.ns_rcode(server.rstrip("."), name)
                except (dns.exception.DNSException, OSError) as error:
                    return f"{name}: delegated name server {server} unreachable ({type(error).__name__})"
                if rcode != dns.rcode.NOERROR:
                    return f"{name}: {server} answers {dns.rcode.to_text(rcode)} for the delegated zone"
        elif rtype in ("A", "AAAA"):
            stray = [ip for ip in values if ip not in self.owned_ips]
            if stray:
                return f"{name}: {rtype} {', '.join(stray)} is not in your inventory of allocated addresses"
        return None


def main(argv):
    records = json.load(open(argv[1]))
    owned = set()
    if "--owned-ips" in argv:
        owned = {line.strip() for line in open(argv[argv.index("--owned-ips") + 1]) if line.strip()}
    checker = Checker(owned_ips=owned)
    findings = [f for f in (checker.check(r) for r in records) if f]
    print("\n".join(findings) or "no dangling records found")
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
```

## Check one name by hand

When a report names a single host, these commands answer the questions in order: what does the record say, does its target exist, and does the delegated zone still answer. A name server that does not host a zone answers REFUSED for it; in a test against a real delegation, a Route 53 name server answered NOERROR for the zone it hosts and REFUSED for `example.com`. Run the commands only against your own names.

```bash
# 1. What does the record point to?
dig +short CNAME shop.example.com
# 2. Does the target exist? status: NXDOMAIN means no.
dig shop-old.azurewebsites.net A | grep status:
# 3. A delegated zone: read the delegation from a parent name server, then ask each child server.
PARENT_NS=$(dig +short NS example.com | head -1)
dig +norecurse NS dev.example.com @"$PARENT_NS" | sed -n '/AUTHORITY SECTION/,/^$/p'
dig +norecurse SOA dev.example.com @ns-1.dns-host.example | grep status:   # REFUSED or SERVFAIL: dangling
# 4. For a provider that answers every name, read the page.
curl -s http://www.example.com/ | grep -E "NoSuchBucket|There isn.t a GitHub Pages site here"
```

## If a subdomain has been taken over



- Remove or repoint the DNS record first; that ends new traffic once caches expire. Lower the TTL in advance for names you are about to change.
- Reclaim the provider resource name if you can, so the record can point somewhere you control again.
- Search Certificate Transparency logs for certificates issued for the name during the exposure window and ask the CA to revoke them.
- Assume cookies scoped to the parent domain were exposed: invalidate sessions and rotate any tokens that could have been sent to the name.
- Remove the name from OAuth redirect URIs, CORS and CSP allowlists and SPF records, and review authorization-server logs for codes delivered to it.
- Scan every zone for other dangling records, then fix the process gap that left this one, and record the timeline as you would for any incident (/domains/appsec/vulnerability-response/product-incident-response/).

## Pitfalls



- Deleting the resource first and the record "later": the window opens immediately and nobody owns closing it.
- Records created by hand in a zone that is otherwise managed by IaC, so no state file knows they exist.
- Trusting `*.example.com` in CORS, CSP or OAuth because every subdomain is "ours": one forgotten record makes it untrue.
- Wildcard DNS records pointing at a hosting provider, which leave every unused name claimable.
- Scanning only CNAMEs: NS delegations hand over a whole zone, and A records to released IPs are easy to miss.
- Assuming CAA alone blocks certificate issuance after a takeover; without `accounturi`, the attacker can usually use your CA too.
- Zones from acquisitions, marketing campaigns and old environments that are in no inventory and no scan.

## Guided practice

The zone export and lookup results below come from `example.com`. Classify each record as healthy, dangling or at risk, decide the fix and its order, and say what the dangling ones would give an attacker.

### Supplied material

```text
Zone export (name, type, value)                       Lookup result
R1 shop.example.com     A (alias)  -> ALB shop-prod        alias target exists in account 1111
R2 www.example.com      CNAME www.example.com.s3-website-eu-west-1.amazonaws.com
                                                            resolves; HTTP body contains "NoSuchBucket"
R3 promo.example.com    CNAME promo-2024.azurewebsites.net  NXDOMAIN; no asuid.promo TXT record
R4 dev.example.com      NS ns-1.dns-host.example, ns-2.dns-host.example
                                                            both name servers answer REFUSED for dev.example.com
R5 legacy.example.com   A 198.51.100.7                      address not in any account's allocated list
R6 docs.example.com     CNAME example-org.github.io         Pages site exists; example.com is a verified domain
R7 *.apps.example.com   CNAME apps-gw.herokudns.example     wildcard to a hosting provider

Application settings: session cookie "sid; Domain=example.com"; CORS allows any https://*.example.com
origin; the OAuth client registers https://*.example.com/callback.
```

### 1. Classify the records

Mark R1 to R7 healthy, dangling or at risk, with one reason each.

Worked answer: R1 healthy (alias to an existing resource). R2 dangling: the website endpoint answers for any name and the bucket is gone, so anyone can create a bucket named `www.example.com`. R3 dangling: the app name is free and nothing verifies the domain. R4 dangling: the delegated zone no longer exists at its DNS host. R5 dangling or at risk: an address you no longer hold. R6 healthy: the domain is verified. R7 at risk: every unused name under the wildcard is claimable.

### 2. Rank by blast radius

Decide which dangling record is most serious and why.

Worked answer: R4: whoever recreates `dev.example.com` at that DNS host controls every name and record type under it, including MX and TXT. Then R2 and R3, which serve web content under the parent domain's cookies and allowlists.

### 3. Fix in the right order

Write the remediation steps for R2, R3 and R4.

Worked answer: Delete the R2 and R3 records now (or point them at a maintenance page you own) and the R4 NS record in the parent zone; then decide whether each name is still needed. If `www` should keep working, recreate the bucket yourself and put CloudFront with a certificate in front; for `promo`, add the `asuid.promo` TXT before re-creating the record. Remove R5 unless the address is reallocated to you.

### 4. Shrink the blast radius

Change the three application settings so a future takeover gets nothing.

Worked answer: Rename the cookie `__Host-sid` with no `Domain` attribute; replace the CORS pattern with an exact list of origins; register exact OAuth callback URIs. These are ASVS 5.0 V3.3.3, V3.4.2 and V10.4.1.

### 5. Prevent the next one

Name two controls that would have stopped R2 to R5 from appearing.

Worked answer: Records built from resource attributes in IaC, so destroy deletes the record first (with a TTL wait for delegations); and a scheduled scan of every zone that fails on NXDOMAIN targets, refused delegations and unowned addresses. Add the TTL wait and allowlist cleanup to the decommissioning checklist.

Deliverable: A table of R1 to R7 with the classification, the fix and its order, plus the three corrected application settings.

Scope: Paper exercise on a fictional zone. Run scanners only against zones you own, and never claim a provider resource to "prove" a takeover on someone else's domain.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Subdomain Takeover Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Subdomain_Takeover_Prevention_Cheat_Sheet.html)
- [OWASP WSTG: Test for Subdomain Takeover (WSTG-CONF-10)](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover). Version 4.2.
- [Microsoft Learn: Prevent dangling DNS entries and avoid subdomain takeover](https://learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover). Updated 20 July 2026.
- [Microsoft Learn: Azure DNS alias records](https://learn.microsoft.com/en-us/azure/dns/dns-alias)
- [Amazon S3 User Guide: General purpose bucket naming rules (name reuse and account regional namespace)](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html)
- [Amazon Route 53 Developer Guide: Deleting a public hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DeleteHostedZone.html)
- [Amazon CloudFront Developer Guide: Use custom URLs by adding alternate domain names](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html)
- [GitHub Docs: Verifying your custom domain for GitHub Pages](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages)
- [RFC 8659: DNS Certification Authority Authorization (CAA) Resource Record](https://www.rfc-editor.org/rfc/rfc8659.html). RFC 8659 (November 2019).
- [RFC 8657: CAA Record Extensions for Account URI and ACME Method Binding](https://www.rfc-editor.org/rfc/rfc8657.html). RFC 8657 (November 2019).
- [Let's Encrypt: Certificate Authority Authorization (CAA)](https://letsencrypt.org/docs/caa/)
- [RFC 8555: Automatic Certificate Management Environment (ACME), HTTP challenge](https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3). RFC 8555 (March 2019).
- [RFC 6761: Special-Use Domain Names (.invalid)](https://www.rfc-editor.org/rfc/rfc6761.html#section-6.4). RFC 6761 (February 2013).
- [Terraform Registry: time_sleep resource (hashicorp/time)](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep). hashicorp/time 0.14.2.
- [OWASP ASVS 5.0.0: V3 Web Frontend Security](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x12-V3-Web-Frontend-Security.md). Version 5.0.0.