Reconciler Security / Takeover Risk
Orphaned DNS records left after service teardown
A team spins up app.example.com pointing at a load balancer or a SaaS app. Months later the underlying resource is torn down, but nobody deletes the DNS record. It sits there, quietly resolving to a name that no longer belongs to anyone, until a stranger notices and claims it first. Here is why the record outlives the service, and a script that finds every one of these before someone else does.
When you delete a cloud resource or cancel a SaaS app, the DNS record that pointed at it is a separate object that nobody is forced to clean up. It keeps resolving to a name that is now unused, and on many platforms that name can be claimed by anyone, including an attacker, which puts your trusted domain in front of their content. Run a script that lists every CNAME and A/AAAA record in your zone, checks each target against your live infrastructure, and deletes or repoints the ones that no longer match anything real. Full checks, fix, and a script are below.
The problem in plain words
Say your team spins up a subdomain like staging.example.com pointing at an EC2 instance, or promo.example.com pointing at a Heroku app. That works fine while the resource is alive. But a DNS record and the resource it points at are two completely separate things, stored in two completely separate systems. Deleting the EC2 instance or closing the Heroku app does nothing to the DNS record. It just keeps answering with the old target, forever, unless someone remembers to remove it.
Nobody is forced to remember. Decommissioning a service usually means turning off billing and deleting the resource, not searching every DNS zone for anything that still points at it. So the record becomes dangling: it resolves fine at the DNS layer, but the thing on the other end is gone. On many cloud and SaaS platforms, an unused hostname or bucket name is free for the next person who asks for it. An attacker who finds your dangling record, through subdomain enumeration or by watching certificate transparency logs, can register that same name on the same platform and instantly serve their own content under your domain, often with a certificate that looks completely legitimate.
Why it happens
- A subdomain is added quickly for a demo, a staging environment, or a marketing campaign, and the DNS record is created by whoever set up the resource, not by whoever later tears it down.
- Decommission runbooks cover turning off the resource and stopping the billing, but rarely include a step for searching DNS for anything that still points at it.
- The team that owns the DNS zone is often different from the team that owns the cloud account or SaaS subscription, so nobody has both the full infrastructure list and the full zone list in one place.
- The record keeps resolving at the DNS layer the entire time, so nothing looks broken from the outside. There is no error, no alert, and no reason for anyone to go looking.
This is a well documented class of bug. Palo Alto Networks and DNS Made Easy both describe dangling DNS as one of the most common, and most overlooked, ways a trusted domain ends up serving someone else's content, precisely because the record and the resource are managed in different places and go stale independently.
A DNS record is a promise that something is listening at the other end. Nothing checks that promise once it is made. The record keeps resolving whether the resource behind it is alive, deleted, or claimed by a stranger. Treat every teardown as incomplete until the matching DNS record is confirmed gone or repointed, the same way you would not consider a server decommissioned while it is still plugged into the network.
The fix, as a flow
We do not wait for a report. We pull every record in the zone, resolve what each one currently points at, and compare that against a list of infrastructure you actually run today, pulled straight from the cloud or SaaS provider's own API. Anything that does not match a live resource, and looks like a known "unclaimed" fingerprint, gets flagged and removed or repointed.
How to fix it
Enumerate the zone and see what each host currently points at
Pull every CNAME, A, and AAAA record from your zone and check what each one currently resolves to. This is your starting list of candidates.
dig +short CNAME app.example.com
# a hostname here is the current target, e.g. old-app.s3-website-us-east-1.amazonaws.com
dig +short A staging.example.com
# an IP here means this is an A record, cross check it against your inventory
Resolve the target itself, not just the record
Follow the CNAME to its own destination. An NXDOMAIN, a SERVFAIL, or a generic "bucket does not exist" or "no such app" response is the signature of a dangling record. The DNS layer itself may still say NOERROR even while the resource behind it is completely gone.
dig +short old-app.s3-website-us-east-1.amazonaws.com
dig +short some-app.herokuapp.com
# NXDOMAIN, SERVFAIL, or empty output is the signature of dangling DNS
curl -sS -o /dev/null -w "%{http_code}\n" https://app.example.com
curl -sS https://app.example.com | head -c 300
# look for platform fingerprints in the body: NoSuchBucket, "no such app",
# "There isn't a GitHub Pages site here"
Check whois or RDAP when the target is a third-party domain
Sometimes the CNAME points at a vendor's own domain rather than a platform default hostname. If that vendor domain itself has expired, anyone can re-register it, which is just as dangerous as an unclaimed cloud resource.
whois oldvendor-cdn.com
# an expired registration or "no match" is a sign anyone can claim this domain
Cross-check every record against your live infrastructure inventory
A record can resolve perfectly fine at the DNS layer and still be orphaned, if the thing it points at is simply absent from your current asset list. Diff the zone's records against a cloud API list of running resources, an internal CMDB, or a list of active app names, so nothing slips through just because it happens to answer.
Delete the record, or repoint it to current infrastructure
Once a record is confirmed orphaned and no longer needed, remove it entirely. If the hostname still needs to work, repoint it at infrastructure you actually own today rather than leaving the old target in place.
# Option A: no longer needed, delete it outright
# remove the CNAME entirely
old-app.example.com CNAME old-app.herokuapp.com (delete this record)
# remove the A record for a decommissioned EC2 instance
staging.example.com A 203.0.113.45 (delete this record)
# Option B: still needed, repoint at current, owned infrastructure
app.example.com CNAME app-v2.herokuapp.com TTL: Auto
# only if app-v2 is a live app you actively own today
Delete or repoint through the Cloudflare API
List current records first, then delete the confirmed orphaned ones. A PATCH or PUT on the same endpoint repoints a record instead, if the hostname needs to keep working.
curl -sS -X GET "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=CNAME" \
-H "Authorization: Bearer {api_token}"
curl -sS -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
-H "Authorization: Bearer {api_token}"
The root cause here is a missing step, not a one-time mistake. Add "remove the DNS record" as a required box to check whenever a service is decommissioned. Where the provider supports it, add a TXT ownership marker, like Azure App Service's asuid. domain verification, so nobody else can claim the hostname even if the record briefly dangles.
How to check it worked
Re-run the same lookups. A clean result either returns nothing at all, because the record is gone, or resolves to infrastructure you currently own and control.
dig +short CNAME old-app.example.com
# expect: nothing (NXDOMAIN/empty) if deleted, or the new owned target if repointed
curl -sS -I https://old-app.example.com
# expect: connection fails (record gone), or a 200 from infrastructure you
# control (record repointed). It must NOT show a third-party unclaimed page.
curl -sS -X GET "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
-H "Authorization: Bearer {api_token}"
# expect: 404 (record deleted), or the content field showing the updated,
# owned target
The full code
Here is a small script in each language that lists a zone's CNAME and A/AAAA records, cross-checks each target against a live infrastructure inventory and an unclaimed-provider fingerprint, and, when told to, deletes the confirmed orphaned records through the Cloudflare API. 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 DNS records left behind after a service teardown, and optionally
repair the zone via Cloudflare.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the zone to check, e.g. "example.com"
CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
LIVE_INVENTORY comma separated list of hostnames/IPs currently
provisioned, e.g. from a cloud API or CMDB
DRY_RUN default "true"; set to "false" to actually write
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("orphaned_records")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
LIVE_INVENTORY = {
item.strip().lower() for item in os.environ.get("LIVE_INVENTORY", "").split(",") if item.strip()
}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
UNCLAIMED_FINGERPRINTS = {
"s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
"herokuapp.com": "no-such-app",
"github.io": "There isn't a GitHub Pages site here",
}
def classify_record(record, live_inventory, unclaimed_fingerprints):
"""Pure decision function. No I/O.
record: {"type": "CNAME"|"A"|"AAAA", "name": str, "content": str}
live_inventory: set of hostnames/IPs currently provisioned (from a
cloud API or CMDB snapshot), already lowercased with no trailing dot.
unclaimed_fingerprints: {provider_domain_suffix: expected_404_body_substring}
for known dangling signatures.
Returns one of "orphaned", "active", "needs_manual_review".
"""
target = record["content"].rstrip(".").lower()
if target in live_inventory:
return "active"
for suffix in unclaimed_fingerprints:
if target.endswith(suffix) and target not in live_inventory:
return "orphaned"
return "needs_manual_review"
def list_zone_records():
"""List CNAME, A, and AAAA records in the zone via the Cloudflare API."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
records = []
for record_type in ("CNAME", "A", "AAAA"):
r = requests.get(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
headers=headers, params={"type": record_type, "per_page": 5000}, timeout=30,
)
r.raise_for_status()
for rec in r.json().get("result", []):
records.append({
"id": rec["id"], "type": rec["type"], "name": rec["name"], "content": rec["content"],
})
return records
def delete_record(record_id):
"""Delete a single DNS record through the Cloudflare API."""
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 record %s", record_id)
return
requests.delete(url, headers=headers, timeout=30).raise_for_status()
log.info("Deleted orphaned record %s", record_id)
def run():
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Nothing to scan for %s.", DNS_DOMAIN)
return
records = list_zone_records()
orphaned = 0
for record in records:
verdict = classify_record(record, LIVE_INVENTORY, UNCLAIMED_FINGERPRINTS)
if verdict == "orphaned":
log.info("%s -> %s is orphaned", record["name"], record["content"])
delete_record(record["id"])
orphaned += 1
elif verdict == "needs_manual_review":
log.warning("%s -> %s needs manual review", record["name"], record["content"])
else:
log.info("%s -> %s is active", record["name"], record["content"])
log.info("Done. %d orphaned record(s) %s.", orphaned, "to remove" if DRY_RUN else "removed")
if __name__ == "__main__":
run()
/**
* Detect DNS records left behind after a service teardown, and optionally
* repair the zone via Cloudflare. Safe by default. Set DRY_RUN=false to let
* it write.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const LIVE_INVENTORY = new Set(
(process.env.LIVE_INVENTORY || "")
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean),
);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
const UNCLAIMED_FINGERPRINTS = {
"s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
"herokuapp.com": "no-such-app",
"github.io": "There isn't a GitHub Pages site here",
};
/**
* Pure decision function. No I/O.
*
* record: { type: "CNAME"|"A"|"AAAA", name: string, content: string }
* liveInventory: Set of hostnames/IPs currently provisioned (from a cloud
* API or CMDB snapshot), already lowercased with no trailing dot.
* unclaimedFingerprints: { providerDomainSuffix: expected404BodySubstring }
* for known dangling signatures.
*
* Returns one of "orphaned", "active", "needs_manual_review".
*/
export function classifyRecord(record, liveInventory, unclaimedFingerprints) {
const target = record.content.replace(/\.$/, "").toLowerCase();
if (liveInventory.has(target)) return "active";
for (const suffix of Object.keys(unclaimedFingerprints)) {
if (target.endsWith(suffix) && !liveInventory.has(target)) return "orphaned";
}
return "needs_manual_review";
}
/** List CNAME, A, and AAAA records in the zone via the Cloudflare API. */
async function listZoneRecords() {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const records = [];
for (const recordType of ["CNAME", "A", "AAAA"]) {
const params = new URLSearchParams({ type: recordType, per_page: "5000" });
const res = await fetch(
`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
{ headers },
);
if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
const body = await res.json();
for (const rec of body.result) {
records.push({ id: rec.id, type: rec.type, name: rec.name, content: rec.content });
}
}
return records;
}
/** Delete a single DNS record through the Cloudflare API. */
async function deleteRecord(recordId) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
if (DRY_RUN) {
console.log(`[dry run] would delete record ${recordId}`);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "DELETE",
headers,
});
if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
console.log(`Deleted orphaned record ${recordId}`);
}
async function run() {
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn(`No Cloudflare credentials set. Nothing to scan for ${DNS_DOMAIN}.`);
return;
}
const records = await listZoneRecords();
let orphaned = 0;
for (const record of records) {
const verdict = classifyRecord(record, LIVE_INVENTORY, UNCLAIMED_FINGERPRINTS);
if (verdict === "orphaned") {
console.log(`${record.name} -> ${record.content} is orphaned`);
await deleteRecord(record.id);
orphaned++;
} else if (verdict === "needs_manual_review") {
console.warn(`${record.name} -> ${record.content} needs manual review`);
} else {
console.log(`${record.name} -> ${record.content} is active`);
}
}
console.log(`Done. ${orphaned} orphaned record(s) ${DRY_RUN ? "to remove" : "removed"}.`);
}
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 decision rule is the part most worth testing, because it decides which records get deleted. Because classify_record is pure, the test needs no network and no Cloudflare account. It just feeds in plain objects and checks the verdict.
from orphaned_records import classify_record, UNCLAIMED_FINGERPRINTS
def rec(name, type_, content):
return {"name": name, "type": type_, "content": content}
def test_active_when_target_in_live_inventory():
record = rec("app.example.com", "CNAME", "app.herokuapp.com")
live_inventory = {"app.herokuapp.com"}
assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "active"
def test_orphaned_when_target_matches_known_suffix_and_not_live():
record = rec("old-app.example.com", "CNAME", "old-app.herokuapp.com")
live_inventory = {"app-v2.herokuapp.com"}
assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "orphaned"
def test_needs_manual_review_for_unknown_target():
record = rec("mystery.example.com", "CNAME", "mystery.internal-tool.com")
live_inventory = set()
assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "needs_manual_review"
def test_trailing_dot_and_case_are_normalized():
record = rec("app.example.com", "CNAME", "App.Herokuapp.com.")
live_inventory = {"app.herokuapp.com"}
assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "active"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyRecord } from "./orphaned-records.js";
const UNCLAIMED_FINGERPRINTS = {
"s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
"herokuapp.com": "no-such-app",
"github.io": "There isn't a GitHub Pages site here",
};
const rec = (name, type, content) => ({ name, type, content });
test("active when target is in live inventory", () => {
const record = rec("app.example.com", "CNAME", "app.herokuapp.com");
const liveInventory = new Set(["app.herokuapp.com"]);
assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "active");
});
test("orphaned when target matches known suffix and is not live", () => {
const record = rec("old-app.example.com", "CNAME", "old-app.herokuapp.com");
const liveInventory = new Set(["app-v2.herokuapp.com"]);
assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "orphaned");
});
test("needs manual review for unknown target", () => {
const record = rec("mystery.example.com", "CNAME", "mystery.internal-tool.com");
const liveInventory = new Set();
assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "needs_manual_review");
});
test("trailing dot and case are normalized", () => {
const record = rec("app.example.com", "CNAME", "App.Herokuapp.com.");
const liveInventory = new Set(["app.herokuapp.com"]);
assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "active");
});
Case studies
The landing page that outlived the campaign
A marketing team spun up promo.example.com as a CNAME to a page builder's hosted subdomain for a two-week campaign. When the campaign ended, the page builder account was closed, but the DNS record stayed in the zone for over a year. A researcher scanning certificate transparency logs found the subdomain, registered the exact same name on the page builder's free tier, and put up a phishing page under the company's own domain.
Deleting the leftover CNAME closed the report the same day. The marketing team now tags every campaign subdomain with an expiry date in a shared tracker.
The staging box that never got its A record removed
An engineering team migrated staging environments to a new provider and terminated the old EC2 instances, but the A record for staging.example.com still pointed at the old, now unassigned, IP address. The IP was later reassigned by the cloud provider to a different customer's instance, and staging traffic briefly reached infrastructure the company no longer controlled.
Running the inventory diff script against the current EC2 host list caught the stale A record immediately, and repointing it to the new staging load balancer closed the gap for good.
Every record in the zone matches something you can point to today in your infrastructure inventory. Decommissioning a service always includes a DNS cleanup step, and where the provider supports it, a TXT ownership marker is in place so a hostname cannot be claimed by anyone else even during the short window before cleanup happens.
FAQ
What is an orphaned or dangling DNS record?
It is a DNS record, usually a CNAME or A record, that still points at a cloud resource, load balancer, or SaaS app that has since been deleted or decommissioned. The record itself is a separate object in the zone, so nobody is forced to remove it when the underlying resource goes away, and it keeps resolving to something that no longer exists.
Why is an orphaned DNS record a security risk, not just clutter?
Many cloud and SaaS platforms let anyone claim an unused hostname or bucket name. If your record still points at that exact name, an attacker who finds it through subdomain enumeration or certificate transparency logs can provision their own resource there and start serving content under your trusted domain, often with a valid certificate.
How do I find orphaned DNS records in my zone?
List every record in the zone and resolve what each one points at. A CNAME target that returns NXDOMAIN or SERVFAIL, or an HTTP response with a platform not found fingerprint like NoSuchBucket or no such app, is the signature of a dangling record. Cross-check the survivors against your current infrastructure inventory to catch anything that resolves fine but no longer matches a resource you actually run.
Related field notes
Citations
On the problem:
- Microsoft Learn: Prevent subdomain takeovers with Azure DNS alias records and Azure App Service's custom domain verification. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover
- Palo Alto Networks Cyberpedia: What Is Dangling DNS? paloaltonetworks.com/cyberpedia/what-is-a-dangling-dns
- DNS Made Easy: Dangling DNS Records and the Risk of Domain Hijacking. dnsmadeeasy.com/resources/dangling-dns-records-and-the-risk-of-domain-hijacking
On the solution:
- Cloudflare API: Delete DNS Record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete
- Cloudflare API: List DNS Records. developers.cloudflare.com/api/resources/dns/subresources/records/methods/list
- Cloudflare DNS docs: Manage DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records
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 your takeover risk?
If this saved you a security incident or an awkward disclosure email, 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