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.

Security risk Python and Node.js Safe by default (dry run)
A rack of servers
Photo by Yuriy Vertikov on Unsplash
The short answer

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.

*.example.com CNAME old-app.paas.net old-app.paas.net deleted, unclaimed Attacker re-registers same name Takeover any.example.com trusted cert too still resolves for whatever.example.com
The wildcard keeps pointing at a name the original service dropped. Whoever claims that name on the provider now owns every unclaimed subdomain of your zone.

Why it happens

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.

The key insight

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.

Resolve wildcard and its CNAME target Target dead? NXDOMAIN / SERVFAIL Delete wildcard or repoint to safe origin Safe origin answers with 404 Add CAA limits who can issue
Confirming the target is really gone comes first. Then the wildcard is removed or repointed to something you own, and a CAA record blocks casual certificate issuance for good measure.

How to fix it

1

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.

Terminal
check-wildcard.sh
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
2

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.

Terminal
check-target.sh
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 
3

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.

4

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.

DNS record
dns-records.txt
# 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
5

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.

6

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.

DNS record
caa-record.txt
Type: CAA  Name: @  Value: 0 issue "letsencrypt.org"  TTL: Auto
# repeat with one CAA record per CA you actually use, and nothing else
Put this in the decommission checklist

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.

Terminal
verify.sh
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.

dangling_wildcard.py
"""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()
dangling-wildcard.js
/**
 * 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.

test_wildcard_dangling.py
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
dangling-wildcard.test.js
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

Retired MVP

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.

CDN migration

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.

What good looks like

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:

  1. OWASP Subdomain Takeover Prevention Cheat Sheet. cheatsheetseries.owasp.org
  2. can-i-take-over-xyz (EdOverflow), service fingerprints for dangling DNS takeovers. github.com/EdOverflow/can-i-take-over-xyz
  3. Microsoft Learn: Prevent subdomain takeovers with Azure DNS alias records. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover

On the solution:

  1. Cloudflare DNS docs: Wildcard DNS records. developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records
  2. Cloudflare API: Delete DNS Record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes