Finding Dangling DNS Records Without Breaking Anything
A record that points at something you deleted is a subdomain-takeover waiting to happen. "Doesn't resolve" is neither necessary nor sufficient to find one. Here's the scanner I built to ask two questions instead — is it dead, and is anything still using it?
- DNS
- Cloud Security
- SRE
- Automation
Every team that has run infrastructure for a few years has a DNS zone full of ghosts. A load balancer that was replaced, a CDN distribution someone tore down, a SaaS status page nobody pays for any more, a storage bucket that was emptied and deleted. The records pointing at them are still there.
That is a dangling record, and it is not just clutter. If somebody else can claim the thing your record points at, they serve their content on your name. That's a subdomain takeover: a phishing page on login.example.com, cookies scoped to your parent domain, a valid-looking certificate issued for a name you "own".
The obvious fix is to delete the dead records. The problem is that nobody knows which ones are dead, and deleting a live one is an outage. So I built a scanner whose whole job is to make that decision boring.
"Doesn't resolve" is the wrong test
The first instinct is to resolve every name and flag whatever fails. That test is wrong in both directions:
- Names that don't resolve can be perfectly healthy. Vendor domain-verification records are random-looking CNAMEs whose target is designed never to resolve. Delete one and the vendor's ownership check breaks. Names in a split-horizon private zone look dead from outside and are live inside.
- Names that resolve can be completely dangling. Some providers answer for every hostname under their domain or on a shared anycast IP. The name resolves forever, whether or not any customer has claimed it. Those are the easiest takeovers there are, and a resolver check will never find them.
So the scanner asks two separate questions about every record:
- Is it dead? Does the name resolve, and does its target still exist?
- Is anything still using it? Clusters, certificates, code.
Only dead AND unused is a deletion candidate. A separate takeover-risk score decides the order you fix them in.
The pipeline
For every A, AAAA, CNAME and alias record in every hosted zone across every cloud account:
0. Collect, and skip what isn't yours to judge
Wildcards, underscore names (_acme-challenge, _domainkey and friends) and vendor verification tokens (random hex labels with a CNAME that is meant not to resolve) are skipped outright. They aren't takeover targets, and touching them breaks things.
1. DNS, asked the right way
Resolve each name from inside the network. For public zones, also ask the zone's own authoritative nameservers directly, non-recursively, so a stale cache or a split-horizon view can't lie to you.
A few rules that saved me from false positives:
- A name with MX or TXT but no address is still live. That's mail.
- But not every TXT is real usage. Ownership markers written by tools such as external-dns are bookkeeping, not traffic.
- A private zone where nothing resolves is unknown, not dead. It's almost certainly just not visible to this resolver.
2. Does the target still exist?
Classify the target by its hostname pattern: application or network load balancer, CDN, API gateway, managed database endpoint, bucket website, raw IP, third party. Then look it up in the cloud APIs across every account. This is what separates "the target was deleted" from "DNS hiccuped this morning".
3. Is anything still using it?
- Every Kubernetes cluster: Ingress, Service, Gateway and HTTPRoute hosts, plus external-dns annotations.
- Every certificate, in the cloud certificate manager and in-cluster cert-manager.
- An organisation-wide code search.
4. Code search is harder than it looks
Two lessons here:
- Search for the full name AND the record's literal target. Infrastructure-as-code builds names by interpolation (
name = "abc.${var.zone}"), soabc.example.comnever appears in any repo. The target hostname usually does. - Budget for it. The code-search API had no
OR, so it's one query per term, at roughly ten queries a minute. Cache results per term. Any hit blocks deletion, and the report says "N hits, M confirmed exact", because tokenizers split on dots and dashes and over-match badly.
5. The verdict
fine_to_delete only when DNS, ingress, certificate and code all say no. An unknown anywhere keeps the record. The scanner is allowed to be conservative; it is not allowed to cause an outage.
Scoring takeover risk
Not every dead record is equally dangerous, and security fixes should come before tidying. Each dead record gets a risk tier:
| Risk | What it covers |
|---|---|
| high | First-come-first-served names: storage buckets, PaaS app prefixes. Anything detected as unclaimed at a provider (below). |
| check | Third-party hosts where it depends on the provider: dynamic DNS, SaaS. |
| low | Released IPs; CDNs that verify domain ownership before serving. |
| none | Cloud-generated names with random IDs (load balancers, DB endpoints), private zones, targets that still exist, targets inside zones you control. |
Two subtleties:
- "Inside your own domain" is only safe if you actually scanned that zone. If
team.example.comis delegated to someone else's nameservers, a target under it isn't yours to vouch for. Match the target against the most specific zone, not just the apex. - Sort the output riskiest first. The person reading the report should hit the takeover candidates before the cleanup.
The cases DNS alone misses
Each of these was found the hard way.
Unclaimed at a provider
Some hosts resolve for any name — shared anycast IPs, CNAMEs under a provider's domain — so the record looks alive while no customer has actually claimed it. The only way to tell is to fetch the page and match the provider's own "not here" response: DEPLOYMENT_NOT_FOUND, "There isn't a GitHub Pages site here", NoSuchBucket, "No such app". These get the highest risk score, because claiming them takes an attacker a few minutes.
Dangling NS delegations
An NS record that hands child.example.com to a hosted zone that was later deleted is worse than a dangling CNAME: an attacker keeps creating zones until the cloud provider assigns the same nameservers, and then owns the whole subtree.
The check: ask each delegated nameserver, non-recursively, for the child's SOA. REFUSED, SERVFAIL, or the parent's SOA instead of the child's means the delegation is dangling. Also flag nameservers whose own hostnames no longer resolve. And skip "occluded" delegations that sit underneath another delegation in the same zone — resolvers never consult them.
Tools that fail silently
Before writing my own, I fed zone-file exports to an off-the-shelf scanner. The export wrote cloud alias records as A records with a hostname instead of an IP, which isn't valid zone-file syntax. The scanner stopped parsing at the first one, exited 0, and reported partial results. Most of every zone was never checked, and nothing said so.
The lesson I took: always assert records read == records written, and test with a fixture that has a real finding placed after an unusual record. A tool that says "no findings" has to prove it looked.
False positives the other way
A public CNAME pointing into a private zone looks dead from the internet but is live inside — and can't be taken over, because you own the target zone. Verification-token CNAMEs look dead by design. Both need to land in "keep", not "delete".
Deleting safely
Finding the records is half the job. Removing them without surprises is the other half.
- Announce first. Post the list to the engineering channel with a deadline a few days out, say exactly what was checked, and ask owners to object.
- Re-check immediately before deleting. Still present? Still dead? Then back up the exact record sets to JSON, delete, and confirm against the authoritative nameservers.
- IaC-managed records go through a PR, not the console, or the next
applyquietly recreates them. - Read the plan, all of it. Unrelated drift rides along with any apply. In one cleanup PR, a "latest image" lookup in an unrelated module would have force-replaced a long-lived server and wiped an agent someone had installed by hand. Pin image IDs (or ignore image changes) for long-lived hosts.
- Make module-only changes trigger the plan pipeline. Path filters on CI often watch the root configurations and miss shared modules, so the PR shows no plan and reviewers approve blind.
What I took away
- Ask two questions, not one. "Is it dead?" and "Is anything using it?" are independent, and only both together justify a delete.
- Unknown means keep. A scanner that is unsure should say so and stay out of the way.
- Resolving is not the same as being claimed. The most dangerous records are the ones that look perfectly healthy.
- Prove your tools looked. An exit code of 0 and an empty findings list is not evidence of a clean zone.
- Rank by risk, then clean up. Fix the takeover candidates first; the tidying can wait a sprint.