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
- Large backlog of historical subdomains that once used GitHub Pages, object storage, Heroku, Azure, Shopify, cloud functions, or similar external hosts.
- Temporary campaign domains never cleaned up, or a service was retired but DNS was not deleted.
- Need a one-shot risk review of every subdomain under the apex.
- Site uses HSTS or sets cookies on the parent; a taken-over subdomain can ride the parent session.
Prerequisites
- A subdomain list: certificate transparency, internal CMDB, or historical resolver data.
- Linux with `dig` (bind-utils / dnsutils) and Python 3.8+.
- Python deps: `dnspython`, `requests`.
- The scanner only does passive resolution and HTTP probes. No writes. Safe to run from the office network.
# 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:
- Host phishing on a legitimate subdomain and borrow parent-domain trust.
- If the parent sets cookies with `Domain=.example.com`, or the subdomain talks to the parent via `document.domain`, session leakage expands.
- Bypass domain-allowlist CSP, CORS, and SSO callback checks.
- Issue a TLS cert for the subdomain and keep a trusted entry point.
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
- Unused subdomains: delete the CNAME. Keep needed A/AAAA records or leave empty. Wait for TTL expiry, typically 300–3600s.
- Still-needed subdomains: reclaim the resource on the platform and bind the hostname, or migrate to owned servers and switch to A records.
- Cannot fix immediately: occupy the resource on the platform first, or fall back via CDN to 404 so the third-party default error page never appears.
- Extra control: CAA records restricting which CAs may issue certs, shrinking post-takeover cert abuse.
; 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
- Script: rerun the scanner. No `DANGLING` lines. Every `dangling` field in `dangling_report.json` is `false`.
- Resolution: `dig +short <subdomain> CNAME` empty, or the target resolves to a live service.
- Content: `curl -s https://<subdomain>/ | head -30` no longer shows the third-party "resource not found" default page.
- Certificate: `echo | openssl s_client -connect <subdomain>:443 -servername <subdomain> 2>/dev/null | openssl x509 -noout -subject -dates` subject is your domain, not the platform default cert.
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.