← News
/PLAYBOOK · 2026-09-15

Subdomain Takeover Detection and Defense: Hunting Dangling CNAMEs

Subdomain takeover happens when a CNAME still points at a decommissioned third-party host; an attacker who registers the same resource owns the subdomain. This article covers a dnspython bulk dangling-CNAME scanner, third-party fingerprints, dig/curl cross-checks, plus record deletion, CAA, and daily monitoring.

Subdomain takeover: a CNAME still points at a decommissioned third-party service; an attacker who registers the same resource fully controls the subdomain. This article gives a bulk dangling-CNAME scanner, third-party fingerprinting, plus fix and verification, so ops can inventory owned assets without a commercial platform.

Scope

Prerequisites

# Debian / Ubuntu
apt-get install -y dnsutils python3-pip
pip3 install dnspython requests

# CentOS / RHEL
yum install -y bind-utils python3-pip

Mechanism

Takeover requires both conditions.

First, a dangling CNAME at the DNS layer. Example: a campaign page set `promo.example.com CNAME example.github.io`. After the campaign the GitHub repo is deleted but the DNS record remains. The resolution chain ends at a nonexistent resource.

Second, the third-party does not verify domain ownership before allowing occupancy. GitHub Pages, some object stores, and PaaS platforms let anyone create a same-named resource and claim the hostname if no custom domain is bound. Traffic then lands on the attacker's origin. No proof of ownership required.

Once taken over, the attacker can:

Detection is therefore two steps: find CNAMEs that point at a third-party host returning unclaimed fingerprints; decide whether that third-party is a takeover-vulnerable class.

Procedure

Step 1: Build the subdomain list

Pull public names from certificate transparency as the base inventory:

curl -s "https://crt.sh/?q=%25.example.com&output=json" \
  | python3 -c "
import json,sys
d=json.load(sys.stdin)
names=set()
for e in d:
    for n in e.get('name_value','').split('\n'):
        n=n.strip().lower().lstrip('*.')
        if n.endswith('example.com'):
            names.add(n)
print('\n'.join(sorted(names)))
" > subdomains.txt

wc -l subdomains.txt

Step 2: Write the dangling-CNAME scanner

Logic: resolve CNAME, identify records pointing at third-party hosts, HTTP-probe the target, compare against known unclaimed fingerprints.

# -*- coding: utf-8 -*-
import json, re, sys
import dns.resolver, requests

DOMAIN = "example.com"

# Common fingerprints of takeover-vulnerable third-party hosting services
FINGERPRINTS = [
    # (CNAME pattern, body/status fingerprint, service name)
    (r"\.github\.io$",        r"There isn't a GitHub Pages site here", "GitHub Pages"),
    (r"\.s3[.-]",             r"NoSuchBucket",                            "AWS S3"),
    (r"\.herokuapp\.com$",    r"No such app",                             "Heroku"),
    (r"\.azurewebsites\.net$",r"Error 404 - Web app not found",           "Azure Web App"),
    (r"\.cloudfront\.net$",   r"Bad request",                             "CloudFront"),
    (r"\.myshopify\.com$",    r"Sorry, this shop is currently unavailable","Shopify"),
    (r"\.readthedocs\.io$",   r"unknown to Read the Docs",                "Read the Docs"),
    (r"\.surge\.sh$",         r"project not found",                       "Surge"),
    (r"\.netlify\.app$",      r"Not Found - Request ID",                  "Netlify"),
    (r"\.trafficmanager\.net$", r"NXDOMAIN",                              "Azure Traffic Manager"),
]

resolver = dns.resolver.Resolver()
resolver.timeout, resolver.lifetime = 3.0, 5.0

def cname_chain(host):
    chain = []
    try:
        ans = resolver.resolve(host, "CNAME")
        for r in ans:
            chain.append(str(r.target).rstrip("."))
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN,
            dns.resolver.NoNameservers, dns.exception.Timeout):
        pass
    return chain

def probe(host):
    for scheme in ("https", "http"):
        try:
            r = requests.get(f"{scheme}://{host}/", timeout=8,
                             allow_redirects=True,
                             headers={"User-Agent": "asset-audit/1.0"},
                             verify=False)
            return r.status_code, r.text[:5000]
        except requests.RequestException:
            continue
    return None, ""

def main():
    hosts = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
    findings = []
    for h in hosts:
        chain = cname_chain(h)
        if not chain:
            continue
        tail = chain[-1].lower()
        for cname_re, body_re, svc in FINGERPRINTS:
            if not re.search(cname_re, tail):
                continue
            code, body = probe(h)
            dangling = bool(re.search(body_re, body, re.I)) or code in (404, 502, None)
            findings.append(dict(host=h, cname=tail, service=svc,
                                 http=code, dangling=dangling))
            print(f"[{'DANGLING' if dangling else 'review  '}] "
                  f"{h} -> {tail} ({svc}, HTTP {code})")
    json.dump(findings, open("dangling_report.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    print(f"\nConclusion: {sum(1 for f in findings if f['dangling'])} dangling records / "
          f"{len(findings)} third-party CNAMEs")

if __name__ == "__main__":
    main()

Run and inspect:

python3 cname_audit.py subdomains.txt
cat dangling_report.json | head -40

Key parameters: `verify=False` skips cert errors so you can read the real body; use only on owned assets. `timeout=8` avoids hanging on jitter. Timeouts are not strong `dangling` evidence; recheck them.

Step 3: Manual cross-check of suspects

# 1. Inspect the full resolution chain
dig +short promo.example.com CNAME
dig +short promo.example.com A

# 2. Confirm CDN/hosting service response fingerprints
curl -sI https://promo.example.com/ | head -5
curl -s https://promo.example.com/ | head -30

# 3. NXDOMAIN or a third-party default error page basically confirms dangling
dig +trace promo.example.com | tail -20

Decision criteria: CNAME exists, the target resource does not exist or is unbound to this hostname, and the platform allows occupancy without ownership proof. All three = real risk.

Step 4: Fix

; Domain CAA: only the named CA may issue certificates for this zone
example.com.  IN  CAA  0 issue "letsencrypt.org"
example.com.  IN  CAA  0 issuewild ";"

Step 5: Continuous monitoring

# Run once daily at 03:00 and email an alert (sample crontab)
0 3 * * * cd /opt/asset-audit && python3 cname_audit.py subdomains.txt \
          >> audit-$(date +\%F).log 2>&1 \
          && grep -q DANGLING audit-$(date +\%F).log \
          && mail -s "dangling CNAME found" ops@example.com < audit-$(date +\%F).log

Verification

FAQ

FAQ1: CNAME points at a third-party and the resource still exists. Do we still fix it?

This is "owned but high-risk." Fix it, lower priority. Existence ≠ control: a departed employee's personal account, or a deleted org account, can be reclaimed any time. Inventory these records, assign an owner and renewal date, and confirm the platform account belongs to the company. If the resource sits on an internal account with MFA, keep it and add it to the quarterly review list.

FAQ2: The apex has a wildcard (* record). Does that amplify risk?

Yes. A wildcard makes every nonexistent subdomain resolve to the same address. If that address is a third-party host, an attacker can pick any prefix, trip the dangling condition, and use search engines plus CT logs to find it fast. Wildcard also makes enumeration hit everything and floods you with false positives. Record wildcard response fingerprints separately; focus review on exact records outside the wildcard.

FAQ3: The scanner reports many false positives.

Three sources: the site's custom 404 contains a third-party product name; WAF/CDN intercepts probes and returns a uniform error page; the third-party resource exists but a hostname allowlist returns 404 for unknown Host. Cut false positives by requiring all three: CNAME target reachable?, platform requires ownership proof?, response body/status matches the official unclaimed copy? All three before calling it dangling. Also `dig` the CNAME target itself; NXDOMAIN on the target is highest confidence.

Summary

Subdomain takeover is an inventory/DNS drift problem. Detection is cheap: one full subdomain resolve plus one HTTP probe covers most risk. The fix is usually deleting one record. Put "retire a service → delete its DNS" into change control, quarterly-review every CNAME to a third-party host, and if possible hang the scanner on a daily cron so dangling records are found the day they appear.