Diagnostic Security / Takeover Risk
Dangling CNAME chain with a claimable intermediate hop
A subdomain's CNAME does not always point straight at a live host. Sometimes it points to another CNAME, which points to another CNAME, and only after a few hops does it reach a real server. If one of the names in the middle of that chain points at a cloud resource that has since been deleted, that hop is dangling, and most quick checks never notice because they only look at the first hop. Here is how to walk the whole chain, find the broken hop, and close it before someone else claims it.
A CNAME chain can resolve fine at the top and still be broken two or three hops later, if one of the in-between names points at a CDN endpoint, load balancer, or PaaS app that no longer exists. Because most scanners and health checks only resolve the first CNAME and stop, that break stays invisible while it sits there, claimable by anyone who registers the same resource name at the vendor. The fix is to walk every hop one at a time with dig until you find the exact name that returns NXDOMAIN, then repoint or delete the record you control so it never reaches that dead resource.
The problem in plain words
A CNAME record does not have to point at a server. It can point at another name, which points at another name, and so on, until the chain finally lands on an A record, an AAAA record, or nothing at all. Each of those names is called a hop. A short chain might be one hop. A chain built up over years of vendors and re-platforming can easily be three or four hops long.
The danger is in the middle. If the first hop and the last hop both look fine, it is easy to assume the whole chain is fine. But one of the names in between might point at a cloud resource, a CDN distribution, a load balancer alias, a PaaS app name, that its owner deleted or deprovisioned a long time ago. That one name now resolves to nothing, NXDOMAIN or a vendor "no such app" page, even though the hop before it and the hop after it were never touched. Most quick health checks, and a lot of takeover scanners, resolve only the first CNAME in the chain and call it done. They never walk far enough to see the break.
Why it happens
- A CDN, load balancer, or PaaS resource in the middle of the chain gets deleted or reassigned during a vendor migration, but nobody updates the DNS record that still points at its old name.
- The chain was built up over time by different teams. Whoever added the first hop years ago is not the same person who later provisioned or removed the intermediate resource, so nobody owns the whole chain end to end.
- Health checks, uptime monitors, and even some subdomain takeover scanners resolve only the first CNAME returned by a recursive query and treat that as the whole answer, so a break two or three hops downstream is invisible to them.
- The final hop still resolves through a working alias, so a shallow test that only checks "does this hostname eventually load a page" can pass even while the specific in-between name is dead, because a different, unrelated route happens to still serve traffic.
The mistake to avoid is running dig CNAME app.example.com once, seeing an answer, and stopping there. A recursive resolver sometimes flattens the whole chain into one answer set, which can hide exactly where the break is, or the query only follows one link and never reaches the dead hop at all. Every name in the chain has to be resolved on its own until you hit a terminal A or AAAA record, or NXDOMAIN.
A CNAME chain is only as safe as its weakest hop, not its first or its last. The first hop resolving is not proof the chain is healthy. You have to walk from the original name to the final terminal record, one hop at a time, and confirm every single name in between still answers with something you own.
The fix, as a flow
Start at the subdomain you are worried about and resolve its CNAME. Take whatever name comes back and resolve that one too. Keep going, one hop at a time, until you reach an A or AAAA record, or until one of the names comes back NXDOMAIN. The moment you hit NXDOMAIN, or a vendor page saying the resource is not configured, that is your dangling hop. Then check whether that hop's target is a record you control. If it is, repoint it or delete it so nothing in your zone still points at a dead resource.
How to fix it
Resolve the first hop by itself
Do not stop at a chain that "resolves." Take the exact target that the first CNAME returns and write it down. That is the name you resolve next, on its own.
dig +short CNAME app.example.com
# e.g. returns: cdn-edge.vendorone.net.
Resolve every following hop one at a time
Take the target from step 1 and resolve it on its own. Keep repeating this for each new name the previous lookup returns, until you reach an A or AAAA record, or until a lookup comes back empty. This is the step most quick checks skip, and it is the only way to see a break that happens two or three hops in.
dig +short CNAME cdn-edge.vendorone.net
# e.g. returns: lb-shared.vendortwo.io. (the intermediate hop)
dig +short CNAME lb-shared.vendortwo.io
# e.g. returns: origin.customer-apps.vendortwo.io.
dig +short A origin.customer-apps.vendortwo.io
# empty, or:
dig +short ANY origin.customer-apps.vendortwo.io
# also empty
Confirm the break with a full dig, not just +short
+short hides the status header, and that header is what tells you NXDOMAIN from an empty-but-valid answer. Run a full dig on the hop you suspect is dangling and read the header line.
dig lb-shared.vendortwo.io
# a bad result looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 40211
# ;; ANSWER SECTION: (empty, no A record at all)
nslookup -type=CNAME cdn-edge.vendorone.net
nslookup lb-shared.vendortwo.io
# both should agree on the same NXDOMAIN independently
Check whether the dangling name is claimable
Look up the parent domain of the dangling hop with whois or RDAP. If the domain shows unregistered, or the vendor's own naming pattern is one they let expire on old CDN, load balancer, or PaaS aliases, treat the hop as claimable and fix your side right away.
whois vendortwo.io
# a bad result shows "No match for domain" or similar,
# meaning the vendor resource behind this name is up for grabs
Repoint or delete the record you control
You cannot fix the vendor's side of a dangling hop, but you can always fix your own zone. Find the record that starts or continues the chain into that dead resource and either delete it or point it at something you actually own now.
; before, cdn-edge points into the dead resource:
cdn-edge.vendorone.net. 300 IN CNAME lb-shared.vendortwo.io.
; after, repointed at a live resource you actually own:
cdn-edge.vendorone.net. 300 IN CNAME app-prod.mycdnaccount.vendorone.net.
; or, if the subdomain is not needed anymore, remove the top of the chain entirely:
; (delete) app.example.com CNAME cdn-edge.vendorone.net.
; optional: lock down issuance while the record is being cleaned up
example.com. IN CAA 0 issue "letsencrypt.org"
Update it in Cloudflare, by dashboard or by API
In Cloudflare's dashboard, go to DNS, Records, find the record you control that targets the dangling hop, and edit or delete it. To do the same thing through the API, send a PATCH with a corrected content, or a DELETE if you no longer need the record.
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{dns_record_id}" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"content":"app-prod.mycdnaccount.vendorone.net"}'
# or, to remove the record entirely:
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{dns_record_id}" \
-H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}"
How to check it worked
Re-walk the chain from the very top the same way you found the break. Every hop should now answer, none should show NXDOMAIN, and the chain should end in a real A or AAAA record. Then check the certificate on the live endpoint so you know it belongs to your service, not a leftover vendor placeholder.
dig +short CNAME app.example.com
dig +short CNAME cdn-edge.vendorone.net
dig +short A app-prod.mycdnaccount.vendorone.net
dig app.example.com A
# a good answer looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5521
# app.example.com. 300 IN A 203.0.113.20
nslookup app.example.com
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer
curl -Iv https://app.example.com
# should return your app's real response, not a
# "domain not configured" or "no such app" placeholder page
The full code
Here is a small script that walks a CNAME chain hop by hop, flags the first hop anywhere in the chain that comes back NXDOMAIN or fails, and, when the offending record lives in your own zone, repairs it through the Cloudflare API. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Walk a CNAME chain hop by hop, find the dangling intermediate hop, and
repair the record you control via Cloudflare. Safe to run on a schedule.
Stays in dry run until DRY_RUN=false.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dangling_cname_chain")
def find_dangling_hop(chain: list, max_depth: int = 10):
"""Pure decision logic, no I/O. The DNS resolution itself happens in run().
Each item in chain is a dict:
{"hostname": str, "is_cname": bool, "target": str | None,
"resolved_status": "OK" | "NXDOMAIN" | "SERVFAIL"}
Returns the first dict (scanning every hop, not just the first) whose
resolved_status is NXDOMAIN or SERVFAIL, or None if the whole chain
resolves cleanly within max_depth hops. Returns a chain-too-long marker
if the chain exceeds max_depth without terminating.
"""
if len(chain) > max_depth:
return {"hostname": chain[max_depth]["hostname"], "reason": "chain-too-long"}
for hop in chain:
if hop["resolved_status"] in ("NXDOMAIN", "SERVFAIL"):
return hop
return None
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import dns.rdatatype
import requests
domain = os.environ["DNS_DOMAIN"]
max_depth = int(os.environ.get("MAX_DEPTH", "10"))
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")
api_token = os.environ.get("CLOUDFLARE_API_TOKEN")
resolver = dns.resolver.Resolver()
chain = []
current = domain
for _ in range(max_depth + 1):
try:
answer = resolver.resolve(current, "CNAME")
target = str(answer[0].target).rstrip(".")
chain.append({"hostname": current, "is_cname": True, "target": target,
"resolved_status": "OK"})
current = target
continue
except dns.resolver.NXDOMAIN:
chain.append({"hostname": current, "is_cname": False, "target": None,
"resolved_status": "NXDOMAIN"})
break
except dns.resolver.NoAnswer:
# No CNAME here. Either it is a terminal A/AAAA record (OK) or
# nothing resolves at all for this name (treat as SERVFAIL-like).
try:
resolver.resolve(current, "A")
chain.append({"hostname": current, "is_cname": False, "target": None,
"resolved_status": "OK"})
except Exception:
chain.append({"hostname": current, "is_cname": False, "target": None,
"resolved_status": "SERVFAIL"})
break
except Exception:
chain.append({"hostname": current, "is_cname": False, "target": None,
"resolved_status": "SERVFAIL"})
break
dangling = find_dangling_hop(chain, max_depth=max_depth)
if dangling is None:
log.info("Chain for %s resolves cleanly, %d hop(s), no dangling hop found.",
domain, len(chain))
return
if dangling.get("reason") == "chain-too-long":
log.warning("Chain for %s exceeded max depth %d, possible loop.", domain, max_depth)
return
log.warning("Dangling hop found: %s (status=%s)", dangling["hostname"], dangling["resolved_status"])
# Find the record in our own zone whose target is the hop just before the
# dangling one, since that is the record we can actually repair.
broken_index = chain.index(dangling)
if broken_index == 0:
log.warning("The dangling hop is the top-level name itself. Nothing upstream to repoint.")
return
owned_record_name = chain[broken_index - 1]["hostname"]
log.warning("Record to repair: %s (currently points at %s)", owned_record_name, dangling["hostname"])
if not zone_id or not api_token:
log.info("No Cloudflare credentials set, skipping repair. %s would be repointed or deleted.",
owned_record_name)
return
replacement_target = os.environ.get("REPLACEMENT_TARGET")
log.info("%s record %s to %s.",
"Would repoint" if dry_run else "Repointing", owned_record_name,
replacement_target or "(delete, no replacement target set)")
if dry_run:
return
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
base = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"
lookup = requests.get(base, headers=headers, params={"name": owned_record_name}, timeout=30)
lookup.raise_for_status()
records = lookup.json().get("result", [])
if not records:
log.warning("Could not find a Cloudflare DNS record named %s to repair.", owned_record_name)
return
record_id = records[0]["id"]
if replacement_target:
resp = requests.patch(f"{base}/{record_id}", headers=headers,
json={"content": replacement_target}, timeout=30)
else:
resp = requests.delete(f"{base}/{record_id}", headers=headers, timeout=30)
resp.raise_for_status()
log.info("Repaired %s.", owned_record_name)
if __name__ == "__main__":
run()
/**
* Walk a CNAME chain hop by hop, find the dangling intermediate hop, and
* repair the record you control via Cloudflare. Safe to run on a schedule.
* Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
export function findDanglingHop(chain, maxDepth = 10) {
// Pure decision logic, no I/O. The DNS resolution itself happens in run().
//
// Each item in chain is an object:
// { hostname, isCname, target, resolvedStatus: "OK" | "NXDOMAIN" | "SERVFAIL" }
//
// Returns the first item (scanning every hop, not just the first) whose
// resolvedStatus is NXDOMAIN or SERVFAIL, or null if the whole chain
// resolves cleanly within maxDepth hops. Returns a chain-too-long marker
// if the chain exceeds maxDepth without terminating.
if (chain.length > maxDepth) {
return { hostname: chain[maxDepth].hostname, reason: "chain-too-long" };
}
for (const hop of chain) {
if (hop.resolvedStatus === "NXDOMAIN" || hop.resolvedStatus === "SERVFAIL") {
return hop;
}
}
return null;
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolver = dns.promises;
const domain = process.env.DNS_DOMAIN;
const maxDepth = Number(process.env.MAX_DEPTH || 10);
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const chain = [];
let current = domain;
for (let i = 0; i <= maxDepth; i++) {
try {
const answers = await resolver.resolveCname(current);
const target = answers[0];
chain.push({ hostname: current, isCname: true, target, resolvedStatus: "OK" });
current = target;
continue;
} catch (err) {
if (err.code === "ENOTFOUND") {
chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "NXDOMAIN" });
break;
}
if (err.code === "ENODATA") {
try {
await resolver.resolve4(current);
chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "OK" });
} catch {
chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "SERVFAIL" });
}
break;
}
chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "SERVFAIL" });
break;
}
}
const dangling = findDanglingHop(chain, maxDepth);
if (dangling === null) {
console.log(`Chain for ${domain} resolves cleanly, ${chain.length} hop(s), no dangling hop found.`);
return;
}
if (dangling.reason === "chain-too-long") {
console.warn(`Chain for ${domain} exceeded max depth ${maxDepth}, possible loop.`);
return;
}
console.warn(`Dangling hop found: ${dangling.hostname} (status=${dangling.resolvedStatus})`);
const brokenIndex = chain.indexOf(dangling);
if (brokenIndex === 0) {
console.warn("The dangling hop is the top-level name itself. Nothing upstream to repoint.");
return;
}
const ownedRecordName = chain[brokenIndex - 1].hostname;
console.warn(`Record to repair: ${ownedRecordName} (currently points at ${dangling.hostname})`);
if (!zoneId || !apiToken) {
console.log(`No Cloudflare credentials set, skipping repair. ${ownedRecordName} would be repointed or deleted.`);
return;
}
const replacementTarget = process.env.REPLACEMENT_TARGET;
console.log(`${dryRun ? "Would repoint" : "Repointing"} record ${ownedRecordName} to ${replacementTarget || "(delete, no replacement target set)"}.`);
if (dryRun) return;
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const base = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
const lookupRes = await fetch(`${base}?name=${encodeURIComponent(ownedRecordName)}`, { headers });
if (!lookupRes.ok) throw new Error(`Cloudflare API returned ${lookupRes.status}`);
const lookupBody = await lookupRes.json();
const records = lookupBody.result || [];
if (records.length === 0) {
console.warn(`Could not find a Cloudflare DNS record named ${ownedRecordName} to repair.`);
return;
}
const recordId = records[0].id;
const patchRes = replacementTarget
? await fetch(`${base}/${recordId}`, {
method: "PATCH",
headers,
body: JSON.stringify({ content: replacementTarget }),
})
: await fetch(`${base}/${recordId}`, { method: "DELETE", headers });
if (!patchRes.ok) throw new Error(`Cloudflare API returned ${patchRes.status}`);
console.log(`Repaired ${ownedRecordName}.`);
}
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 chain walker's decision logic is the part worth testing on its own, because it decides which hop gets flagged as dangling and, from that, which record gets touched. It takes plain lists of dicts or objects, so the test needs no network and no DNS library at all.
from dangling_cname_chain import find_dangling_hop
def hop(hostname, status, target=None, is_cname=True):
return {"hostname": hostname, "is_cname": is_cname, "target": target, "resolved_status": status}
def test_clean_chain_returns_none():
chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
hop("lb-shared.vendortwo.io", "OK", None, is_cname=False),
]
assert find_dangling_hop(chain) is None
def test_flags_intermediate_hop_not_just_first():
chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
hop("lb-shared.vendortwo.io", "NXDOMAIN", None, is_cname=False),
]
result = find_dangling_hop(chain)
assert result["hostname"] == "lb-shared.vendortwo.io"
def test_flags_servfail_hop():
chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "SERVFAIL", None, is_cname=False),
]
result = find_dangling_hop(chain)
assert result["hostname"] == "cdn-edge.vendorone.net"
def test_chain_too_long_flags_possible_loop():
chain = [hop(f"hop{i}.example.com", "OK", f"hop{i+1}.example.com") for i in range(12)]
result = find_dangling_hop(chain, max_depth=10)
assert result["reason"] == "chain-too-long"
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDanglingHop } from "./dangling-cname-chain.js";
function hop(hostname, resolvedStatus, target = null, isCname = true) {
return { hostname, isCname, target, resolvedStatus };
}
test("clean chain returns null", () => {
const chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
hop("lb-shared.vendortwo.io", "OK", null, false),
];
assert.equal(findDanglingHop(chain), null);
});
test("flags intermediate hop, not just first", () => {
const chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
hop("lb-shared.vendortwo.io", "NXDOMAIN", null, false),
];
const result = findDanglingHop(chain);
assert.equal(result.hostname, "lb-shared.vendortwo.io");
});
test("flags servfail hop", () => {
const chain = [
hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
hop("cdn-edge.vendorone.net", "SERVFAIL", null, false),
];
const result = findDanglingHop(chain);
assert.equal(result.hostname, "cdn-edge.vendorone.net");
});
test("chain too long flags possible loop", () => {
const chain = Array.from({ length: 12 }, (_, i) =>
hop(`hop${i}.example.com`, "OK", `hop${i + 1}.example.com`));
const result = findDanglingHop(chain, 10);
assert.equal(result.reason, "chain-too-long");
});
Case studies
The load balancer alias nobody re-created
A company moved its CDN provider two years ago. The top-level subdomain was updated to point at the new provider, but an old intermediate alias, a shared load balancer name from the previous vendor, was left in the chain because a second internal record still pointed at it as a stepping stone. When the old vendor account was finally closed, that alias started returning NXDOMAIN.
A takeover scanner had run monthly for a year and never flagged it, because it only resolved the first hop, which still pointed at the new, working CDN entry point for an unrelated part of the chain. Walking every hop by hand found the dead alias in about ten minutes.
The PaaS app that outlived its project
A subdomain used for an internal demo pointed, through two hops, at a PaaS app that the original project team deleted when the demo wrapped up. Nobody removed the DNS record because the top-level name was owned by a different team than the one that had provisioned the app.
Whois on the PaaS provider's domain showed the resource pattern was registered and freely available for anyone with an account. The fix was deleting the top-level CNAME entirely, since the subdomain was no longer needed by anyone.
Once the record is fixed, walking the chain from the top with dig +short CNAME at each hop returns a real answer at every step, with no NXDOMAIN anywhere in the middle, and it ends in an A or AAAA record you recognize. openssl s_client against the final hostname shows a certificate issued by your own CA, and the page it serves is your application, not a vendor's placeholder. Run the chain walker on a schedule so a future vendor cleanup gets caught before anyone else notices it first.
FAQ
Why does my CNAME chain look fine but a subdomain still gets flagged as a takeover risk?
Most quick checks only resolve the first CNAME hop and stop. A chain can have three or four hops before it reaches a real server, and the break can sit in the middle, not at the start or the end. You have to resolve every hop one at a time to find it.
What makes an intermediate hop claimable by an attacker?
It is claimable when the hop points at a cloud resource name, like a CDN endpoint or a PaaS app, that has been deleted at the vendor but the DNS record pointing to it is still live. Anyone who can register that same resource name at the vendor can then serve their own content under your subdomain.
Can I fix the dangling hop if I do not own that part of the chain?
You cannot fix a hop that lives in a vendor's system you do not control, but you can always fix the record in your own zone that points into it. Repoint or delete the CNAME record you own so it never reaches the dead resource, or re-provision the resource yourself if you still need it.
Related field notes
Citations
On the problem:
- Microsoft Azure: Prevent dangling DNS entries and avoid subdomain takeover. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover
- Censys: How a dangling DNS entry can lead to a subdomain takeover. censys.com/blog/dangling-dns-subdomain-takeover
- haccer/subjack: DNS takeover tool with multi-level CNAME chain support. github.com/haccer/subjack
On the solution:
- Cloudflare API: update a DNS record (PATCH). developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit
- Cloudflare DNS docs: manage DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records
- AWS Security Blog: threat tactic spotlight, subdomain takeover. aws.amazon.com/blogs/security/threat-tactic-spotlight-subdomain-takeover
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 close a dangling CNAME for you?
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