Diagnostic Security / Takeover Risk

Dangling CNAME enables subdomain takeover

A subdomain like promo.example.com still has a CNAME pointing at a cloud service you closed months ago. Nobody noticed, because the record itself looks completely normal. Then a stranger signs up for that same service, claims the exact name your CNAME points at, and now they control what shows up on your subdomain. This is one of the quietest ways a company loses part of its own domain, and it is simple to find and simple to fix.

Security / Takeover Risk Python and Node.js Safe by default (dry run)
Closeup photo of turned-on blue and white laptop computer
Photo by Philipp Katzenberger on Unsplash
The short answer

A dangling CNAME points at a target hostname that no longer exists or no longer belongs to you, such as a deleted cloud storage bucket, a removed static site project, or a cancelled hosting account. DNS still answers with that CNAME, so anyone who claims the same target name on the same provider can serve their own content under your subdomain. Check every CNAME target for a real, working answer, and delete or repoint any record whose target is gone. Full detection script, tests, and a Cloudflare API repair are below.

The problem in plain words

A CNAME record is a pointer. It tells the internet "for this subdomain, go look at this other hostname instead." Lots of services use this on purpose: a static site host, a help desk tool, a CDN, a file storage bucket. You add a CNAME once, the provider connects it to your account, and traffic flows.

The trouble starts when the account on the other end disappears but the CNAME record stays. Maybe someone deleted the storage bucket, closed the trial account, or removed the project. The provider no longer has anything answering for that target name. But your DNS record still says "go there," and on many providers, that target name is free for the next person to claim. If an attacker signs up and claims the exact same name, your subdomain now serves their content, on your own domain, with your own SSL certificate if you have one set up for it.

promo.example.com CNAME old-app.host.io old-app.host.io account deleted Attacker claims the same target name Subdomain now serves attacker content Phishing or fake page
The CNAME still says "go there," the target account is gone, and the next person to claim that name gets your subdomain's traffic.

Why it happens

This is a well documented class of vulnerability, not a rare edge case. Security researchers and bug bounty programs report subdomain takeovers constantly, and most major cloud platforms, including GitHub Pages, Amazon S3, Azure, and Heroku, have all had to publish guidance on how customers should protect themselves from it.

The key insight

The DNS record is not the vulnerability by itself. The vulnerability is a CNAME pointing at a target that your team no longer controls, on a service where anyone can register that same target name. A CNAME pointing at something you still own, even something unused, is not a takeover risk. A CNAME pointing at something deleted, cancelled, or unclaimed is.

The fix, as a flow

Every CNAME record deserves one simple question on a schedule: does its target still resolve, and does it still answer as something you own? If the target is gone or shows a provider's "not claimed" or "no such site" page, the subdomain is exposed right now, and the fix is either to delete the CNAME or to re-claim the target before anyone else does.

Scheduled scan every CNAME in the zone Resolve target and request it over HTTPS Answers as yours? yes Leave it alone record is healthy no, dangling Remove CNAME or re-claim the target Takeover blocked before it can happen
A scheduled scan tells the difference between a healthy pointer and a dangling one, and repairs the dangling ones before anyone can claim them.

How to fix it

1

List every CNAME record in the zone

Start with a full inventory. You cannot check what you do not know exists. Pull every CNAME record from your DNS provider, including old marketing subdomains and anything that looks forgotten.

Terminal
list-cnames.sh
dig +short CNAME promo.example.com
dig +short CNAME legacy.example.com
dig +short CNAME status.example.com
# repeat for every subdomain, or pull the full record list from your DNS provider's API
2

Check whether each target still resolves

Ask DNS whether the CNAME target itself has an address. If it returns NXDOMAIN, the target is gone and the subdomain is dangling right now.

Terminal
check-target.sh
dig +short A old-app.host.io
# empty output plus an NXDOMAIN status means the target is gone

dig old-app.host.io | grep -i status
# look for "status: NXDOMAIN" in the header
3

Check whether a resolving target is actually unclaimed

Some targets still resolve to a real IP address but answer with the provider's own "no such site" or "not claimed" page. That is just as dangerous as NXDOMAIN, because the name is free to register. Request the subdomain over HTTPS and read the response for those phrases.

Terminal
check-unclaimed.sh
curl -s -o - https://promo.example.com/ | grep -i "there isn't a github pages site here"
curl -s -o - https://promo.example.com/ | grep -i "the specified bucket does not exist"
openssl s_client -connect promo.example.com:443 -servername promo.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject
# a certificate error, or one of those "not claimed" phrases in the body, both mean the target is unclaimed
4

Remove the dangling record or re-claim the target

Delete the CNAME for any subdomain you no longer use. If you still need the subdomain, log into the provider and re-register the exact target name yourself before an attacker does.

DNS record
dns-records.txt
# Before: dangling record, target account was deleted
Type: CNAME  Name: promo   Value: old-app.host.io   TTL: 300

# Fix option A: delete the record entirely, the subdomain is not in use
# (remove the row above from the zone)

# Fix option B: re-claim the target on the provider, then keep the record
Type: CNAME  Name: promo   Value: promo.example.com.new-app.host.io   TTL: 300
# only keep the CNAME once the target resource is verified back under your account
Do this before you decommission anything

The safest order of operations is: remove the DNS record first, then delete or cancel the cloud resource. Doing it in the other order leaves a window, sometimes days or weeks, where the subdomain points at nothing and is free for anyone to grab.

How to check it worked

Re-run the same checks. A fixed subdomain either has no CNAME at all, or its CNAME target resolves and answers as a resource you control, with a valid TLS certificate that matches your domain.

Terminal
verify.sh
dig +short CNAME promo.example.com
# expect: nothing (record removed), or a target hostname you still own

dig +short A old-app.host.io
# expect: a real IP address, not empty, if you kept and re-claimed the target

curl -s -o /dev/null -w "%{http_code}\n" https://promo.example.com/
# expect: 200 or 301/302, not a provider's generic "not found" page

openssl s_client -connect promo.example.com:443 -servername promo.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -dates
# expect: a valid certificate for your domain, not expired, not a mismatch error

The full code

Here is a small script in each language that checks a list of subdomains for a dangling CNAME, and, when told to, repairs the zone through the Cloudflare API by removing the offending record. 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_cname_takeover.py
"""Detect a dangling CNAME that enables subdomain takeover, and optionally
repair it via Cloudflare by removing the offending record.
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_cname_takeover")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "promo.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"

# Snippets a provider's "nothing lives here" page tends to contain.
UNCLAIMED_SIGNATURES = (
    "there isn't a github pages site here",
    "the specified bucket does not exist",
    "no such app",
    "domain mapping not found",
)


def classify_cname_target(target_status):
    """Pure decision function. No I/O.

    target_status looks like:
      {"resolves": True, "http_status": 200, "body_snippet": "..."}

    Returns one of:
      "ok"        - target resolves and does not look unclaimed
      "dangling"  - target does not resolve (NXDOMAIN) or the response body
                    matches a known "not claimed" signature
      "unknown"   - could not tell, treat as needing a human look
    """
    if target_status is None:
        return "unknown"

    if not target_status.get("resolves", False):
        return "dangling"

    snippet = (target_status.get("body_snippet") or "").lower()
    for signature in UNCLAIMED_SIGNATURES:
        if signature in snippet:
            return "dangling"

    http_status = target_status.get("http_status")
    if http_status is None:
        return "unknown"

    return "ok"


def resolve_cname_chain(domain):
    """Follow the CNAME for domain and probe the final target. Requires network."""
    import dns.resolver
    import requests

    try:
        answer = dns.resolver.resolve(domain, "CNAME")
        target = str(answer[0].target).rstrip(".")
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
        return None, {"resolves": False, "http_status": None, "body_snippet": ""}

    try:
        dns.resolver.resolve(target, "A")
        resolves = True
    except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
        return target, {"resolves": False, "http_status": None, "body_snippet": ""}

    try:
        r = requests.get(f"https://{domain}/", timeout=10)
        return target, {"resolves": resolves, "http_status": r.status_code, "body_snippet": r.text[:2000]}
    except requests.RequestException as exc:
        log.warning("HTTPS probe for %s failed: %s", domain, exc)
        return target, {"resolves": resolves, "http_status": None, "body_snippet": ""}


def find_cname_record_id(domain):
    """Find the dangling CNAME record 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", "name": domain}, timeout=30)
    r.raise_for_status()
    result = r.json().get("result", [])
    return result[0]["id"] if result else None


def remove_dangling_cname(domain, record_id):
    """Delete the dangling CNAME record so the name can no longer be claimed against it."""
    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 dangling CNAME record %s for %s", record_id, domain)
        return

    requests.delete(url, headers=headers, timeout=30).raise_for_status()
    log.info("Deleted dangling CNAME record for %s", domain)


def run():
    target, status = resolve_cname_chain(DNS_DOMAIN)
    verdict = classify_cname_target(status)
    log.info("Subdomain %s (target %s) classified as: %s", DNS_DOMAIN, target, verdict)

    if verdict != "dangling":
        log.info("Nothing to repair.")
        return

    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("Dangling CNAME found but no Cloudflare credentials set. Skipping repair.")
        return

    record_id = find_cname_record_id(DNS_DOMAIN)
    if not record_id:
        log.warning("Could not find the CNAME record via the Cloudflare API.")
        return

    remove_dangling_cname(DNS_DOMAIN, record_id)


if __name__ == "__main__":
    run()
dangling-cname-takeover.js
/**
 * Detect a dangling CNAME that enables subdomain takeover, and optionally
 * repair it via Cloudflare by removing the offending record.
 * Safe by default. Set DRY_RUN=false to let it write.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "promo.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";

// Snippets a provider's "nothing lives here" page tends to contain.
const UNCLAIMED_SIGNATURES = [
  "there isn't a github pages site here",
  "the specified bucket does not exist",
  "no such app",
  "domain mapping not found",
];

/**
 * Pure decision function. No I/O.
 *
 * targetStatus looks like:
 *   { resolves: true, httpStatus: 200, bodySnippet: "..." }
 *
 * Returns one of:
 *   "ok"        - target resolves and does not look unclaimed
 *   "dangling"  - target does not resolve (NXDOMAIN) or the response body
 *                 matches a known "not claimed" signature
 *   "unknown"   - could not tell, treat as needing a human look
 */
export function classifyCnameTarget(targetStatus) {
  if (targetStatus == null) return "unknown";

  if (!targetStatus.resolves) return "dangling";

  const snippet = (targetStatus.bodySnippet || "").toLowerCase();
  for (const signature of UNCLAIMED_SIGNATURES) {
    if (snippet.includes(signature)) return "dangling";
  }

  if (targetStatus.httpStatus == null) return "unknown";

  return "ok";
}

/** Follow the CNAME for domain and probe the final target. Requires network. */
async function resolveCnameChain(domain) {
  const dns = await import("node:dns/promises");

  let target;
  try {
    const answer = await dns.resolveCname(domain);
    target = answer[0];
  } catch {
    return [null, { resolves: false, httpStatus: null, bodySnippet: "" }];
  }

  try {
    await dns.resolve4(target);
  } catch {
    return [target, { resolves: false, httpStatus: null, bodySnippet: "" }];
  }

  try {
    const res = await fetch(`https://${domain}/`);
    const body = await res.text();
    return [target, { resolves: true, httpStatus: res.status, bodySnippet: body.slice(0, 2000) }];
  } catch {
    return [target, { resolves: true, httpStatus: null, bodySnippet: "" }];
  }
}

/** Find the dangling CNAME record via the Cloudflare API. */
async function findCnameRecordId(domain) {
  const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=CNAME&name=${domain}`;
  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();
  const result = body.result || [];
  return result.length ? result[0].id : null;
}

/** Delete the dangling CNAME record so the name can no longer be claimed against it. */
async function removeDanglingCname(domain, recordId) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`;

  if (DRY_RUN) {
    console.log(`[dry run] would delete dangling CNAME record ${recordId} for ${domain}`);
    return;
  }

  const del = await fetch(url, { method: "DELETE", headers });
  if (!del.ok) throw new Error(`Cloudflare delete failed: ${del.status}`);
  console.log(`Deleted dangling CNAME record for ${domain}`);
}

async function run() {
  const [target, status] = await resolveCnameChain(DNS_DOMAIN);
  const verdict = classifyCnameTarget(status);
  console.log(`Subdomain ${DNS_DOMAIN} (target ${target}) classified as: ${verdict}`);

  if (verdict !== "dangling") {
    console.log("Nothing to repair.");
    return;
  }

  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn("Dangling CNAME found but no Cloudflare credentials set. Skipping repair.");
    return;
  }

  const recordId = await findCnameRecordId(DNS_DOMAIN);
  if (!recordId) {
    console.warn("Could not find the CNAME record via the Cloudflare API.");
    return;
  }

  await removeDanglingCname(DNS_DOMAIN, recordId);
}

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 classification rule is the part worth testing, because it decides whether the script thinks a subdomain is safe or exposed. It takes a plain status object, no network, no DNS lookups, so the tests run in milliseconds.

test_dangling_classify.py
from dangling_cname_takeover import classify_cname_target


def test_ok_when_target_resolves_and_answers_normally():
    status = {"resolves": True, "http_status": 200, "body_snippet": "welcome to our site"}
    assert classify_cname_target(status) == "ok"


def test_dangling_when_target_does_not_resolve():
    status = {"resolves": False, "http_status": None, "body_snippet": ""}
    assert classify_cname_target(status) == "dangling"


def test_dangling_when_body_matches_unclaimed_signature():
    status = {"resolves": True, "http_status": 404, "body_snippet": "There isn't a GitHub Pages site here."}
    assert classify_cname_target(status) == "dangling"


def test_unknown_when_status_is_missing():
    assert classify_cname_target(None) == "unknown"
dangling-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCnameTarget } from "./dangling-cname-takeover.js";

test("ok when target resolves and answers normally", () => {
  const status = { resolves: true, httpStatus: 200, bodySnippet: "welcome to our site" };
  assert.equal(classifyCnameTarget(status), "ok");
});

test("dangling when target does not resolve", () => {
  const status = { resolves: false, httpStatus: null, bodySnippet: "" };
  assert.equal(classifyCnameTarget(status), "dangling");
});

test("dangling when body matches unclaimed signature", () => {
  const status = { resolves: true, httpStatus: 404, bodySnippet: "There isn't a GitHub Pages site here." };
  assert.equal(classifyCnameTarget(status), "dangling");
});

test("unknown when status is missing", () => {
  assert.equal(classifyCnameTarget(null), "unknown");
});

Case studies

Marketing subdomain

The campaign page nobody tore down properly

A marketing team launched a landing page on a static site host, pointed launch.example.com at it with a CNAME, and ran the campaign for a month. When the campaign ended, they deleted the project in the host's dashboard but never told anyone to remove the DNS record.

A researcher scanning for exactly this pattern found the dangling CNAME within a week, claimed the same project name on the host for free, and reported it as a bug bounty finding before anyone with bad intent found it first.

Cloud storage

The bucket that outlived its project

An engineering team pointed files.example.com at a cloud storage bucket for a project that later got cancelled. The bucket was deleted during cleanup, but the CNAME record was buried in a large zone file with hundreds of entries and nobody caught it.

A scheduled scan against every CNAME target flagged the missing bucket within a day of the cleanup. The team removed the record immediately, before the bucket name could be re-registered by anyone else.

What good looks like

Every CNAME record in your zone points at something you actively control, and a scheduled check confirms that on a regular basis. Whenever a project, bucket, or app is decommissioned, removing its DNS record is a required step, not an afterthought, and it happens before the resource itself is released back to the provider.

FAQ

What is a subdomain takeover?

A subdomain takeover happens when a CNAME record still points at a cloud service or third-party host that your team no longer owns. Anyone who signs up for that same service and claims the exact hostname can serve their own page under your subdomain, because DNS still sends visitors there.

How do I know if a CNAME is dangling?

Look up the CNAME target and see whether it actually resolves and answers. If the target returns NXDOMAIN, or it resolves but the page shows an error like "There isn't a GitHub Pages site here" or "The specified bucket does not exist," the CNAME is dangling and the subdomain is at risk.

How do I fix a dangling CNAME?

Remove the CNAME record for any subdomain you are no longer using, or re-claim the target resource yourself before someone else does. Once the record is deleted or the resource is safely back under your control, the subdomain can no longer be taken over.

Related field notes

Citations

On the problem:

  1. A. a.com

On the solution:

  1. B. b.com

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 fix your zone?

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

Back to all DNS and Domains field notes