FortWatch.ai

How to Detect a Subdomain Takeover: The Provider Fingerprint Reference

FortWatch

FortWatch Team

How to Detect a Subdomain Takeover: The Provider Fingerprint Reference

The OWASP Subdomain Takeover Prevention Cheat Sheet publishes eight provider fingerprints. Six of them — GitHub Pages, Heroku, Shopify, Netlify, Fastly and Zendesk — no longer carry a Vulnerable status on the community list that tracks them, which means six of the eight can get your report closed as not reproducible.

Fastly is the sharpest case, because the two most-cited sources flatly disagree. OWASP rates it High Risk and states the mechanism plainly — "An attacker with a Fastly account can add the victim's domain to their own Fastly service configuration." can-i-take-over-xyz marks the same service Not vulnerable, and records no documentation for that status at all, only an open discussion thread. Its famous string, Fastly error: unknown domain:, fires on every unclaimed hostname either way. The string is real. Whether the takeover is, is contested — and that is exactly the fact no fingerprint table records.

This is the operational companion to our explainer on how a dangling CNAME becomes a takeover; that mechanism is not re-taught here. What follows is the procedure you run against your own zone, and a fingerprint table that tells you which strings still mean anything.

The short version:

  • Four steps, in this order: enumerate the zone, resolve the CNAME target, match the service signature, match the error body. Skipping to the body match is how false positives get filed.
  • Two proof shapes exist — NXDOMAIN on the CNAME target (Azure, Elastic Beanstalk, Discourse) and a live edge returning a claim-me body (S3, Bitbucket, Pantheon). A DNS-only check misses the entire second half.
  • The headline fingerprints have rotted: GitHub Pages, Heroku, Shopify and Netlify are edge cases; Fastly and Zendesk are marked Not vulnerable with no mechanism you can go and read; Google Cloud Storage genuinely does enforce domain verification. Every one of them still emits the famous string.
  • Pin a resolver you trust on every query, probe for a wildcard before you trust your inventory, and check the provider's verification record (asuid TXT, _github-pages-challenge TXT) before filing.
  • NXDOMAIN on a target you cannot attribute to a provider is not automatically low confidence. Run whois on the registrable base domain first — if it has lapsed, the claim path is a registrar checkout and the finding is critical.
  • SERVFAIL or REFUSED from a delegated nameserver is a lame delegation, not a dangling CNAME. It hands over the whole subtree — MX and TXT included — and it is critical the moment you confirm the zone is gone from a provider that lets anyone re-create it.
  • Remediate in order: delete the DNS record, wait out the TTL, then deprovision.

An error page proves the resource is unclaimed, not that you can claim it

The body is a fact about the provider's resource. Claimability is a fact about the provider's ownership policy. Different systems, different teams, and only the second one is a finding — but the scanner can only ever see the first.

Collapsing those two costs you in both directions. Match the string alone and you file criticals whose reproduction step is somebody else's account policy — Shopify, Zendesk, Fastly — and watch them get closed. Trust a list that rotted the other way and you miss live ones: Ghost is still listed as vulnerable, but its unclaimed body now reads Site unavailable., so a scanner grepping for The thing you were looking for is no longer here walks past a claimable ghost.io CNAME.

This is not rare. A 2023 ACM SIGMETRICS measurement study found 10,351 subdomains vulnerable to hosting-based takeover across 2,096 popular apex domains — roughly eight times prior estimates. And it gets weaponized at scale: Guardio Labs' SubdoMailing investigation found roughly 13,000 hijacked subdomains across 8,000+ domains, including MSN, eBay, McAfee and VMware, relaying five million emails a day — mail that cleared authentication because the hijacked subdomains genuinely belonged to those brands.

Step 1: enumerate the zone, and probe for a wildcard first

subfinder -d victim.com is where almost every guide opens, and it is the right first move — against someone else's domain. subfinder is passive: it returns whatever CT logs and third-party datasets happen to know about. You own the zone, and no external dataset knows about the internal-only name nobody remembers creating. An authoritative export does.

# Route 53 — CNAME, NS, MX, plus every alias record (aliases are type A/AAAA)
aws route53 list-resource-record-sets --hosted-zone-id ZID --output json \
  --query "ResourceRecordSets[?Type=='CNAME'||Type=='NS'||Type=='MX'||AliasTarget!=null].\
{Name:Name,Type:Type,Values:ResourceRecords[*].Value,Alias:AliasTarget.DNSName}"

# Cloudflare — one call per type, and page through every one of them
for t in CNAME NS MX; do
  page=1
  while :; do
    resp=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \
      "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=$t&per_page=100&page=$page")
    echo "$resp" | jq -r '.result[] | [.name, .type, .content, .proxied] | @tsv'
    [ "$page" -ge "$(echo "$resp" | jq -r '.result_info.total_pages')" ] && break
    page=$((page+1))
  done
done

# BIND, or any zone you can still transfer
dig AXFR example.com @ns1.example.com

Three details in that first command are load-bearing. ResourceRecords[*].Value rather than [0], because NS and MX record sets are almost always multi-valued and those are the two variants with the biggest blast radius. AliasTarget!=null in the filter, because Route 53 alias records are type A or AAAA — there is no such thing as an alias CNAME — so a filter admitting only CNAME, NS and MX discards every alias record before the AliasTarget.DNSName projection ever runs. That is not cosmetic: an alias pointing at an S3 website endpoint is exactly as takeoverable as the equivalent CNAME, as the remediation section explains, so alias targets go through Steps 2 to 4 alongside your CNAMEs. On the Cloudflare side, the page loop matters the moment a zone crosses 100 records of one type — which is exactly the zone you most need a complete export of.

Pull all three types — OWASP's WSTG-CONF-10 lists the affected records as "A, CNAME, MX, NS, TXT etc." for a reason. CNAME is the common case, handled by Steps 2 through 4; MX is the mail-interception case; NS is the whole-subtree case, and the worst. Coverage is capped by inventory, which is why the tools worth running ingest zones rather than resolving them — dnsReaper reads directly from Route 53, Cloudflare, Azure, BIND files and AXFR. For names outside the zones you exported, pull the list out of Certificate Transparency logs or use our free subdomain finder.

Probe for a wildcard before you trust your inventory

dig +short zz-probe-8471.example.com @1.1.1.1

If a name you invented ten seconds ago answers, your zone has a wildcard and resolution has stopped being able to tell you which names exist. Be precise about what that does and does not break. A wildcard in your zone cannot suppress an NXDOMAIN coming from the provider's zone, so the target checks in Step 2 keep working exactly as written. What it breaks is everything upstream of them: brute-force and passive-DNS inventories start reporting names that were never created, and any scanner that tests whether sub.example.com itself resolves loses the ability to tell a real record from a synthesized one. RFC 4592 sets out when a wildcard declines to synthesize, so some names still answer honestly and you cannot tell which from outside. With a wildcard in the zone, existence comes from the export, never a resolver. It is also a finding in its own right: GitHub warns against wildcard records outright, and is explicit that verifying example.com stops someone using a.example.com but not b.a.example.com — verification does not reach the nested labels a wildcard answers for.

Step 2: resolve the CNAME target — you get one of two proof shapes

Resolve the target, not your own subdomain. Yours will always answer — it has a CNAME sitting on it. The only question is whether the name it points at still exists.

Pin a resolver you trust on every query. A recursive resolver that rewrites NXDOMAIN — ISP search redirection, a captive portal, a filtering or corporate resolver — hands back a synthesized A record instead of the response code, and the entire DNS half of the fingerprint table silently stops firing across your whole zone. Every command below queries 1.1.1.1 explicitly; querying the target's own authoritative nameservers works just as well.

One more visibility caveat, and it is not about rewriting. If the record is Cloudflare-proxied, dig +short CNAME sub.example.com returns Cloudflare's A records and never the origin CNAME — from outside the account the proxy is all you can see. For proxied zones the API export in Step 1 is not one option among several, it is the only way to read the record at all, so run the target check against the content field the API returns.

# Flavor 1 — the DNS layer is the proof
$ dig +short CNAME legacy.example.com @1.1.1.1
legacy-crm-prod.azurewebsites.net.

$ dig +noall +comments legacy-crm-prod.azurewebsites.net @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 41833

Read the status: field — that single token is the finding. NXDOMAIN on the target ends the check. This is the proof shape for providers whose backing resource takes its hostname with it when it dies: Azure across the CNAME suffixes fingerprints.json lists for it, Elastic Beanstalk, and Discourse, all carrying "nxdomain": true. There is no error body to fetch because there is no server left to fetch it from. And it is a hard signal for a specific reason: NXDOMAIN is an authoritative denial issued by the provider's own nameservers, unlike SERVFAIL or a timeout, which are resolver-side failures that tell you nothing about the name. Per RFC 8020, that denial covers the name and everything under it. (No dig? Our DNS lookup tool returns the same response code.)

# Flavor 2 — DNS tells you nothing at all
$ dig +noall +comments example-assets.s3-website-us-east-1.amazonaws.com @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 22107

NOERROR. Nothing is wrong here, and nothing is proven either. The bucket behind that hostname may have been deleted last March; the S3 website edge still resolves because it is shared infrastructure, answering for every bucket in the region from one front door. Same for github.io and *.herokuapp.com. S3, Bitbucket, Ghost and Pantheon carry "nxdomain": false, so the check continues into Steps 3 and 4.

This is the most common coverage gap in home-rolled tooling. A scan that only tests whether the target resolves catches Flavor 1 and misses Flavor 2 completely, so mirror the nxdomain boolean from fingerprints.json rather than inventing a schema. And one thing that is not NXDOMAIN: SERVFAIL and REFUSED point at a different condition entirely, covered below. (If CNAME chains are fuzzy, we cover what each DNS record field actually means separately.)

Step 3: match the service signature before you look at the body

Order these two checks correctly, because a copied list of error strings implicitly teaches the wrong order. Match error bodies first and you fire on every ordinary 404 that resembles a provider's unclaimed page. Match the CNAME target's service signature first and you have scoped the check to a namespace whose claim mechanic you can name — which turns the body match in Step 4 into confirmation rather than discovery.

Nuclei's dns/elasticbeanstalk-takeover.yaml is the published reference: matchers-condition: and across a regex demanding a region-qualified Beanstalk target plus a word matcher for NXDOMAIN. Its own comment explains why — "Only CNAMEs with region specification are hijackable" — a scope limiter applied at detection time rather than at triage time. It also says, bluntly, "Do not report this without claiming the CNAME." OWASP's WSTG-CONF-10 splits its workflow along the same seam: detection produces a hypothesis, validation produces the finding.

The reverse error is subtler, and getting it wrong costs you the most-exploited variant in this post. NXDOMAIN on a target you cannot attribute to any provider is not automatically low confidence. Run one more query before you decide:

# the registrable base domain of the NXDOMAIN target
whois dead-vendor-cdn.net

If that base domain is unregistered, expired, or sitting in pendingDelete or redemptionPeriod, the claim path is a registrar checkout and the finding is critical whether or not a provider signature matched. That is precisely the SubdoMailing mechanic — a CNAME aimed at a third-party domain that later lapsed, which by construction matches no fingerprint and returns NXDOMAIN. marthastewart.msn.com pointed at one for 21 years. Only when the base domain is registered and held by somebody else does the finding drop to lower-confidence DNS debt: a broken record, real cleanup work, no demonstrated claim path.

Step 4: fetch the page and match the error body

# plain HTTP first — several unclaimed edges serve no TLS at all
curl -sik --max-time 10 http://sub.example.com | head -c 4000

# then the same request over TLS
curl -sik --max-time 10 https://sub.example.com | head -c 4000

That order is not stylistic. S3 website endpoints — the first row in the still-claimable table below — have no TLS listener at all. AWS states it flatly: "Amazon S3 website endpoints do not support HTTPS or access points." So https://<bucket>.s3-website-us-east-1.amazonaws.com simply hangs and times out, while plain HTTP returns The specified bucket does not exist. Run HTTPS only against a genuinely dangling S3 CNAME and you get a connection failure and file nothing. OWASP's documented command is curl -i http://subdomain.victim.com for the same reason.

Two flags are absent on purpose. No -L: an unclaimed edge that redirects to the provider's marketing or login page hands you a body with no fingerprint in it at all, and you conclude there is nothing there — a redirect off the host is itself a signal, so follow it deliberately in a second command rather than silently in the first. And head -c rather than head -20, because -i puts ten to fifteen lines of headers in front of the body and the claim-me string usually sits well below line 20. If you already know which string you are hunting, skip the eyeballing entirely: curl -sik http://sub.example.com | grep -F 'The specified bucket does not exist'.

The -k is deliberate but narrow. It suppresses certificate validation errors, which are the expected condition on a takeover-pending edge — the provider has no certificate for a name nobody has claimed. It does nothing for a handshake refusal, an unknown-SNI reject or a connect timeout. Those mean the edge serves no TLS for unclaimed names at all, and the correct response is to fall back to plain HTTP, not to a no-finding.

Now match the string exactly as fingerprints.json stores it: punctuation, capitalization, trailing period. GitHub Pages publishes There isn't a GitHub Pages site here. with the period, and a case-sensitive match against a half-remembered paraphrase will silently never fire — worse than noise, because nobody investigates a clean report.

Two caveats the "match it verbatim" instruction hides. First, the file mixes literal strings with regexes: WordPress.com's entry is Do you want to register .*.wordpress.com?, ngrok's is Tunnel .*.ngrok.io not found, and Ghost's is an alternation stored as Site unavailable\.|Failed to resolve DNS path for this host — where \. is an escaped literal period, not a wildcard. Grep any of those literally and you match nothing, ever. The Match column in the tables below flags which is which, and prints the regex rows with their escapes intact. Second, bodies encode punctuation inconsistently: an apostrophe may arrive as ', as &#39;, or as a typographic , so when you are matching against raw HTML, anchor on the longest apostrophe-free substring. Copy your strings out of fingerprints.json, never out of a blog post, including this one.

Tooling, named honestly: subzy run --targets subdomains.txt; nuclei -l subdomains.txt -t http/takeovers/ -t dns/ — include the DNS path or you exclude the entire NXDOMAIN half of this methodology, the Elastic Beanstalk template above included; dnsReaper (50+ signatures, with --disable-probable and --enable-unlikely as explicit confidence tiers, plus NS-layer signatures); and BadDNS, one of the few that also checks dangling NS records, registerable MX base domains and hijackable domains inside TXT and SPF chains. All of them produce false positives. The tool hands you a lead; the claim path gives you a finding. EdOverflow's reporting standard closes it: hold the finding until the takeover can be reliably demonstrated, then demonstrate it by claiming the subdomain discreetly and serving a harmless file on a hidden page, never the index page.

The NS and MX variants: same bug, bigger blast radius

Everything above tests one name. An NS delegation tests an entire subtree: whoever answers authoritatively for sub.example.com controls every A, MX and TXT record beneath it. OWASP ranks it above the CNAME case for that reason — "NS subdomain takeover (although less likely) has the highest impact, because a successful attack could result in full control over the whole DNS zone and the victim's domain."

$ dig NS sub.example.com +short @1.1.1.1
ns1.digitalocean.com.

$ dig @ns1.digitalocean.com sub.example.com SOA +norec +noall +comments
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 41522
;; flags: qr; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

An authoritative answer is healthy; anything else is not. But a response code only means something once you say where you were standing when you got it — and the version of this table that circulates gets that wrong by filing codes under vendor names. SERVFAIL is not an AWS signature and REFUSED is not a DigitalOcean one; query a Route 53 nameserver directly for a zone it does not hold and you get REFUSED, byte-identical to DigitalOcean. Sort by vantage point instead:

Where you queriedResponseWhat it meansNext step
A recursive resolverSERVFAILThe resolver could not get an answer: every delegated nameserver refused or was unreachable, or DNSSEC validation failed. Not a vendor signature, and not a finding on its own.Re-query each delegated nameserver directly with +norec. Whatever they say is the actual evidence.
A delegated nameserver, directlyREFUSEDLame delegation. The server is delegated for the name but holds no zone for it. Route 53 and DigitalOcean both answer this way.Check whether the hosted zone still exists in your account; remove the parent delegation if it does not.
A delegated nameserver, directlyNOERROR with no aa flagAlso lame — it answered, but not authoritatively, so the delegation points at a server that does not own the zone. Read the flags: line, not just status:.Same fix.
A CNAME targetNXDOMAINBacking resource deprovisioned; per RFC 8020 the name and everything under it does not exist.Run the CNAME loop; provider policy — or a whois on the base domain — decides claimability.

The mechanic is unglamorous: the zone was deleted inside the managed DNS provider's account, nobody removed the delegation at the parent, and an attacker creates a zone of the same name in their account and starts answering. No registrar login, no password reset. Infoblox and Eclypsium's Sitting Ducks research puts the scale at "over a million exploitable target domains on any given day," with the vector used "to hijack over 35k domains since 2018."

MX is the quieter variant. Microsoft states it plainly: "MX records could enable threat actors to receive emails directed to legitimate subdomains associated with trusted brands." Check the mail path the same way — dig MX sub.example.com +short, then whois each base domain to see whether it is registerable — and audit your SPF include: chain for the same failure. That pairing was the SubdoMailing amplifier: one engineered SPF record authorised 17,826 sending IPs, assembled through a nested include: chain stacked to exactly ten included domains — the maximum RFC 7208 permits before a receiver should stop evaluating. Not an overflow. A record built right up to the edge of the limit precisely so it would keep being evaluated.

Severity here follows the same evidence bar the rest of this post applies, not the response code. A lame delegation is critical when the delegation points at a managed DNS provider that lets anyone create a zone for that name and you have confirmed the zone is gone from your account — that is the Sitting Ducks condition, and the blast radius really is the whole subtree. It is high pending that confirmation, because a dead self-hosted BIND, a firewalled or rate-limited nameserver, a DNSSEC failure and a thirty-second outage all produce the same rcodes with no claim path behind any of them. And the honest part: takeover tooling is overwhelmingly built around the CNAME case, ours included — FortWatch's scanner resolves CNAMEs and does not walk NS delegations, so run the two commands above by hand against the NS records you exported in Step 1.

The subdomain takeover fingerprint reference (with the column everyone leaves off)

Statuses verified against can-i-take-over-xyz fingerprints.json on 1 August 2026.

Five of the six columns below exist in a dozen other places. The sixth is the only reason to keep this open in a tab: provider, pattern, proof, match type and string tell you a resource is unclaimed, while Status & why tells you whether anybody can claim it. Read the Proof column first — where it says DNS, the response code is the whole finding; where it says HTTP, the target resolves to a live shared edge and only the body gives it away.

Still claimable

ProviderCNAME patternProofMatchExact stringStatus & why
Amazon S3s3.amazonaws.com, s3-websiteHTTPliteralThe specified bucket does not existAnonymous re-claim, global namespace
AWS Elastic Beanstalk*.<region>.elasticbeanstalk.comDNSn/aNXDOMAINRegion-qualified hostname is first-come; non-regional CNAMEs are not hijackable
Azure App Serviceazurewebsites.netDNSn/aNXDOMAINBlocked by an asuid.{subdomain} TXT record; without one the resource name is re-creatable in any subscription
Azure Front Doorazurefd.netDNSn/aNXDOMAINDifferent challenge — Front Door uses a _dnsauth TXT record, not asuid
Azure Cloud Servicescloudapp.net, cloudapp.azure.comDNSn/aNXDOMAINName is reserved after deletion, then released — Microsoft: "after the reservation period, any Azure subscription can claim the DNS name"
Azure Blob, CDN, Traffic Manager, API Management, Container Instancesblob.core.windows.net, azureedge.net, trafficmanager.net, azure-api.net, azurecontainer.ioDNSn/aNXDOMAINNo ownership challenge that survives deprovisioning — Blob's asverify and CDN's cdnverify CNAMEs are zero-downtime pre-registration steps, not proof of ownership, so an asuid record protects nothing here
Discoursetrydiscourse.comDNSn/aNXDOMAINVulnerable, and one of only two entries in the file with cicd_pass: true — the best-verified row here
Bitbucketbitbucket.ioHTTPliteralRepository not foundRepository slug re-creatable by any account
WordPress.comwordpress.comHTTPregexDo you want to register .*.wordpress.com?Free signup binds the hostname, no ownership check
Surge.shna-west1.surge.shHTTPliteralproject not foundUnclaimed project name binds on publish
Pantheonpantheonsite.ioHTTPliteral404 error unknown site!Site name first-come after deletion
Ghostghost.ioHTTPregex (alternation)Site unavailable\.|Failed to resolve DNS path for this hostCustom domain attaches with no DNS proof; string changed, older lists are stale
Strikinglys.strikinglydns.comHTTPliteralPAGE NOT FOUND.Custom domain mapping is self-service
Uberflipread.uberflip.comHTTPliteralThe URL you've accessed does not provide a hub.Hub domain mapping unverified
Help Scouthelpscoutdocs.comHTTPliteralNo settings were found for this company:Docs domain set account-side, no DNS challenge
JetBrains YouTrackyoutrack.cloudHTTPliteralis not a registered InCloud YouTrackInstance name reusable once released
ngrokngrok.ioHTTPregexTunnel .*.ngrok.io not foundReleased reserved tunnel name is re-bindable
Read the Docsreadthedocs.ioHTTPliteralThe link you have followed or the URL that you entered does not exist.Project slug is first-come

Three notes on that table. Azure gets five rows rather than one because asuid is App Service verification and nothing else — Front Door uses a separate _dnsauth challenge, cloudapp.net is governed by a reservation that expires, and Blob, CDN, Traffic Manager, API Management and Container Instances have nothing that outlives the resource. A single "claimable unless asuid is present" row would tell someone with a dangling blob.core.windows.net CNAME that they are protected when they are not. The trafficmanager.net and azurefd.net suffixes come from Microsoft's own Get-DanglingDnsRecords coverage rather than from the fingerprints file. And DigitalOcean is not a row here: its signature is an NS-layer condition, covered above.

AWS shipped account-regional S3 bucket namespaces in March 2026, but they are opt-in at bucket creation, so every bucket already sitting in the global namespace stays there — and stays reclaimable the moment it is deleted, which is how public S3, GCS and Azure buckets leak data on top of the takeover.

Azure earns its five rows for a reason: in 2020, researchers found more than 670 Microsoft-owned subdomains claimable because Azure required no proof of domain ownership. Microsoft's control for this class is the asuid.{subdomain} TXT record — "when such a TXT record exists, no other Azure subscription can validate the custom domain or take it over." That is this whole post in one artifact: the error body never changed, the ownership policy did.

Edge cases

Where fingerprints.json records an Edge case, the status is a pointer to an argument, not a conclusion — and the argument lives in a linked issue thread that almost nobody opens. Shopify's is Issues #32 and #46 plus a linked writeup; Netlify's is Issue #40 with no documentation attached; Webflow's is Issue #44 plus a Webflow forum thread. Read the thread, or test the claim path in an account you control. Inheriting the boolean without doing either is how the list rots in the first place.

ProviderCNAME patternProofMatchExact stringStatus & why
GitHub Pagesgithub.ioHTTPliteralThere isn't a GitHub Pages site here.vulnerable: false — verification is optional and unenforced, so an unverified org is still exposed; a verified apex covers immediate subdomains but not nested labels
Herokuherokuapp.com, herokudns.comHTTPliteralNo such appNo ownership verification, but global exclusivity plus randomized targets leave one residual case: a deleted, globally unique app name
Shopifymyshopify.comHTTPliteralSorry, this shop is currently unavailable.Edge case; reasoning in Issues #32 and #46 — read it or test it
Netlifynetlify.app, netlify.comHTTPliteralNot Found - Request ID:Edge case, Issue #40, no documentation attached; site names are account-scoped and custom domains take a TXT challenge — test it
Webflowproxy-ssl.webflow.comHTTPliteralThe page you are looking for doesn't exist or has been moved.Edge case, Issue #44 plus a Webflow forum thread — and the string is generic enough to match an ordinary Webflow 404

"Edge case" is not a shrug, it is a precise state: the string fires, and whether the reclaim works depends on a condition you have to go and check. For GitHub Pages that condition is verification, shipped 1 November 2021 to close takeover — but GitHub is blunt that you are still exposed "when you delete your repository, when your billing plan is downgraded, or after any other change which unlinks the custom domain… and is not verified," so check for the _github-pages-challenge-USERNAME TXT record before deciding. For Heroku the condition is name uniqueness — "a given domain can be added to only a single Heroku app" — and since default hostnames were randomized on 14 June 2023, the surviving path is a genuinely deleted app name.

Marked not vulnerable — two proven, two asserted

ProviderCNAME patternProofMatchExact stringStatus & why
Google Cloud Storagec.storage.googleapis.comHTTPliteral<Code>NoSuchBucket</Code><Message>The specified bucket does not exist.</Message>Not vulnerable, and the control is documented — a domain-named GCS bucket requires verified domain ownership
OktaOkta custom domainn/an/an/aNot vulnerable — ownership proven by a randomly generated string in a DNS TXT record. Per EdOverflow's guide, not the fingerprints file, which has no Okta entry
Fastlyfastly.netHTTPliteralFastly error: unknown domain:Marked Not vulnerable (Issue #22, no documentation); OWASP rates the same service High Risk with the opposite mechanism. Contested — test the claim path before filing either way
Zendeskzendesk.comHTTPliteralHelp Center ClosedMarked Not vulnerable, with Zendesk's host-mapping documentation linked as the basis — read it rather than trusting the boolean

This tier is the false-positive factory. Every string here is current and will match; whether the claim fails is an account-side policy question. Two of the four have a control you can go and read — Google requires domain verification for domain-named buckets, Okta proves ownership with a random TXT string. The other two are statuses rather than mechanisms, and Fastly's is actively disputed by OWASP, so treat both as unproven until you have tried the claim in an account you control.

The GCS row is the cleanest demonstration in the reference — near-identical wording to S3, separated by exactly the trailing period Step 4 told you to care about (…does not exist. against S3's …does not exist), with the S3 body additionally carrying BucketName and RequestId elements that GCS omits. One tier apart, purely because Google requires domain verification and AWS's legacy global namespace does not.

One disclosure on the columns, extending the Azure note above to the whole reference. Status and Exact string come from EdOverflow's can-i-take-over-xyz fingerprints.json. The CNAME pattern column does not — the file's cname array is empty for Fastly, Zendesk, Shopify, Netlify, Webflow, GitHub Pages, Heroku, Google Cloud Storage, Pantheon and Read the Docs, and lists only s3.amazonaws.com for S3 — so those patterns are compiled from provider documentation and our own scanner. If you are automating against them, that is the column to re-derive yourself.

And the caveat most tables omit: the list rots. Detectify named at least 17 susceptible providers in October 2014 and 100+ by 2017; can-i-take-over-xyz tracked 76 as of September 2024. This is an August 2026 snapshot. Providers and patterns age slowly, strings age faster, and the status column is what actually rots — a provider ships verification on a Tuesday and every list on the internet is wrong by Wednesday, with no error message changing to warn you.

What severity is a subdomain takeover?

Severity comes from the trust the parent domain carries, not from the content of the forgotten subdomain. Nobody visits the abandoned marketing microsite; everybody trusts the hostname. What to write on the ticket:

  • Confirmed fingerprint on a still-claimable provider: CRITICAL once the claim path is confirmed — a live, TLS-valid page on your own domain, every session cookie set with Domain=example.com (which covers every subdomain, the hijacked one included), and whatever your OAuth redirect allowlists and CORS rules extend to it. HIGH while the fingerprint is matched but the claim path is untested. (Nuclei scores the Elastic Beanstalk case CVSS 7.2; a scope-limited CVSS vector systematically undercounts what the parent domain's trust is worth.)
  • NS or lame-delegation takeover: CRITICAL once the claim path is confirmed — a managed provider that permits anonymous zone creation, and the zone confirmed gone from your account. That hands over the whole subtree, MX and TXT included. HIGH until you have confirmed both. The class files under A05:2021 Security Misconfiguration.
  • Provider ownership verification present: not a takeover finding — file it as low-severity DNS cleanup debt instead, and re-check severity if the challenge record ever disappears. Query the challenge record before you assign severity, not after.
  • Unattributed NXDOMAIN on a CNAME target: whois decides — an expired or unregistered base domain is CRITICAL, because the claim path is a registrar checkout. A base domain that is registered and held by a third party is lower-confidence DNS debt.

And never downgrade because the subdomain "has SSL." Microsoft's own wording: "a threat actor can use the hijacked subdomain to apply for and receive a valid SSL certificate," which "grant[s] them access to secure cookies." The certificate is the attacker's, not your protection.

Subdomain takeover false positives: four ways a confirmed fingerprint misleads you — and one way you never get one

1. Provider pre-verification

The mechanic behind most bad takeover reports, and one DNS query away from being ruled out.

dig _github-pages-challenge-USERNAME.example.com TXT +nostats +nocomments +nocmd @1.1.1.1
dig asuid.status.example.com TXT +short @1.1.1.1

Where a provider binds a hostname to a proven owner, re-creating the resource name gains an attacker nothing. Microsoft's dangling-DNS guidance puts it plainly: "without the ability to prove ownership of the domain name, bad actors can't receive traffic or control the content." The query works in both directions — a challenge record present kills the finding, one absent on a provider that offers verification upgrades yours from plausible to real.

2. Wildcard masking, and what it actually masks

Described backwards nearly everywhere. A *.example.com record cannot suppress the NXDOMAIN in Step 2 — that response comes out of the provider's zone and nothing you publish under your own touches it. What a wildcard corrupts is the inventory upstream of the check, which is why the probe lives in Step 1 and not here. If you skipped it, your list of names is fiction and nothing downstream is worth triaging.

3. NXDOMAIN that isn't dangling

A target can be unresolvable and still be nobody's to claim. The commonly cited example is Azure Traffic Manager: a profile with zero endpoints configured returns NXDOMAIN while the profile is alive and in your own subscription, indistinguishable on the wire from a deprovisioned resource. We have not reproduced that specific case, and the general rule is what matters anyway — a target inside a namespace nobody can register. AWS is explicit that VPCs, EC2 instances and private hosted zones are not exposed to globally claimable namespaces. Unresolvable does not mean vulnerable, and the whois check in Step 3 is what separates the two.

4. Generic fingerprint strings

Any string an ordinary error page could produce is a lead. 404 Not Found matches a stock nginx page, a misconfigured proxy, or an ingress with no backing service. Worse than generic is transient: a bare 500 body like Error: Server Error fires on a healthy application having a bad thirty seconds, so re-running the probe an hour later is often the whole investigation.

5. A resolver that rewrites NXDOMAIN

The other four cost you a bad ticket. This one costs you the entire DNS half of the table, and it fails clean: NOERROR on every target, nothing errors, and you conclude that Flavor 1 simply does not apply to your estate. Sanity-check the resolver once against a name you know is dead before you believe a single NOERROR in this post.

One rule covers the four false positives: the last mile is the claim path. Attempt the claim in an account you control, or read the provider's documented ownership policy, before you assign severity. The false negative has a rule of its own — never run this loop through a resolver you have not tested.

Worked example: reading one ambiguous result end to end

$ dig +short CNAME docs.example.com @1.1.1.1
example-org.github.io.

$ dig +noall +comments example-org.github.io @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41827

$ curl -sik --max-time 10 http://docs.example.com | head -c 4000
HTTP/1.1 404 Not Found
<p>There isn't a GitHub Pages site here.</p>

Three signals aligned: a CNAME at a provider with a documented takeover history, a resource that is not serving, and a famous fingerprint matched character for character. Most tools stop here and file critical — even though the community list carries that entry as Edge case, vulnerable: false. It is a confident wrong answer, and one more query settles it.

$ dig _github-pages-challenge-example-org.example.com TXT +nostats +nocomments +nocmd @1.1.1.1
_github-pages-challenge-example-org.example.com. 3600 IN TXT "6f2a1c9e4b7d0835ac11"

The apex is verified to that organization, verification is inherited by its immediate subdomains, and no other GitHub account can bind docs.example.com. The error string is still there; the takeover is not. What you file is a broken CNAME to a deleted Pages site — real cleanup debt, low severity, no exploitability claim. Two things flip it back to critical: no challenge record present, or the organization dropping verification later. The extra query cost five seconds and moved the severity three levels.

How we built the same loop into FortWatch's takeover scanner — and where ours is wrong

Those four steps are not a teaching device; they are our code, in order. The scanner enumerates — every domain in the asset inventory it runs against, fed to dns.resolveCname — then resolves the target (dns.resolve4), matches the service signature (case-insensitive substring match over 17 providers), and matches the error body (HTTPS first with an HTTP fallback, 10-second timeout, first 10KB only, certificate errors ignored, then a case-sensitive body.includes).

One part of that is genuinely conservative and worth stating. A CNAME pointing at github.io or herokuapp.com is not a finding on its own: critical requires NXDOMAIN on the target plus a recognized service, or a resolving target plus the fingerprint string actually present in the body. NXDOMAIN on an unrecognized target files HIGH instead, and only ENOTFOUND counts as dangling — SERVFAIL and timeouts deliberately do not, the same reasoning as the NS table above.

Now the part that matters more, because this post spent five thousand words on it. Eight of our seventeen signatures are providers can-i-take-over-xyz marks Not vulnerable or Edge case — GitHub Pages, Heroku, Shopify, Netlify, Tumblr, HubSpot, Fastly and Zendesk — and when the body match hits on any of them we file critical, named Subdomain Takeover Possible via <service>, with no verification-record lookup anywhere in the file. That is our own instance of the exact mistake this post opens by criticising OWASP for, and publishing without saying so would hand you a one-line rebuttal to the whole article. The ticket is open: gate those providers behind a distinct "verify the claim path" template at a lower severity, and query asuid and _github-pages-challenge before anything gets called critical.

Three smaller ones. Three of the seventeen fingerprints are generic strings that cannot distinguish an unclaimed resource from an ordinary error page: Fly.io's 404 Not Found is the literal HTTP reason phrase, Unbounce's The requested URL was not found is stock framework text, and our Google Cloud entry matches Error: Server Error — a generic server-error body, not an unclaimed-resource body. All three fail the "fingerprints, not generic words" bar we hold every other scanner rule to. Our Ghost fingerprint is The thing you were looking for is no longer here, the exact string fingerprints.json has since replaced, so a claimable ghost.io CNAME walks straight past us today — this post's own stale-list example, shipping in our own code. And our probe only falls back to plain HTTP when HTTPS fails to connect: anything that answers on 443, including an unrelated 403 or a parked-page holder, ends the probe before we ever see the plain-HTTP body an S3 website endpoint would have returned.

Coverage gaps on top of that: one CNAME hop rather than a chain, no NS-delegation detection, no wildcard handling. The value was never a bigger list — it is running the loop on every scan against every discovered asset. The list is maintenance debt, ours very much included, and now you know exactly which of our rows to distrust.

Remediation, in the order that actually closes the window

Most teams decommission a service by deleting the resource and getting to the DNS record later. That order is the vulnerability. AWS documents the reverse: delete the CNAME record; wait for the DNS TTL to expire; deprovision the resource; re-run a DNS check on the removed record to verify. And "verified" does not mean it 404s from your laptop — resolver caches and out-of-sync secondaries outlive the delete, so query each authoritative nameserver directly with dig @<each-ns> sub.example.com CNAME.

When the CNAME still needs to exist, you have two different controls and they are not interchangeable. Bind ownership — Azure's asuid TXT, a GitHub verified domain, the Netlify or Vercel challenge — or constrain the target. Azure DNS alias records genuinely couple lifecycle: Microsoft documents that deleting the Front Door, Traffic Manager profile, CDN endpoint or Public IP leaves "an empty record set." Route 53 alias records carry no equivalent published guarantee, and one case cancels the benefit outright: an alias pointing at an S3 website endpoint resolves against a globally claimable bucket name, so it is exactly as takeoverable as the equivalent CNAME. Alias is not a fix for the top row of the table above.

Why "audit quarterly" is indefensible

Make the frequency argument with numbers. In 2025, watchTowr Labs re-registered roughly 150 abandoned S3 buckets for about $400, served nothing, and logged more than 8 million HTTP requests in two months — software updates, VM images, CloudFormation templates — from NASA, military, government and Fortune 500 networks. That measures one thing precisely: how fast abandoned namespace attracts real, trusting traffic. A quarterly sweep leaves a 90-day window in which whoever claims the name inherits all of it — and once taken, it sits. Hazy Hawk was found in February 2025 via CDC subdomains, with activity traced to at least December 2023. Certificate Transparency is the second continuous signal: RFC 6962 makes issuance publicly auditable, so a certificate for a subdomain you never requested is evidence someone else controls that name.

Subdomain takeover detection FAQ

Is an error page proof of a subdomain takeover?

No — and the error fails in both directions. Google Cloud Storage returns its famous NoSuchBucket body while enforcing domain verification for domain-named buckets, so the string fires and the reclaim fails. Ghost fails the other way: it is still listed as vulnerable, but the unclaimed body now reads Site unavailable., so a scanner grepping for the old string walks straight past a claimable ghost.io CNAME. The body tells you a resource is unclaimed. The provider's ownership policy tells you whether that is a finding.

Is GitHub Pages still vulnerable to subdomain takeover?

Only when the domain is unverified. GitHub shipped domain verification on 1 November 2021 using a _github-pages-challenge-USERNAME TXT record, and a verified apex also covers its immediate subdomains — though not nested labels beneath them. Verification is optional and unenforced, so unverified organizations remain exposed and the error string alone proves nothing either way.

Is Fastly still vulnerable to subdomain takeover?

Unsettled, and the two authorities disagree. can-i-take-over-xyz marks Fastly Not vulnerable and documents no mechanism for that status; OWASP's own cheat sheet still rates it High Risk and describes the opposite — an attacker adding the victim's domain to their own Fastly service configuration. If Fastly error: unknown domain: fires on your zone you have a real problem either way: a CNAME pointing at a service you no longer run. Delete the record. Before you file it as a takeover, test the claim path in an account you control, because the reproduction step is what your report will be judged on.

Subdomain takeover detection checklist

  1. Export your zones authoritatively — Route 53, the Cloudflare API, a BIND file, an AXFR — and list every CNAME, MX and NS record plus every alias record, with all values, paged to the end. Route 53 aliases are type A/AAAA, so a CNAME-only filter silently drops them.
  2. Probe for a wildcard first with dig +short zz-probe-8471.example.com @1.1.1.1. If it answers, your inventory can only come from the export.
  3. Pin a resolver on every query, and confirm it does not rewrite NXDOMAIN before you trust a single NOERROR.
  4. Resolve every CNAME and alias target and read the header status:. NXDOMAIN on the target is DNS-layer proof; a resolving target means you owe it an HTTP request. For Cloudflare-proxied records, read the target off the API export — the resolver only shows you the proxy.
  5. Match the service signature before the body, so the body match confirms rather than discovers.
  6. Match the error body with curl -sik --max-time 10, HTTP first and then HTTPS — several unclaimed edges serve no TLS at all. No -L, and use head -c or grep -F rather than head -20. Exact punctuation, exact case, and check whether the entry is a literal or a regex. Any string an ordinary 404 could produce is a lead, not proof.
  7. Check the status column before you file. If the provider has a documented ownership control, the string is not a finding; if the status has no mechanism behind it, treat it as unproven in both directions.
  8. Query the verification record that overturns the confident wrong answer — _github-pages-challenge-USERNAME TXT, asuid.{subdomain} TXT, Front Door's _dnsauth, the Okta or Netlify challenge — and add it wherever it is missing.
  9. Run whois on the registrable base domain of every unattributed NXDOMAIN target. Expired, unregistered or in redemption is critical, not DNS debt.
  10. Test NS delegations separately: dig NS sub.example.com +short, then dig @<delegated-ns> sub.example.com SOA +norec against each, reading the aa flag as well as the status. REFUSED, or NOERROR without authority, is a lame delegation — confirm the zone is gone from your account and treat it as critical.
  11. Audit MX records and SPF include: chains for vendor domains that have expired and can be re-registered.
  12. Prove exploitability the accepted way: claim the resource discreetly, serve a harmless file on a hidden page, never the index page.
  13. Fix in the safe order — delete the record, wait out the TTL, then deprovision — and verify it is gone from every authoritative nameserver. Then finish the zone with the wider DNS hygiene checklist.

None of this is difficult. It is just relentless, and the deprovision-to-claim window is the one part a person cannot cover on a calendar. FortWatch runs these four steps against every asset it discovers on every scan — CNAME only, with the eight over-severity providers and four stale fingerprints above already on the fix list — so a record that goes dangling on a Friday afternoon becomes a ticket rather than a quarter-long exposure. NS delegations are still two commands you run by hand. You can have it alert you the moment a CNAME starts dangling, or just run the commands above against your own zone today. Either way, the checklist is one afternoon of work, and about five seconds per record after that.

Share this post
Get started

Ready to secure your infrastructure?

Try for free — scan your entire attack surface in under 5 minutes. No credit card required.

  • No credit card required

  • 14-Day free trial

Ready to secure your stack?

Secure your entire stack today

Start scanning in under 5 minutes. No credit card required. 14-day free trial included.