Diagnostic Security / Takeover Risk
Dangling CNAME enables subdomain takeover
A subdomain like promo.example.com still has a CNAME pointing at a cloud service you closed months ago. Nobody noticed, because the record itself looks completely normal. Then a stranger signs up for that same service, claims the exact name your CNAME points at, and now they control what shows up on your subdomain. This is one of the quietest ways a company loses part of its own domain, and it is simple to find and simple to fix.
A dangling CNAME points at a target hostname that no longer exists or no longer belongs to you, such as a deleted cloud storage bucket, a removed static site project, or a cancelled hosting account. DNS still answers with that CNAME, so anyone who claims the same target name on the same provider can serve their own content under your subdomain. Check every CNAME target for a real, working answer, and delete or repoint any record whose target is gone. Full detection script, tests, and a Cloudflare API repair are below.
The problem in plain words
A CNAME record is a pointer. It tells the internet "for this subdomain, go look at this other hostname instead." Lots of services use this on purpose: a static site host, a help desk tool, a CDN, a file storage bucket. You add a CNAME once, the provider connects it to your account, and traffic flows.
The trouble starts when the account on the other end disappears but the CNAME record stays. Maybe someone deleted the storage bucket, closed the trial account, or removed the project. The provider no longer has anything answering for that target name. But your DNS record still says "go there," and on many providers, that target name is free for the next person to claim. If an attacker signs up and claims the exact same name, your subdomain now serves their content, on your own domain, with your own SSL certificate if you have one set up for it.
Why it happens
- A team spins up a demo, marketing landing page, or short-lived project on a cloud service, points a subdomain at it with a CNAME, then tears the project down later without removing the DNS record.
- A hosting account is cancelled, a trial expires, or a storage bucket is deleted, but nobody audits the DNS zone to see what still points at it.
- Staff turnover means the person who knows a CNAME exists leaves, and the record sits forgotten because nothing about it looks broken from the DNS side.
- Large companies build up dozens or hundreds of subdomains over the years, and nobody owns the job of checking whether every CNAME target is still alive.
This is a well documented class of vulnerability, not a rare edge case. Security researchers and bug bounty programs report subdomain takeovers constantly, and most major cloud platforms, including GitHub Pages, Amazon S3, Azure, and Heroku, have all had to publish guidance on how customers should protect themselves from it.
The DNS record is not the vulnerability by itself. The vulnerability is a CNAME pointing at a target that your team no longer controls, on a service where anyone can register that same target name. A CNAME pointing at something you still own, even something unused, is not a takeover risk. A CNAME pointing at something deleted, cancelled, or unclaimed is.
The fix, as a flow
Every CNAME record deserves one simple question on a schedule: does its target still resolve, and does it still answer as something you own? If the target is gone or shows a provider's "not claimed" or "no such site" page, the subdomain is exposed right now, and the fix is either to delete the CNAME or to re-claim the target before anyone else does.
How to fix it
List every CNAME record in the zone
Start with a full inventory. You cannot check what you do not know exists. Pull every CNAME record from your DNS provider, including old marketing subdomains and anything that looks forgotten.
dig +short CNAME promo.example.com
dig +short CNAME legacy.example.com
dig +short CNAME status.example.com
# repeat for every subdomain, or pull the full record list from your DNS provider's API
Check whether each target still resolves
Ask DNS whether the CNAME target itself has an address. If it returns NXDOMAIN, the target is gone and the subdomain is dangling right now.
dig +short A old-app.host.io
# empty output plus an NXDOMAIN status means the target is gone
dig old-app.host.io | grep -i status
# look for "status: NXDOMAIN" in the header
Check whether a resolving target is actually unclaimed
Some targets still resolve to a real IP address but answer with the provider's own "no such site" or "not claimed" page. That is just as dangerous as NXDOMAIN, because the name is free to register. Request the subdomain over HTTPS and read the response for those phrases.
curl -s -o - https://promo.example.com/ | grep -i "there isn't a github pages site here"
curl -s -o - https://promo.example.com/ | grep -i "the specified bucket does not exist"
openssl s_client -connect promo.example.com:443 -servername promo.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject
# a certificate error, or one of those "not claimed" phrases in the body, both mean the target is unclaimed
Remove the dangling record or re-claim the target
Delete the CNAME for any subdomain you no longer use. If you still need the subdomain, log into the provider and re-register the exact target name yourself before an attacker does.
# Before: dangling record, target account was deleted
Type: CNAME Name: promo Value: old-app.host.io TTL: 300
# Fix option A: delete the record entirely, the subdomain is not in use
# (remove the row above from the zone)
# Fix option B: re-claim the target on the provider, then keep the record
Type: CNAME Name: promo Value: promo.example.com.new-app.host.io TTL: 300
# only keep the CNAME once the target resource is verified back under your account
The safest order of operations is: remove the DNS record first, then delete or cancel the cloud resource. Doing it in the other order leaves a window, sometimes days or weeks, where the subdomain points at nothing and is free for anyone to grab.
How to check it worked
Re-run the same checks. A fixed subdomain either has no CNAME at all, or its CNAME target resolves and answers as a resource you control, with a valid TLS certificate that matches your domain.
dig +short CNAME promo.example.com
# expect: nothing (record removed), or a target hostname you still own
dig +short A old-app.host.io
# expect: a real IP address, not empty, if you kept and re-claimed the target
curl -s -o /dev/null -w "%{http_code}\n" https://promo.example.com/
# expect: 200 or 301/302, not a provider's generic "not found" page
openssl s_client -connect promo.example.com:443 -servername promo.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -dates
# expect: a valid certificate for your domain, not expired, not a mismatch error
The full code
Here is a small script in each language that checks a list of subdomains for a dangling CNAME, and, when told to, repairs the zone through the Cloudflare API by removing the offending record. It stays in dry run by default so it only reports what it would change.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a dangling CNAME that enables subdomain takeover, and optionally
repair it via Cloudflare by removing the offending record.
Safe by default. Set DRY_RUN=false to let it write.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dangling_cname_takeover")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "promo.example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
# Snippets a provider's "nothing lives here" page tends to contain.
UNCLAIMED_SIGNATURES = (
"there isn't a github pages site here",
"the specified bucket does not exist",
"no such app",
"domain mapping not found",
)
def classify_cname_target(target_status):
"""Pure decision function. No I/O.
target_status looks like:
{"resolves": True, "http_status": 200, "body_snippet": "..."}
Returns one of:
"ok" - target resolves and does not look unclaimed
"dangling" - target does not resolve (NXDOMAIN) or the response body
matches a known "not claimed" signature
"unknown" - could not tell, treat as needing a human look
"""
if target_status is None:
return "unknown"
if not target_status.get("resolves", False):
return "dangling"
snippet = (target_status.get("body_snippet") or "").lower()
for signature in UNCLAIMED_SIGNATURES:
if signature in snippet:
return "dangling"
http_status = target_status.get("http_status")
if http_status is None:
return "unknown"
return "ok"
def resolve_cname_chain(domain):
"""Follow the CNAME for domain and probe the final target. Requires network."""
import dns.resolver
import requests
try:
answer = dns.resolver.resolve(domain, "CNAME")
target = str(answer[0].target).rstrip(".")
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return None, {"resolves": False, "http_status": None, "body_snippet": ""}
try:
dns.resolver.resolve(target, "A")
resolves = True
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return target, {"resolves": False, "http_status": None, "body_snippet": ""}
try:
r = requests.get(f"https://{domain}/", timeout=10)
return target, {"resolves": resolves, "http_status": r.status_code, "body_snippet": r.text[:2000]}
except requests.RequestException as exc:
log.warning("HTTPS probe for %s failed: %s", domain, exc)
return target, {"resolves": resolves, "http_status": None, "body_snippet": ""}
def find_cname_record_id(domain):
"""Find the dangling CNAME record via the Cloudflare API."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
r = requests.get(url, headers=headers, params={"type": "CNAME", "name": domain}, timeout=30)
r.raise_for_status()
result = r.json().get("result", [])
return result[0]["id"] if result else None
def remove_dangling_cname(domain, record_id):
"""Delete the dangling CNAME record so the name can no longer be claimed against it."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
if DRY_RUN:
log.info("[dry run] would delete dangling CNAME record %s for %s", record_id, domain)
return
requests.delete(url, headers=headers, timeout=30).raise_for_status()
log.info("Deleted dangling CNAME record for %s", domain)
def run():
target, status = resolve_cname_chain(DNS_DOMAIN)
verdict = classify_cname_target(status)
log.info("Subdomain %s (target %s) classified as: %s", DNS_DOMAIN, target, verdict)
if verdict != "dangling":
log.info("Nothing to repair.")
return
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("Dangling CNAME found but no Cloudflare credentials set. Skipping repair.")
return
record_id = find_cname_record_id(DNS_DOMAIN)
if not record_id:
log.warning("Could not find the CNAME record via the Cloudflare API.")
return
remove_dangling_cname(DNS_DOMAIN, record_id)
if __name__ == "__main__":
run()
/**
* Detect a dangling CNAME that enables subdomain takeover, and optionally
* repair it via Cloudflare by removing the offending record.
* Safe by default. Set DRY_RUN=false to let it write.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "promo.example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
// Snippets a provider's "nothing lives here" page tends to contain.
const UNCLAIMED_SIGNATURES = [
"there isn't a github pages site here",
"the specified bucket does not exist",
"no such app",
"domain mapping not found",
];
/**
* Pure decision function. No I/O.
*
* targetStatus looks like:
* { resolves: true, httpStatus: 200, bodySnippet: "..." }
*
* Returns one of:
* "ok" - target resolves and does not look unclaimed
* "dangling" - target does not resolve (NXDOMAIN) or the response body
* matches a known "not claimed" signature
* "unknown" - could not tell, treat as needing a human look
*/
export function classifyCnameTarget(targetStatus) {
if (targetStatus == null) return "unknown";
if (!targetStatus.resolves) return "dangling";
const snippet = (targetStatus.bodySnippet || "").toLowerCase();
for (const signature of UNCLAIMED_SIGNATURES) {
if (snippet.includes(signature)) return "dangling";
}
if (targetStatus.httpStatus == null) return "unknown";
return "ok";
}
/** Follow the CNAME for domain and probe the final target. Requires network. */
async function resolveCnameChain(domain) {
const dns = await import("node:dns/promises");
let target;
try {
const answer = await dns.resolveCname(domain);
target = answer[0];
} catch {
return [null, { resolves: false, httpStatus: null, bodySnippet: "" }];
}
try {
await dns.resolve4(target);
} catch {
return [target, { resolves: false, httpStatus: null, bodySnippet: "" }];
}
try {
const res = await fetch(`https://${domain}/`);
const body = await res.text();
return [target, { resolves: true, httpStatus: res.status, bodySnippet: body.slice(0, 2000) }];
} catch {
return [target, { resolves: true, httpStatus: null, bodySnippet: "" }];
}
}
/** Find the dangling CNAME record via the Cloudflare API. */
async function findCnameRecordId(domain) {
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME&name=${domain}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
if (!res.ok) throw new Error(`Cloudflare list failed: ${res.status}`);
const body = await res.json();
const result = body.result || [];
return result.length ? result[0].id : null;
}
/** Delete the dangling CNAME record so the name can no longer be claimed against it. */
async function removeDanglingCname(domain, recordId) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`;
if (DRY_RUN) {
console.log(`[dry run] would delete dangling CNAME record ${recordId} for ${domain}`);
return;
}
const del = await fetch(url, { method: "DELETE", headers });
if (!del.ok) throw new Error(`Cloudflare delete failed: ${del.status}`);
console.log(`Deleted dangling CNAME record for ${domain}`);
}
async function run() {
const [target, status] = await resolveCnameChain(DNS_DOMAIN);
const verdict = classifyCnameTarget(status);
console.log(`Subdomain ${DNS_DOMAIN} (target ${target}) classified as: ${verdict}`);
if (verdict !== "dangling") {
console.log("Nothing to repair.");
return;
}
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("Dangling CNAME found but no Cloudflare credentials set. Skipping repair.");
return;
}
const recordId = await findCnameRecordId(DNS_DOMAIN);
if (!recordId) {
console.warn("Could not find the CNAME record via the Cloudflare API.");
return;
}
await removeDanglingCname(DNS_DOMAIN, recordId);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The classification rule is the part worth testing, because it decides whether the script thinks a subdomain is safe or exposed. It takes a plain status object, no network, no DNS lookups, so the tests run in milliseconds.
from dangling_cname_takeover import classify_cname_target
def test_ok_when_target_resolves_and_answers_normally():
status = {"resolves": True, "http_status": 200, "body_snippet": "welcome to our site"}
assert classify_cname_target(status) == "ok"
def test_dangling_when_target_does_not_resolve():
status = {"resolves": False, "http_status": None, "body_snippet": ""}
assert classify_cname_target(status) == "dangling"
def test_dangling_when_body_matches_unclaimed_signature():
status = {"resolves": True, "http_status": 404, "body_snippet": "There isn't a GitHub Pages site here."}
assert classify_cname_target(status) == "dangling"
def test_unknown_when_status_is_missing():
assert classify_cname_target(None) == "unknown"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCnameTarget } from "./dangling-cname-takeover.js";
test("ok when target resolves and answers normally", () => {
const status = { resolves: true, httpStatus: 200, bodySnippet: "welcome to our site" };
assert.equal(classifyCnameTarget(status), "ok");
});
test("dangling when target does not resolve", () => {
const status = { resolves: false, httpStatus: null, bodySnippet: "" };
assert.equal(classifyCnameTarget(status), "dangling");
});
test("dangling when body matches unclaimed signature", () => {
const status = { resolves: true, httpStatus: 404, bodySnippet: "There isn't a GitHub Pages site here." };
assert.equal(classifyCnameTarget(status), "dangling");
});
test("unknown when status is missing", () => {
assert.equal(classifyCnameTarget(null), "unknown");
});
Case studies
The campaign page nobody tore down properly
A marketing team launched a landing page on a static site host, pointed launch.example.com at it with a CNAME, and ran the campaign for a month. When the campaign ended, they deleted the project in the host's dashboard but never told anyone to remove the DNS record.
A researcher scanning for exactly this pattern found the dangling CNAME within a week, claimed the same project name on the host for free, and reported it as a bug bounty finding before anyone with bad intent found it first.
The bucket that outlived its project
An engineering team pointed files.example.com at a cloud storage bucket for a project that later got cancelled. The bucket was deleted during cleanup, but the CNAME record was buried in a large zone file with hundreds of entries and nobody caught it.
A scheduled scan against every CNAME target flagged the missing bucket within a day of the cleanup. The team removed the record immediately, before the bucket name could be re-registered by anyone else.
Every CNAME record in your zone points at something you actively control, and a scheduled check confirms that on a regular basis. Whenever a project, bucket, or app is decommissioned, removing its DNS record is a required step, not an afterthought, and it happens before the resource itself is released back to the provider.
FAQ
What is a subdomain takeover?
A subdomain takeover happens when a CNAME record still points at a cloud service or third-party host that your team no longer owns. Anyone who signs up for that same service and claims the exact hostname can serve their own page under your subdomain, because DNS still sends visitors there.
How do I know if a CNAME is dangling?
Look up the CNAME target and see whether it actually resolves and answers. If the target returns NXDOMAIN, or it resolves but the page shows an error like "There isn't a GitHub Pages site here" or "The specified bucket does not exist," the CNAME is dangling and the subdomain is at risk.
How do I fix a dangling CNAME?
Remove the CNAME record for any subdomain you are no longer using, or re-claim the target resource yourself before someone else does. Once the record is deleted or the resource is safely back under your control, the subdomain can no longer be taken over.
Related field notes
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your zone?
If this saved you from a subdomain takeover, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi