How I Built a Subdomain Takeover Scanner in Python (The Dangerous Ones Don't Resolve)

python dev.to

Every subdomain takeover scanner I've used starts from the same place: enumerate what resolves, then check what it points at. That ordering is backwards. The subdomain you need to worry about is the one that stopped resolving eight months ago, when somebody deleted a Heroku app and left the CNAME behind.

A live scanner never sees it. It's not in your wordlist hits, it's not in your cert transparency pull, and it doesn't answer DNS. It sits there until someone re-registers checkout-staging-v2 on Heroku and starts serving whatever they want from checkout-staging.yourbank.com.

So I built the scanner the other way round. Pull the historical subdomain set first, including records that went dark. Then resolve.

The key insight

Subdomain takeover has two halves, and most tooling only does one well. The DNS half is easy: follow the CNAME, see if the target is gone. The discovery half is where everything falls apart, because brute-force and passive DNS both bias hard toward what's alive right now. Certificate transparency helps, but only for names somebody bothered to issue a cert for, and plenty of internal staging hosts never got one.

What I wanted was a list of every hostname that has ever existed under a domain, with dates attached, so I could sort by "last seen" and start at the bottom. That's exactly what the WhoisFreaks Subdomains API returns. Each record carries first_seen, last_seen, and inactive_from, plus a status filter you can set to inactive to get only the dead ones. Full details in the WhoisFreaks Subdomains API docs.

The dates are the part that matters. A subdomain last seen in March that still has a CNAME in your zone file is a strong lead, not a curiosity.

Setup

git clone https://github.com/WhoisFreaks/wf-subdomain-takeover.git
cd wf-subdomain-takeover
pip install -r requirements.txt          # requests, dnspython
export WHOISFREAKS_API_KEY="your_key"
python takeover_scan.py example.com --csv results.csv
Enter fullscreen mode Exit fullscreen mode

Free tier is 500 credits, which is plenty for a few domains. Two dependencies, no Docker, no config file.

Step 1: Pull the full subdomain history

The endpoint is GET /v1.0/subdomains and it pages. total_pages comes back on the first response, so you loop until you've drained it. I added a rate limiter because the free tier caps requests per minute and I'd rather sleep 6 seconds than eat a 429.

API_BASE = "https://api.whoisfreaks.com/v1.0/subdomains"

def fetch_subdomains(domain, api_key, status=None, max_pages=50, rpm=10):
    """Page through /v1.0/subdomains and return every record."""
    session = build_session()
    limiter = RateLimiter(rpm)
    records, page = [], 1

    while page <= max_pages:
        params = {"apiKey": api_key, "domain": domain, "page": page}
        if status:
            params["status"] = status

        limiter.wait()
        resp = session.get(API_BASE, params=params, timeout=25)
        if resp.status_code != 200:
            sys.stderr.write(f"[!] page {page} returned HTTP {resp.status_code}\n")
            break

        payload = resp.json()
        batch = payload.get("subdomains") or []
        records.extend(batch)

        total_pages = int(payload.get("total_pages") or 1)
        if page >= total_pages or not batch:
            break
        page += 1

    return records
Enter fullscreen mode Exit fullscreen mode

Each record looks like this:

{"subdomain":"staging-api.example.com","first_seen":"2021-08-04","last_seen":"2025-11-19","inactive_from":"2025-11-20"}
Enter fullscreen mode Exit fullscreen mode

Pass --status inactive and you get only the records the index has marked decommissioned. On the domain I tested that filter was exactly right, so it's a reasonable shortlist. I'd still pull everything and resolve it yourself, partly because DNS is cheap and mostly because you want the live hosts in the same table to compare against.

Step 2: Walk the CNAME chain properly

This is where I lost the most time. A single resolve(host, "CNAME") isn't enough, because chains nest. blog.example.comexample.netlify.appapex-loadbalancer.netlify.com is three hops, and the interesting answer lives at the end.

You also have to separate outcomes that look identical from the outside: no CNAME at all, a CNAME pointing somewhere alive, a CNAME pointing at a name that NXDOMAINs, and a lookup that simply failed. Only the third is a dangling record.

def resolve_chain(resolver, host, depth=6):
    """Walk CNAMEs until an A record, NXDOMAIN, or the depth cap.

    Returns (chain, resolves_to_address, final_target_nxdomain, state) where
    state is live / nxdomain / no_records / error.
    """
    chain, current = [], host

    for _ in range(depth):
        try:
            answer = resolver.resolve(current, "CNAME")
            target = str(answer[0].target).rstrip(".")
            chain.append(target)
            current = target
            continue
        except dns.resolver.NoAnswer:
            pass                      # no CNAME here, try A below
        except dns.resolver.NXDOMAIN:
            return chain, False, bool(chain), "nxdomain"
        except (dns.resolver.NoNameservers, dns.exception.Timeout):
            return chain, False, False, "error"

        try:
            resolver.resolve(current, "A")
            return chain, True, False, "live"
        except dns.resolver.NXDOMAIN:
            return chain, False, bool(chain), "nxdomain"
        except dns.resolver.NoAnswer:
            return chain, False, False, "no_records"
        except (dns.resolver.NoNameservers, dns.exception.Timeout):
            return chain, False, False, "error"

    return chain, False, False, "error"
Enter fullscreen mode Exit fullscreen mode

bool(chain) is doing real work in those NXDOMAIN branches. If the chain is empty, the host itself just doesn't exist and that's boring. If the chain has entries, something in your zone is pointing at a name that no longer exists, and that's the whole game.

That fourth return value isn't how I wrote it the first time. The original collapsed no_records and error into the same "didn't resolve" boolean, which seemed fine until I counted results on a real domain. More on that below.

Step 3: Fingerprint the service

A dangling CNAME only matters if the service on the other end lets a stranger claim the hostname. So I keep a table modelled on the classifications in can-i-take-over-xyz, with three values instead of a boolean.

FINGERPRINTS = [
    {
        "service": "AWS S3",
        "cnames": ["s3.amazonaws.com", "s3-website"],
        "bodies": ["NoSuchBucket", "The specified bucket does not exist"],
        "vulnerable": "yes",
        "note": "bucket name must still be free in the same region",
    },
    {
        "service": "GitHub Pages",
        "cnames": ["github.io", "githubusercontent.com"],
        "bodies": ["There isn't a GitHub Pages site here"],
        "vulnerable": "edge",
        "note": "GitHub has required domain verification since 2021; older orgs can still be exposed",
    },
    {
        "service": "Zendesk",
        "cnames": ["zendesk.com"],
        "bodies": ["Help Center Closed", "this help center no longer exists"],
        "vulnerable": "no",
        "note": "Zendesk blocks re-registration of released subdomains",
    },
    # ...17 services total in the repo
]
Enter fullscreen mode Exit fullscreen mode

That "no" on Zendesk is the reason for the three-way split. help.figma.com CNAMEs to figma.zendesk.com, and a naive scanner that treats every known-service CNAME as a hit will flag it. Nobody's taking that over. Flagging it trains people to ignore your tool, which is worse than not having one.

Step 4: Confirm over HTTP

DNS gets you a lead. The HTTP body gets you proof. Wildcards are why: *.github.io resolves, so a deleted GitHub Pages site never NXDOMAINs and the DNS layer tells you nothing. You have to ask the service.

I checked both of these against live endpoints while writing this:

wf-takeover-demo-9xk22.s3.amazonaws.com  -> HTTP 404, "NoSuchBucket"
wf-takeover-demo-9xk22.github.io         -> HTTP 404, "There isn't a GitHub Pages site here"
Enter fullscreen mode Exit fullscreen mode
def probe_http(session, host, fp, timeout=12):
    for scheme in ("https", "http"):
        try:
            resp = session.get(f"{scheme}://{host}", timeout=timeout, allow_redirects=True)
        except requests.RequestException:
            continue

        body = resp.text[:20000]
        if fp:
            for needle in fp["bodies"]:
                if needle.lower() in body.lower():
                    return resp.status_code, needle
        return resp.status_code, ""
    return None, ""
Enter fullscreen mode Exit fullscreen mode

Note it tries HTTPS first and falls back to HTTP. A lot of abandoned hosts have a dead cert, and if you only speak HTTPS you'll miss them entirely.

Step 5: Score it

Four verdicts, ordered by how much of your evening they deserve:

def classify(finding, fp):
    if fp:
        finding.service, finding.note = fp["service"], fp["note"]

    if finding.target_nxdomain:
        finding.verdict = "DANGLING"
    elif finding.matched_body:
        finding.verdict = "FINGERPRINT" if fp and fp["vulnerable"] != "no" else "REVIEW"
    elif fp and not finding.resolves:
        finding.verdict = "REVIEW"
    else:
        finding.verdict = "OK"

    if fp and fp["vulnerable"] == "no" and finding.verdict in ("DANGLING", "FINGERPRINT"):
        finding.verdict = "REVIEW"
        finding.note += " (service blocks third-party claims)"
    return finding
Enter fullscreen mode Exit fullscreen mode

DANGLING means the CNAME target is gone. FINGERPRINT means the service is serving its "nothing here" page and the service is claimable. REVIEW is everything suspicious that I'm not willing to call. The exit code follows suit: 2 for dangling, 1 for fingerprint, 0 for clean, so you can drop it straight into CI.

Step 6: Prove it actually fires

A detector nobody has seen fire is just a script that prints OK. So there's a --selftest mode that points the whole pipeline at two hostnames where nothing is registered, on services anyone can check:

python takeover_scan.py --selftest
Enter fullscreen mode Exit fullscreen mode
[*] self-test: probing unclaimed hostnames on live services

  PASS  wf-takeover-demo-9xk22.s3.amazonaws.com
        service  AWS S3  (expected AWS S3)
        verdict  FINGERPRINT  (expected FINGERPRINT)
        dns      state=live  resolves=True  target_nxdomain=False
        http     404  matched=NoSuchBucket

  PASS  wf-takeover-demo-9xk22.github.io
        service  GitHub Pages  (expected GitHub Pages)
        verdict  FINGERPRINT  (expected FINGERPRINT)
        dns      state=live  resolves=True  target_nxdomain=False
        http     404  matched=There isn't a GitHub Pages site here

[*] 2/2 passed.
    Both hosts resolve through a service wildcard, so DNS alone says nothing.
    The response body is what flags them.
Enter fullscreen mode Exit fullscreen mode

Look at the dns line on both. state=live, target_nxdomain=False. If you only walked CNAMEs you'd call both of these clean and move on. That's the wildcard problem in two lines of output, and it's why the HTTP stage isn't optional.

It needs no API key, which also makes it the fastest way to tell whether a fingerprint has gone stale. Services rewrite their error pages without announcing it.

Real results

I pointed it at python.org. Public infrastructure, over a decade of history, and a surface wide enough to be interesting without being someone's private estate.

The first thing it prints is the part I nearly got wrong:

[*] python.org: 113 subdomain records across 2 page(s)
Enter fullscreen mode Exit fullscreen mode

Two pages. My first run used --max-pages 1 and cheerfully scanned 100 of the 113 hostnames without complaining about the other 13. Page size is 100. Drain the pages or you're auditing a prefix of your attack surface and calling it a scan.

The full run:

[*] python.org: 113 subdomain records across 2 page(s)
[*] scanning 113 hostnames with 12 workers
    100/113

HOST                       VERDICT   SERVICE         CNAME TARGET
------------------------------------------------------------------------------
blog.python.org            OK        GitHub Pages    python.github.io
blog-ja.python.org         OK        -               ghs46.google.com
devguide.python.org        OK        Read the Docs   readthedocs.io
discuss.python.org         OK        -               python1.hosted-by-discourse.com
docs.python.org            OK        Fastly          dualstack.python.map.fastly.net
education.python.org       OK        Heroku          desolate-laurel-pfbo73zva...herokudns.com
hg.python.org              OK        -               map-lb.nyc1.psf.io
peps.python.org            OK        Fastly          python.map.fastly.net
status.python.org          OK        -               2p66nmmycsj3.stspg-customer.com
translations.python.org    OK        GitHub Pages    python-docs-translations.github.io
ukpatl.uk.python.org       OK        Netlify         glistening-kashata-2a8d56.netlify.app
wwww.python.org            OK        -               -
...

OK=113
[*] wrote python-results.csv
Enter fullscreen mode Exit fullscreen mode

Clean. All 113.

The CSV is where the shape shows up. 54 of the 113 records carry a CNAME, 59 are bare A records. Sixty-seven resolve, and 46 don't. The index spans 2025-09-28 to 2026-09-13, about a year of history for a page and a bit of API calls.

Then I checked that 46 against what the index itself claims:

python takeover_scan.py python.org --status inactive --show-all

[*] python.org: 43 subdomain records across 1 page(s)
OK=43
Enter fullscreen mode Exit fullscreen mode

Forty-three flagged inactive, and all 43 are in my non-resolving set. Zero false positives. The three my scan counted as dead but the index didn't were my bug, not its: _dmarc.python.org is a TXT-only record that was never going to answer an A query, and es.python.org and comunidad.es.python.org both resolved fine when I retried them. I'd been counting "my lookup failed" as "this host is gone."

That's the bug I flagged back in Step 2. One overloaded boolean was hiding three different situations, and it only showed up because a number I could check disagreed with a number someone else had computed. The scanner reports dns_state now.

Twenty-five of those hosts matched a service in the fingerprint table: 11 Fastly, 7 GitHub Pages, 5 Read the Docs, 1 Heroku, 1 Netlify. Every one came back OK, because in every case the service is actively serving the site. That's the table doing its actual job, which is filtering rather than finding.

Look at education.python.org for a second. It CNAMEs to desolate-laurel-pfbo73zvavjs5gdidi59tus8.herokudns.com, and it's live and healthy right now. If that Heroku app were ever deleted without removing the DNS record, that single row is the entire premise of this post, sitting there in a live scan. Nothing is wrong with it today. It's just the shape of the thing you're looking for.

What I noticed

  • Paging is not optional and the tool won't warn you. --max-pages 1 returned 100 of 113 hosts and printed no complaint. Thirteen hostnames silently outside the scan is exactly the kind of gap that makes a clean result meaningless.
  • Forty-three of the 113 records are genuinely decommissioned, and the index's own inactive flag matched all 43 exactly. I'd expected that flag to lag reality and it didn't. Good remediation is invisible from the outside, and it's a third of what a historical index contains.
  • "My DNS lookup failed" and "this host is gone" are different claims, and I'd been conflating them. A TXT-only record like _dmarc never answers an A query. A timeout is a timeout. Three hosts landed in my dead pile that didn't belong there, which on a bigger estate would be a steady trickle of noise nobody chases.
  • Only 54 of 113 carry a CNAME at all. The rest are bare A records and structurally can't be taken over this way. Your real exposure is usually a fraction of your subdomain count, worth knowing before a long list sends anyone into a panic.
  • Twenty-five hosts matched a fingerprinted service and every single one came back OK, because the service is actively serving them. The table earns its place by filtering, not by finding. A scanner that flagged all 25 would be useless by Tuesday.
  • Ten hostnames CNAME to ghs46.google.com, the Blogger custom-domain host, and my table doesn't cover it. Seven more targets are also unrecognized, including python1.hosted-by-discourse.com and the Statuspage and PSF load-balancer names. I've left them out rather than guess at body strings I haven't verified, which is the honest failure mode: the scanner tells you it doesn't know, instead of pretending.
  • Wildcard DNS is why the HTTP check isn't optional. *.github.io answers for names that were never registered, so the DNS signal is flat and only the response body tells you anything.
  • education.python.org points at a live Heroku app. Nothing's wrong with it. But that one row is the whole vulnerability class in miniature, and the only difference between it and a finding is whether somebody deletes the app and forgets the DNS.
  • Every record came back OK, and I'll admit I wanted one embarrassing finding to write up. Does a scanner that finds nothing still earn its keep? Only in the sense a smoke alarm does on the days it stays quiet, which is most of them.

Going further

A few directions I'd take this next.

Run it on a schedule against your own zones and diff the CSV, because the useful signal is a host moving from OK to DANGLING, not the state on any single day. GitHub Actions on a weekly cron does the job in about fifteen lines.

Widen the fingerprint table. Seventeen services covers the common ground, and the can-i-take-over-xyz list is longer than that. The table is plain dicts specifically so adding one is a two-minute pull request.

Feed the DANGLING rows into whatever you use for tickets. A finding that sits in a CSV nobody opens isn't a finding.

And if you'd rather not run the enumeration side yourself, the WhoisFreaks Subdomains API keeps the historical index with first-seen and last-seen dates so you only maintain the DNS logic.

Full source code

Everything's on GitHub: github.com/WhoisFreaks/wf-subdomain-takeover. MIT, one file, two dependencies. The repo includes the full seventeen-service fingerprint table, a sample domains.txt, and the GitHub Actions workflow for the scheduled diff.

If you find a fingerprint that's wrong or missing, open an issue. Service error pages change more often than you'd think, and a stale fingerprint is a false negative.


Source: dev.to

arrow_back Back to Tutorials