Blog

Subdomain Takeover: How It Happens and How to Detect It Without Drowning in False Positives

A dangling CNAME pointing at a release-claimed cloud resource is a hijack waiting to happen. Walk-through of the mechanic, the providers most vulnerable to it, and how to detect it without flagging every page that mentions "Repository not found".

6 min readsubdomain-takeoverdnsheuristics

Your marketing team spins up blog.acme.com on a hosted platform for a one-off product launch. Six months later marketing leaves the company, the launch is over, the hosted account gets cancelled. Nobody removes the blog.acme.com CNAME from DNS. The hosted platform releases the name back into its pool. An attacker claims it.

Now blog.acme.com, a name that still passes any SPF/DKIM/cookie scope test because the parent domain trusts it, belongs to someone else. That's a subdomain takeover. It's one of the most consequential vulnerabilities startup teams overlook, precisely because nothing in the DNS audit log looks wrong. The CNAME target is still resolvable. The name still answers HTTP. Only the meaning of the response changed.

What has to line up

Subdomain takeover requires three things to line up. A subdomain whose DNS record (usually a CNAME) points to a hostname on a SaaS platform. The SaaS account that originally owned that hostname is gone; the project was deleted, the trial expired, or the team rotated billing accounts. And the platform allows new customers to claim arbitrary hostnames on a first-come-first-served basis, which most do because custom-domain onboarding has to work somehow.

The combination is depressingly common. GitHub Pages, Heroku, Fastly, AWS S3 static hosting, Unbounce, Cloudflare Tunnels, and dozens of other providers all support custom domains by accepting a CNAME pointed at one of their platform hostnames. Each one is a potential takeover surface if the DNS record outlives the platform tenancy. AWS has slowed the S3 flavor down by blocking re-creation of recently-deleted bucket names for around 90 days, so the exposure window is narrower than it used to be. The class of bug is still very much live, and can-i-take-over-xyz (the canonical catalog of which providers are vulnerable) is still actively maintained. Finding which subdomains exist in the first place is its own problem; the practical free sources are in our piece on subdomain enumeration without paid tooling.

What the "unclaimed" state looks like

When a hostname is dangling (the CNAME still resolves, but nothing on the platform side owns it) most providers return a characteristic error page rather than a 404. That error page is the detection signal:

Heroku       "There is no app here" / "No such app" / herokucdn.com
S3           "NoSuchBucket" / "The specified bucket does not exist"
GitHub Pages "There isn't a GitHub Pages site here"
Fastly       "Fastly error: unknown domain"
Unbounce     "The requested URL was not found" / unbouncepages.com
Cloudflare T "Argo Tunnel error" / "trycloudflare.com"

The naive detection algorithm is to fetch the page and substring-match against a list of these strings. That works most of the time. It also produces a stream of false positives the moment you point it at the wider internet.

The false-positive problem

Take this list of takeover fingerprints from a typical open-source detector:

"Repository not found"
"Domain not configured"
"This site can't be reached"
"project not found"

Each of those strings is a real takeover signal on some platform. Each of them also appears constantly in completely innocent contexts. "Repository not found" shows up in GitHub's own documentation, in error tutorials, in dev blog posts, and on legitimate but-now-private repos. "This site can't be reached" is Chrome's offline error page; any cached HTML capture surfaced by a CDN can include that string. "project not found" matches GitLab, yes, but also literally anywhere a developer wrote about a missing project.

Fire those into a scanner that flags critical findings on substring match and the operator's inbox fills with alerts for static documentation pages. The same dynamic shows up when you mine certificate transparency logs for subdomain discovery: signal density is high, only if you gate on shape rather than substring presence.

The fix: split the fingerprints by specificity

The pattern we landed on after our customer-zero deployment burned through too many of these was to split the fingerprint list into two tiers and require corroboration for the loose tier.

static STRONG_FINGERPRINTS: &[&str] = &[
    "There is no app here",
    "No such app",
    "herokucdn.com",
    "The specified bucket does not exist",
    "NoSuchBucket",
    "Fastly error: unknown domain",
    "unbouncepages.com",
    "trycloudflare.com",
];

static WEAK_FINGERPRINTS: &[&str] = &[
    "Repository not found",
    "Domain not configured",
    "This site can't be reached",
    "project not found",
];

Strong fingerprints are concrete provider error strings that wouldn't appear in normal content. A single strong hit is enough to flag a takeover.

Weak fingerprints are real takeover signals on some platforms, but they also show up in legitimate content. A single weak hit isn't enough to fire. They need either to co-occur (two weak hits together) or to be corroborated by an independent signal, the most useful one being the asset's actual CNAME target.

CNAME provider correlation

Every takeover-prone provider hosts custom hostnames under a known suffix. foo.herokuapp.com, baz.github.io, quux.fastly.net, and so on. If the asset's DNS CNAME points at one of those suffixes, the probability that a weak fingerprint in the body is a real takeover signal goes way up.

static TAKEOVER_CNAME_PROVIDERS: &[&str] = &[
    "azurewebsites.net",
    "cloudapp.net",
    "fastly.net",
    "github.io",
    "herokuapp.com",
    "herokudns.com",
    "readme.io",
    "surge.sh",
    "unbouncepages.com",
];

A note on what's deliberately not in that set. Plain amazonaws.com is too broad to be useful; almost every AWS CNAME (ALB, CloudFront, API Gateway) lands on an amazonaws.com suffix, and the vast majority of them aren't takeover-prone. Only S3 website hosting and Elastic Beanstalk really belong here, and those need more-specific suffix matches (e.g. s3-website-*.amazonaws.com, *.elasticbeanstalk.com). pages.dev and vercel.app are less takeover-prone now that ownership verification is the default flow for adding a custom domain, though not eliminated. Re-claim after a project deletion is still a live scenario, especially in the window before the platform reaps the original binding, so we keep them in the broader set with a heavier corroboration requirement rather than dropping them entirely.

Historical fingerprints worth a quick note rather than a seat at the table. Tumblr's custom-domain feature was discontinued around 2017, so its tumblr.com/login signature only matters for ancient DNS entries. Any provider-agnostic "parked free" or generic-domain-parking strings are vague enough to flag legitimate parked landing pages, so we keep them out of the strong tier until we have a named provider behind them.

With the provider set as a second signal, the match policy becomes a small decision tree:

fn takeover_match(body: &str, cname: Option<&str>) -> Option<&'static str> {
    let body_lower = body.to_lowercase();

    // Strong hit alone -> confident match.
    for fp in STRONG_FINGERPRINTS {
        if body_lower.contains(&fp.to_lowercase()) {
            return Some(fp);
        }
    }

    let weak_hits: Vec<&&str> = WEAK_FINGERPRINTS.iter()
        .filter(|fp| body_lower.contains(&fp.to_lowercase()))
        .collect();

    // Two or more weak hits co-occurring -> still confident.
    if weak_hits.len() >= 2 {
        return Some(weak_hits[0]);
    }

    // Single weak hit, but CNAME points at a takeover-prone provider ->
    // promoted to a real match.
    if let (Some(first_weak), Some(cn)) = (weak_hits.first(), cname) {
        let cn_lower = cn.to_lowercase();
        let cn_lower = cn_lower.trim_end_matches('.');
        if TAKEOVER_CNAME_PROVIDERS.iter().any(|p| cn_lower.contains(p)) {
            return Some(**first_weak);
        }
    }

    // Otherwise we have a weak signal with no corroboration. Hold fire.
    None
}

Result: a detector that catches the same set of real takeovers (provider-specific signals still fire on a single hit) but stops flagging GitHub's own docs pages and Chrome's offline-error template.

What this misses (and what to add)

A heuristic at this level catches the obvious cases (the ones that account for most opportunistic takeovers), but there are real takeover classes it doesn't cover.

  • Wildcard catch-all hijacks on platforms that let one tenant claim *.somecorp.acme-platform.com. The fingerprint approach doesn't help here because the response isn't an error; it's the attacker's actual page.
  • DNS A/AAAA pointing at released cloud IPs (e.g. an Elastic IP released back to AWS's pool and assigned to someone else's instance). The CNAME path is the easy one; raw-IP takeovers are an arms race.
  • NS-record takeovers, where a delegation points at a nameserver service the org no longer pays for. Rare, devastating, and the detection signal is at the DNS level, not the HTTP body.

The right strategy is to layer signals. Substring fingerprints for the common case, CNAME-provider correlation to gate the ambiguous case, and active platform-API claim checks ("does this hostname currently belong to a tenant on Heroku?") for the dangerous case.

Mitigations the engineering team can ship today

Most of the practical mitigation work fits into four habits:

  • Audit DNS records on a schedule. Any CNAME pointing at a third-party hostname that no longer resolves, or that returns a known dangling-state body, needs the DNS record removed the same day.
  • Remove DNS before cancelling the platform. When deprecating a service that lives on a custom domain, do the DNS removal step before cancelling the platform tenancy. Reverse order leaves a window where the subdomain is exploitable.
  • Claim a placeholder tenant where you can. Keep the custom hostname bound to a tenant you control (for example, a real GitHub Pages repo still pointed at the name) so an attacker can't race you for the binding after a project goes away.
  • Monitor continuously. The window between "name released" and "name claimed" on an active provider can be measured in hours, so a quarterly DNS audit is a lottery ticket against attackers who already won.

Astraeus runs the exact heuristic above on every domain verified through the dashboard, and re-runs it on a schedule rather than only at signup.