Diagnostic Security / Takeover Risk
Wildcard record points at a deprovisioned service
Years ago someone added a wildcard record so every subdomain of your zone would route to a hosted app. That app is long gone now, closed or deleted, but the wildcard record never got cleaned up. It still answers for any name you can type, and that is exactly what a stranger needs to serve their own page on your domain with a certificate that looks trusted.
A wildcard record like *.example.com CNAME some-app.herokudns.com answers for every possible subdomain, even ones that do not exist yet. If some-app.herokudns.com was deleted, an attacker can register that same free-tier name on the provider and take over any unclaimed subdomain of your zone, complete with a trusted TLS certificate. Delete the wildcard if you no longer need one, or repoint it at an origin you control that returns a plain 404 for unknown names, and add a CAA record as a backstop. Full checks, fix, and a script are below.
The problem in plain words
A wildcard DNS record uses a star in place of the subdomain name, so it matches anything. *.example.com answers for shop.example.com, test123.example.com, or any name nobody has ever used, all pointed at the same target. Teams add this when they want every customer subdomain, every preview branch, or every random name to land on one hosted app without adding a new DNS record each time.
That convenience becomes a hole the day the target app, bucket, or CDN endpoint gets deleted. The wildcard record in your zone does not know the target is gone. It keeps telling every resolver on the internet "go look over there." Meanwhile, on the provider's side, that name is now free for anyone to claim. An attacker signs up for the same free-tier service, picks the exact name your wildcard points at, and suddenly any unclaimed subdomain of your domain serves their content. Because it is genuinely your domain in the address bar, the attacker can also get a real, trusted certificate for it, so there is not even a browser warning.
Why it happens
- A wildcard was added early on to save time, one record instead of one per subdomain, and nobody kept a list of what still needed it.
- The hosted app, storage bucket, or CDN distribution behind the wildcard was retired or the account was closed, but deleting the DNS record was nobody's job.
- Decommissioning checklists cover shutting down the app and the billing, but rarely mention "go check DNS for anything pointing here."
- The wildcard was meant for a narrow case, like preview branches on a PaaS, and quietly kept working for every other name too, so the blast radius is wider than anyone realizes.
This is a well documented class of bug. The community-maintained can-i-take-over-xyz list tracks dozens of providers where an unclaimed name on their platform will happily serve a stranger's content, and OWASP's own cheat sheet on subdomain takeovers exists specifically because this keeps happening to real companies.
A normal dangling CNAME only exposes one subdomain. A dangling wildcard exposes every subdomain that does not already have its own record, an unbounded and growing attack surface, because new unclaimed names appear the moment anyone tries a subdomain that was never registered before. You cannot list every vulnerable name in advance. The only fix is removing the dangling target itself.
The fix, as a flow
We do not chase every possible subdomain one at a time. We go straight to the wildcard record, confirm its target is actually gone, and either delete the wildcard or repoint it at something you own that safely answers every unknown name with a 404. Then we add a CAA record so a squatter cannot as easily get a certificate even if they try.
How to fix it
Resolve the wildcard directly
Query the wildcard name itself, quoting the asterisk so your shell does not expand it, and also probe a random name that has never been used. Both should behave the same way, since the wildcard answers for anything.
dig +short CNAME '*.example.com'
# a hostname here is the wildcard target, e.g. old-app.herokudns.com
dig +short randomstring123.example.com
# should match the wildcard's behavior, since the wildcard answers for any name
Check if the target itself is still alive
Resolve the hostname the wildcard points at. An NXDOMAIN, a SERVFAIL, or "no such host" means the service behind it is gone, which is exactly the dangling state an attacker is looking for.
dig +short A old-app.herokudns.com
# empty output, NXDOMAIN, or SERVFAIL means the target is dead
curl -sI https://randomstring123.example.com
# look for a provider's "not claimed" page: "No such app", "NoSuchBucket",
# "There isn't a GitHub Pages site here", or a connection refused / cert mismatch
openssl s_client -connect randomstring123.example.com:443 -servername randomstring123.example.com
Cross-check the fingerprint
Compare what you saw against EdOverflow's can-i-take-over-xyz list, which catalogs the exact error text and behavior of every provider known to allow this kind of takeover. A match confirms this is a known vulnerable pattern, not a one-off fluke.
Delete the wildcard, or repoint it at a safe origin
If nothing legitimately needs the wildcard anymore, delete it outright in your DNS provider's dashboard. If some subdomains still need wildcard coverage, replace the dangling target with a live one you control, an origin that answers unrecognized hostnames with a plain 404 instead of resolving to a third-party service.
# Option A: no longer needed, just delete it
# Cloudflare DNS: Records tab, find the "*" CNAME/A record, click Delete
# Option B: still needed, repoint at a fallback you control
Type: CNAME Name: * Value: fallback.example.com TTL: Auto
# fallback.example.com is an A/AAAA record you own, set to answer
# unrecognized hostnames with HTTP 404 rather than a third-party service
# Option C: repoint at a currently provisioned app instance
Type: CNAME Name: * Value: myapp-v2.herokudns.com TTL: Auto
# only if myapp-v2 is a live, actively owned service you control today
Sweep individual subdomain CNAMEs too
The wildcard is not always the only offender. List every CNAME in the zone and remove any that still point at the same deprovisioned service instance, since those are just as claimable.
Add a CAA record as defense in depth
A CAA record tells certificate authorities which of them are allowed to issue a certificate for your domain. Even if a squatter briefly re-claims a name, restricting issuance to the CAs you actually use makes it harder for them to get a trusted certificate for it.
Type: CAA Name: @ Value: 0 issue "letsencrypt.org" TTL: Auto
# repeat with one CAA record per CA you actually use, and nothing else
The root cause here is a missing step, not a one-time mistake. Add "search DNS for any record pointing at this service" as a required box to check whenever an app, bucket, or CDN endpoint is shut down, per OWASP's Subdomain Takeover Prevention Cheat Sheet.
How to check it worked
Re-run the same lookups against a fresh random name. A clean result either returns nothing at all, or resolves to an origin you actually own and that answers correctly.
dig +short CNAME '*.example.com'
dig +short randomstring456.example.com
# expect: nothing (wildcard removed), or a host/IP you control that answers
dig +short A fallback.example.com
# expect: a valid IP address you own, if you kept a wildcard
curl -sI https://randomstring456.example.com
# expect: your own 404 or placeholder page, HTTP 200 or 404 from your
# infrastructure, not a provider's "not claimed" error
openssl s_client -connect example.com:443 -servername randomstring456.example.com
The full code
Here is a small script in each language that pulls a zone's CNAME records, flags any wildcard whose target has gone dark, and, when told to, deletes the record 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 a wildcard CNAME pointing at a deprovisioned service and optionally
delete it via Cloudflare. 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_wildcard")
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", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
KNOWN_VULNERABLE_FINGERPRINTS = {
"no such app",
"nosuchbucket",
"there isn't a github pages site here",
"unrecognized domain",
}
def is_dangling_wildcard(record, resolved_target_status, http_fingerprint, known_vulnerable_fingerprints):
"""Pure decision function. No I/O.
record: dict, must have name starting with "*" and type "CNAME".
resolved_target_status: one of "NXDOMAIN", "SERVFAIL", "OK", pre-resolved
by the caller via dns.resolver.
http_fingerprint: a lowercase string from the target's HTTP response, or
None if no request was made.
known_vulnerable_fingerprints: set of known "unclaimed resource" strings.
Returns True if the wildcard CNAME target is dangling.
"""
if not record.get("name", "").startswith("*"):
return False
if record.get("type") != "CNAME":
return False
if resolved_target_status != "OK":
return True
if http_fingerprint in known_vulnerable_fingerprints:
return True
return False
def list_cname_records():
"""List every CNAME record in the zone 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"}, timeout=30)
r.raise_for_status()
return r.json().get("result", [])
def resolve_target_status(hostname):
"""Resolve a CNAME target and classify it as OK, NXDOMAIN, or SERVFAIL."""
import dns.resolver
resolver = dns.resolver.Resolver()
try:
resolver.resolve(hostname, "A")
return "OK"
except dns.resolver.NXDOMAIN:
return "NXDOMAIN"
except dns.resolver.NoAnswer:
return "OK"
except Exception as exc:
log.warning("Resolving %s failed: %s", hostname, exc)
return "SERVFAIL"
def probe_http_fingerprint(hostname):
"""Fetch the hostname over HTTPS and return a lowercase fingerprint string."""
import requests
try:
resp = requests.get(f"https://{hostname}/", timeout=10)
return resp.text.lower()
except Exception as exc:
log.warning("HTTP probe for %s failed: %s", hostname, exc)
return ""
def delete_record(record_id):
"""Delete a dangling wildcard 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 dangling wildcard record %s", record_id)
def run():
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Nothing to scan.")
return
records = list_cname_records()
flagged = 0
for record in records:
if not record.get("name", "").startswith("*"):
continue
target = record.get("content", "")
status = resolve_target_status(target)
fingerprint = probe_http_fingerprint(target) if status == "OK" else ""
dangling = is_dangling_wildcard(record, status, fingerprint, KNOWN_VULNERABLE_FINGERPRINTS)
if dangling:
log.info("Wildcard %s -> %s is dangling (%s)", record["name"], target, status)
delete_record(record["id"])
flagged += 1
else:
log.info("Wildcard %s -> %s looks fine", record["name"], target)
log.info("Done. %d dangling wildcard(s) %s.", flagged, "to remove" if DRY_RUN else "removed")
if __name__ == "__main__":
run()
/**
* Detect a wildcard CNAME pointing at a deprovisioned service and optionally
* delete it 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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
const KNOWN_VULNERABLE_FINGERPRINTS = new Set([
"no such app",
"nosuchbucket",
"there isn't a github pages site here",
"unrecognized domain",
]);
/**
* Pure decision function. No I/O.
*
* record: object, must have name starting with "*" and type "CNAME".
* resolvedTargetStatus: one of "NXDOMAIN", "SERVFAIL", "OK", pre-resolved by
* the caller.
* httpFingerprint: a lowercase string from the target's HTTP response, or
* null if no request was made.
* knownVulnerableFingerprints: Set of known "unclaimed resource" strings.
*
* Returns true if the wildcard CNAME target is dangling.
*/
export function isDanglingWildcard(record, resolvedTargetStatus, httpFingerprint, knownVulnerableFingerprints) {
if (!record.name || !record.name.startsWith("*")) return false;
if (record.type !== "CNAME") return false;
if (resolvedTargetStatus !== "OK") return true;
if (knownVulnerableFingerprints.has(httpFingerprint)) return true;
return false;
}
/** List every CNAME record in the zone via the Cloudflare API. */
async function listCnameRecords() {
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME`;
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();
return body.result || [];
}
/** Resolve a CNAME target and classify it as OK, NXDOMAIN, or SERVFAIL. */
async function resolveTargetStatus(hostname) {
const dns = await import("node:dns/promises");
try {
await dns.resolve(hostname, "A");
return "OK";
} catch (err) {
if (err.code === "ENOTFOUND" || err.code === "ENODATA") return "NXDOMAIN";
return "SERVFAIL";
}
}
/** Fetch the hostname over HTTPS and return a lowercase fingerprint string. */
async function probeHttpFingerprint(hostname) {
try {
const res = await fetch(`https://${hostname}/`, { signal: AbortSignal.timeout(10000) });
const text = await res.text();
return text.toLowerCase();
} catch {
return "";
}
}
/** Delete a dangling wildcard record through the Cloudflare API. */
async function deleteRecord(recordId) {
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`;
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
if (DRY_RUN) {
console.log(`[dry run] would delete record ${recordId}`);
return;
}
const res = await fetch(url, { method: "DELETE", headers });
if (!res.ok) throw new Error(`Cloudflare delete failed: ${res.status}`);
console.log(`Deleted dangling wildcard record ${recordId}`);
}
async function run() {
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("No Cloudflare credentials set. Nothing to scan.");
return;
}
const records = await listCnameRecords();
let flagged = 0;
for (const record of records) {
if (!record.name || !record.name.startsWith("*")) continue;
const target = record.content || "";
const status = await resolveTargetStatus(target);
const fingerprint = status === "OK" ? await probeHttpFingerprint(target) : "";
const dangling = isDanglingWildcard(record, status, fingerprint, KNOWN_VULNERABLE_FINGERPRINTS);
if (dangling) {
console.log(`Wildcard ${record.name} -> ${target} is dangling (${status})`);
await deleteRecord(record.id);
flagged++;
} else {
console.log(`Wildcard ${record.name} -> ${target} looks fine`);
}
}
console.log(`Done. ${flagged} dangling wildcard(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 worth testing, because it decides which records the script treats as dangling. It takes plain values, no network, no DNS lookups, so the tests run in milliseconds.
from dangling_wildcard import is_dangling_wildcard
VULN = {"no such app", "nosuchbucket"}
def wildcard_record(**over):
base = {"name": "*.example.com", "type": "CNAME", "content": "old-app.paas.net"}
base.update(over)
return base
def test_dangling_when_target_is_nxdomain():
record = wildcard_record()
assert is_dangling_wildcard(record, "NXDOMAIN", None, VULN) is True
def test_dangling_when_target_is_servfail():
record = wildcard_record()
assert is_dangling_wildcard(record, "SERVFAIL", None, VULN) is True
def test_dangling_when_fingerprint_matches_known_vulnerable():
record = wildcard_record()
assert is_dangling_wildcard(record, "OK", "no such app", VULN) is True
def test_not_dangling_when_target_ok_and_fingerprint_unknown():
record = wildcard_record()
assert is_dangling_wildcard(record, "OK", "welcome home", VULN) is False
def test_not_dangling_when_not_a_wildcard_name():
record = wildcard_record(name="shop.example.com")
assert is_dangling_wildcard(record, "NXDOMAIN", None, VULN) is False
def test_not_dangling_when_not_cname_type():
record = wildcard_record(type="A")
assert is_dangling_wildcard(record, "NXDOMAIN", None, VULN) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDanglingWildcard } from "./dangling-wildcard.js";
const VULN = new Set(["no such app", "nosuchbucket"]);
const wildcardRecord = (over = {}) => ({
name: "*.example.com",
type: "CNAME",
content: "old-app.paas.net",
...over,
});
test("dangling when target is nxdomain", () => {
assert.equal(isDanglingWildcard(wildcardRecord(), "NXDOMAIN", null, VULN), true);
});
test("dangling when target is servfail", () => {
assert.equal(isDanglingWildcard(wildcardRecord(), "SERVFAIL", null, VULN), true);
});
test("dangling when fingerprint matches known vulnerable", () => {
assert.equal(isDanglingWildcard(wildcardRecord(), "OK", "no such app", VULN), true);
});
test("not dangling when target ok and fingerprint unknown", () => {
assert.equal(isDanglingWildcard(wildcardRecord(), "OK", "welcome home", VULN), false);
});
test("not dangling when not a wildcard name", () => {
const record = wildcardRecord({ name: "shop.example.com" });
assert.equal(isDanglingWildcard(record, "NXDOMAIN", null, VULN), false);
});
test("not dangling when not cname type", () => {
const record = wildcardRecord({ type: "A" });
assert.equal(isDanglingWildcard(record, "NXDOMAIN", null, VULN), false);
});
Case studies
The demo app nobody remembered to unhook
A startup built a quick demo on a PaaS two years ago and pointed a wildcard at it so every prospect could get their own subdomain. The demo was shut down within a quarter, but the wildcard record stayed in the zone the whole time. A security researcher found it, registered the same app name for free, and reported that any subdomain of the company's domain could be made to show arbitrary content.
Deleting the wildcard closed the report the same day. The team added "check DNS" to their service shutdown checklist afterward.
The bucket that moved and left its name behind
A media team migrated their asset storage to a new CDN provider and deleted the old storage bucket, but a wildcard CNAME still pointed subdomains at the old bucket's hostname. Because that hostname pattern was on a well-known vulnerable-provider list, an automated scanner flagged it within days of the bucket's deletion.
Repointing the wildcard at the new CDN's fallback origin, one that serves a 404 for unrecognized names, fixed both the dangling record and kept the convenience of a single wildcard rule.
A healthy wildcard, if you keep one at all, always points at something you actively own today, and that origin answers every unrecognized hostname with a clean 404 instead of resolving to a third-party service. A CAA record limits who can even try to issue a certificate for your domain. Nobody can claim a name you already control.
FAQ
Why is a wildcard DNS record dangerous if the service behind it is gone?
A wildcard record answers for every possible subdomain, even ones nobody has created yet. If it points at a PaaS app, bucket, or CDN endpoint that was later deleted, that name becomes unclaimed on the provider's side while your DNS still sends traffic there. An attacker can register the same free-tier name on that provider and any subdomain of your zone will now serve their content on your domain, with a certificate that looks trusted.
How do I know if my wildcard record is actually dangling?
Resolve the wildcard target directly, then resolve that target's own hostname. If the wildcard CNAME points at something like an app on herokudns.com or azurewebsites.net and that hostname returns NXDOMAIN or SERVFAIL, or an HTTP request to a random unclaimed subdomain shows a provider error page like No such app, that is the signature of a dangling wildcard.
What is the safest way to fix a dangling wildcard record?
Delete the wildcard record if you no longer need one. If you still need wildcard coverage, repoint it at an origin you control that answers every unknown hostname with a plain 404 instead of a third-party service. Add a CAA record limiting which certificate authorities can issue for your domain, and make record cleanup a required step whenever a hosted service is decommissioned.
Related field notes
Citations
On the problem:
- OWASP Subdomain Takeover Prevention Cheat Sheet. cheatsheetseries.owasp.org
- can-i-take-over-xyz (EdOverflow), service fingerprints for dangling DNS takeovers. github.com/EdOverflow/can-i-take-over-xyz
- Microsoft Learn: Prevent subdomain takeovers with Azure DNS alias records. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover
On the solution:
- Cloudflare DNS docs: Wildcard DNS records. developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records
- Cloudflare API: Delete DNS Record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete
- Cloudflare DNS docs: CAA record management. developers.cloudflare.com/dns/manage-dns-records/reference/caa-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