Skip to content

Subdomain Takeover Detection

Samuele Giampieri edited this page Oct 25, 2026 · 7 revisions

📖 Canonical version: read this page on the official docs site — https://www.redamon.org/docs/subdomain-takeover-detection. The GitHub wiki is a mirror.

Subdomain Takeover Detection

The Subdomain Takeover module is a layered scanner that finds dangling DNS records whose third-party target can still be claimed by an attacker (expired Heroku apps, decommissioned S3 buckets, dead GitHub Pages, orphaned NS delegations, etc.). It stacks three independent detection engines, deduplicates their output, scores each finding against a confidence model, and emits Vulnerability nodes with source="takeover_scan".

It runs as GROUP 6 Phase A (parallel with Nuclei and the GraphQL scanner). Disabled by default. Enable in the project settings under the Subdomain Takeover tab.


Why a layered scanner

No single tool catches every takeover class. The module combines three engines with different strengths so coverage compounds:

Engine Strength License Isolation
Subjack Fast DNS-first CNAME walker with built-in service fingerprints. High precision on classic provider takeovers Apache-2.0 Native Go binary baked into the recon image
Nuclei (takeover templates) Fingerprint-based HTTP detection against alive URLs. Catches providers that Subjack misses and confirms CNAME matches via the response body MIT Reuses projectdiscovery/nuclei:latest via Docker-in-Docker
BadDNS Deep multi-module DNS audit (CNAME, NS, MX, TXT, SPF, DMARC, wildcard, NSEC-walk, zone-transfer, HTML references). Finds takeover vectors that pure CNAME scanners ignore AGPL-3.0 Runs inside a separate redamon-baddns:latest image. RedAmon code never imports baddns. Opt-in

Findings from all three engines are merged on (hostname, provider, method), so a subdomain confirmed by two engines produces one finding with both tools listed under sources and a confidence bonus.


Pipeline position

GROUP 5  - Resource enumeration (Katana, Hakrawler, jsluice, FFuf, GAU, Kiterunner, Arjun)
GROUP 5b - JS Reconnaissance
GROUP 6 Phase A  - Nuclei  ||  GraphQL Security  ||  Subdomain Takeover     <- parallel fan-out
GROUP 6 Phase B  - MITRE enrichment (consumes Nuclei CVEs)

Phase A tools are launched via ThreadPoolExecutor. Each scanner uses an _isolated wrapper that deep-copies the shared combined_result so the three threads never race on the same dict. The module pulls its inputs from GROUP 1 (DNS subdomains) and GROUP 4 (httpx alive URLs), so it needs the DNS + HTTP probe stages to have already completed.


Target collection

The module assembles two target sets from combined_result:

Set Source in recon_data Consumed by
Subdomains dns.subdomains keys + the flat subdomains list + the project apex domain Subjack, BadDNS
Alive URLs http_probe.by_url entries with status_code < 500, plus http_probe.by_host[*].live_urls Nuclei takeover templates

Dead hosts are Subjack and BadDNS territory (CNAME/NS walks do not need a live HTTP response). Alive URLs get the HTTP-fingerprint treatment via Nuclei.


Subjack layer (DNS-first, primary)

Subjack (haccer/subjack, Apache-2.0) is a Go tool that walks CNAME/NS/MX records, checks the resulting hostname against a compiled-in fingerprint database, and reports vulnerable subdomains. The binary is built in Stage 1d of the recon Dockerfile and installed at /usr/local/bin/subjack.

Command shape:

subjack -w <targets.txt> -t <threads> -timeout <sec> -o <results.json> \
        [-ssl] [-a] [-ns] [-ar] [-mail] [-r <resolvers>]

Flags:

Flag Setting Purpose
-w (auto) Newline-separated targets file
-t SUBJACK_THREADS Concurrent worker count
-timeout SUBJACK_TIMEOUT Per-request timeout (seconds)
-o (auto) JSON output path
-ssl SUBJACK_SSL Force HTTPS on probes. Improves accuracy against HTTPS-only SaaS providers
-a SUBJACK_ALL Test every subdomain, not just ones with an obvious CNAME. Slower but more thorough
-ns SUBJACK_CHECK_NS Detect expired nameserver delegations and dangling cloud DNS zones
-ar SUBJACK_CHECK_AR Flag stale A records pointing to dead cloud IPs (probabilistic, manual verification required)
-mail SUBJACK_CHECK_MAIL Audit SPF includes and MX records for references to dead infrastructure

Subjack has no -c flag; fingerprints are compiled into the binary. A vulnerable row in subjack_results.json is normalized into the canonical finding shape with source_tool="subjack".

Run bound: SUBJACK_RUN_TIMEOUT (default 900 s) hard-caps the whole subjack invocation so a pathological target set cannot stall the pipeline.


Nuclei takeover templates (fingerprint-based)

The module reuses the existing Nuclei Docker image but forces -t http/takeovers/ -t dns/ so only ~60 takeover-focused templates fire instead of the full 9,000+ community set. Targets are the alive URLs collected above.

Key differences vs. the main Nuclei vuln scan:

Behaviour Main Nuclei Takeover Nuclei
Templates Community + custom http/takeovers/ + dns/ only
NUCLEI_EXCLUDE_TAGS Inherited from project Not inherited (filters out takeover tags by accident)
Interactsh (OOB) Optional Disabled (takeover templates do not need OOB)
Severity filter Project default TAKEOVER_SEVERITY (default critical,high,medium)
Rate limit NUCLEI_RATE_LIMIT TAKEOVER_RATE_LIMIT
Run timeout NUCLEI_RUN_TIMEOUT NUCLEI_TAKEOVER_RUN_TIMEOUT (default 1800 s)

Only findings whose tags or template-id include takeover, dangling, or detect-dangling-cname are kept; other categories (CVE, misconfig) are discarded by the normalizer. HTTP templates map to method="cname", DNS templates map to method="dns". The first extracted_results entry becomes cname_target.


BadDNS layer (AGPL-3.0 isolated sidecar)

BadDNS is an AGPL-3.0 Python toolkit that goes well beyond CNAME walks: it audits NS, MX, SPF, DMARC, wildcard, NSEC zone-walking, zone transfers, and HTML references. Because of the AGPL-3.0 license, RedAmon never imports baddns at the Python level. Instead a dedicated Docker image (redamon-baddns:latest) ships upstream baddns unmodified, and the recon container spawns it via Docker-in-Docker. The process + filesystem boundary is the license-safe integration pattern (see THIRD-PARTY-LICENSES.md).

Build the sidecar once:

docker compose --profile tools build baddns-scanner

The image is not pulled automatically. If it is missing on the host, the BadDNS layer is skipped with a warning instead of aborting the run.

Container invocation:

docker run --rm --name redamon-baddns-<pid>-<ts> \
    -v <work_dir_host>:/work:ro \
    redamon-baddns:latest \
    /work/baddns_targets.txt \
    <modules_csv> \
    <nameservers_csv>

The entrypoint is a batch wrapper that invokes baddns -s -m <modules> -- <target> once per line, with a per-target timeout (BADDNS_PER_TARGET_TIMEOUT, default 90 s) so one hanging target cannot stall the batch. Findings arrive as NDJSON on stdout, one JSON object per line from Finding.to_json().

BadDNS modules

BadDNS ships 11 modules; 10 are CLI-addressable (MTA-STS is excluded because the baddns 2.1.0 validate_modules regex rejects the hyphen). RedAmon exposes all 10 in the UI.

Module takeover_method Purpose
cname cname Dangling CNAME records + takeover potential
ns ns Dangling NS records (expired nameservers, cloud DNS delegations)
mx mx Dangling MX records + base-domain availability
txt txt TXT record takeover opportunities
spf spf SPF include/redirect chain pointing to dangling domains
dmarc txt Missing or misconfigured DMARC
wildcard dns Wildcard DNS enabling broad takeovers
nsec dns Subdomain enumeration via NSEC-walking (slow)
references cname HTML links pointing to hijackable domains
zonetransfer dns AXFR zone-transfer attempts (slow)

Wildcard DNS and the names checked here. BadDNS's wildcard module audits the wildcard record itself. The names Subjack, Nuclei and BadDNS check come from recon, where RedAmon's wildcard control has already dropped every name that answers only through its zone's wildcard (see Running Reconnaissance > GROUP 1), so a takeover candidate under a wildcard zone is a name with a record of its own.

Default enabled subset: cname, ns, mx, txt, spf. nsec and zonetransfer are opt-in because they can be slow on large targets. Unknown module strings are silently filtered at command-build time to prevent argparse-level baddns failures.

Nameservers: BADDNS_NAMESERVERS is an optional comma-separated resolver list passed to baddns via -n. Empty = system resolvers.


Provider fingerprinting

Each normalized finding is tagged with a canonical takeover_provider slug (e.g. github-pages, heroku, aws-s3). The provider is inferred in this precedence order:

  1. Subjack service field (e.g. "GitHub" -> github-pages)
  2. Nuclei template-id substring (e.g. heroku-takeover -> heroku)
  3. BadDNS signature or indicator strings
  4. Fallback CNAME resolution against the DNS map (e.g. foo.herokuapp.com -> heroku)
  5. BadDNS module name as last resort (e.g. ns -> ns) for otherwise-unknown matches

Auto-exploitable providers (single-step claim, no verification challenge) receive a +20 confidence bonus:

github-pages, heroku, aws-s3, shopify, fastly, ghost,
unbounce, readthedocs, surge, webflow, tumblr, statuspage

The full fingerprint table (~40 signals + ~30 CNAME patterns) lives in recon/helpers/takeover_helpers.py::PROVIDER_FROM_SIGNAL. Keep it in sync with upstream nuclei-templates/http/takeovers/ and subjack fingerprints when adding providers.


Deduplication & scoring

Dedupe key

Findings are merged by the tuple (hostname, takeover_provider, takeover_method). Merged findings carry:

  • sources: ordered list of tool names (subjack, nuclei_takeover, baddns)
  • confirmation_count: len(sources)
  • raw_by_source: {tool: raw_payload} for provenance
  • evidence: Subjack evidence is kept when both Subjack and another tool fire (higher precision)

Confidence scoring

Every deduped finding is scored 0-100 using additive rules:

Condition Delta
Confirmed by 2+ tools +30
Subjack flagged as vulnerable +25
Provider in auto-exploitable list +20
Nuclei template match +15
Method = cname (most reliable) +10
Method = stale_a or mx (probabilistic) -15
Provider = unknown -10

Score is clamped to [0, 100], then mapped to a verdict:

Score Verdict
>= threshold + 10 confirmed
>= threshold likely
otherwise manual_review

Default threshold is 60, configurable via TAKEOVER_CONFIDENCE_THRESHOLD.

Severity mapping

Verdict Severity (default) With TAKEOVER_MANUAL_REVIEW_AUTO_PUBLISH
confirmed high (or nuclei-assigned if present) unchanged
likely medium (or nuclei-assigned if present) unchanged
manual_review info medium

manual_review defaults to info so it does not pollute the main alert stream. Flip TAKEOVER_MANUAL_REVIEW_AUTO_PUBLISH on when you want every unverified candidate surfaced in the findings table.


Full parameter reference

Master toggle

Parameter Default Description
SUBDOMAIN_TAKEOVER_ENABLED false Master toggle. When off, the whole module is skipped; recon output contains { "skipped_reason": "disabled" }

Subjack

Parameter Default Clamp Description
SUBJACK_ENABLED true - Enable the Subjack layer (only applied if the master toggle is on)
SUBJACK_THREADS 10 1-100 Concurrent subjack workers (-t)
SUBJACK_TIMEOUT 30 - Per-request connection timeout in seconds (-timeout)
SUBJACK_SSL true - Force HTTPS on probes (-ssl)
SUBJACK_ALL false - Probe every subdomain, not just CNAME-bearing ones (-a)
SUBJACK_CHECK_NS false - Check NS takeovers (-ns)
SUBJACK_CHECK_AR false - Check stale A records (-ar). Probabilistic, requires human verification
SUBJACK_CHECK_MAIL false - Check SPF/MX takeovers (-mail)
SUBJACK_RUN_TIMEOUT 900 >= 60 Overall hard cap on the subjack subprocess (seconds)

Nuclei takeover templates

Parameter Default Description
NUCLEI_TAKEOVERS_ENABLED true Enable the Nuclei takeover layer
NUCLEI_TAKEOVER_RUN_TIMEOUT 1800 Overall hard cap on the nuclei takeover subprocess (seconds)
NUCLEI_DOCKER_IMAGE projectdiscovery/nuclei:latest Shared with the main Nuclei vuln scanner

The following nuclei settings are inherited from the global Nuclei block: NUCLEI_BULK_SIZE, NUCLEI_CONCURRENCY, NUCLEI_TIMEOUT, NUCLEI_RETRIES, NUCLEI_SYSTEM_RESOLVERS, NUCLEI_FOLLOW_REDIRECTS, NUCLEI_MAX_REDIRECTS. Global NUCLEI_EXCLUDE_TAGS is not inherited (would accidentally exclude takeover tags).

Scoring & severity

Parameter Default Description
TAKEOVER_SEVERITY ["critical", "high", "medium"] Severity filter passed to Nuclei (via -severity)
TAKEOVER_RATE_LIMIT 50 Nuclei req/s rate limit for this layer (does not affect the global vuln scan)
TAKEOVER_CONFIDENCE_THRESHOLD 60 Minimum score for likely; threshold + 10 for confirmed
TAKEOVER_MANUAL_REVIEW_AUTO_PUBLISH false When true, manual_review findings are promoted from info to medium so they appear in the main findings table

BadDNS sidecar

Parameter Default Description
BADDNS_ENABLED false Enable the AGPL-3.0 BadDNS sidecar (opt-in)
BADDNS_DOCKER_IMAGE redamon-baddns:latest Sidecar image tag. Build via docker compose --profile tools build baddns-scanner
BADDNS_MODULES ["cname", "ns", "mx", "txt", "spf"] Active module list. 10 addressable modules: see table above
BADDNS_NAMESERVERS [] Optional custom DNS resolvers (comma-separated or list); empty = system
BADDNS_RUN_TIMEOUT 1800 Overall hard cap on the baddns subprocess (seconds). Orphan containers are reaped via docker kill <container_name> on timeout

Output structure

Results live under combined_result.subdomain_takeover:

{
  "findings": [
    {
      "id": "takeover_9d2e4b1a3f8c",
      "hostname": "promo.example.com",
      "cname_target": "promo-app.herokuapp.com",
      "takeover_provider": "heroku",
      "takeover_method": "cname",
      "evidence": "Subjack confirmed Heroku takeover",
      "severity": "high",
      "confidence": 85,
      "verdict": "confirmed",
      "sources": ["subjack", "nuclei_takeover"],
      "confirmation_count": 2,
      "raw_by_source": { "subjack": {...}, "nuclei_takeover": {...} },
      "detected_at": "2026-04-21T13:37:00Z"
    }
  ],
  "by_target": { "promo.example.com": [ {...} ] },
  "summary": {
    "total": 3,
    "confirmed": 1,
    "likely": 1,
    "manual_review": 1,
    "by_provider": { "heroku": 1, "aws-s3": 1, "unknown": 1 }
  },
  "scan_metadata": {
    "subjack_enabled": true,
    "nuclei_takeovers_enabled": true,
    "confidence_threshold": 60,
    "subdomains_scanned": 42,
    "alive_urls_scanned": 37,
    "duration_sec": 128.4,
    "scan_timestamp": "2026-04-21T13:37:00Z"
  }
}

id is a deterministic SHA1-based hash of hostname|provider|method so rescans update the same graph node instead of duplicating.


Graph schema

Input nodes (consumed)

The scanner never queries Neo4j directly during the full recon run -- all inputs come from the in-memory combined_result dict that earlier pipeline stages populated. The underlying graph provenance of those inputs is:

Graph node Produced by How this module uses it
Domain Subdomain Discovery (GROUP 1) Apex target. Added to the subdomain list so the root itself is scanned. Used as the fallback attachment point when a finding hits the apex and no Subdomain exists
Subdomain Subdomain Discovery (GROUP 1), DNS recon Primary target set for Subjack and BadDNS. Pulled from recon_data.dns.subdomains keys and the flat subdomains list
DNSRecord (CNAME) DNS recon (GROUP 1) Read via the DNS map (dns.subdomains[host].records.CNAME) to fill in cname_target and infer the provider when Subjack/Nuclei/BadDNS all mark it unknown
BaseURL / alive URL Httpx (GROUP 4) The Nuclei takeover layer targets only URLs with status_code < 500 from http_probe.by_url and the per-host live_urls lists. Dead hosts stay with Subjack/BadDNS

In Partial Recon, the scanner instead calls _build_vuln_scan_data_from_graph() which directly reads Subdomain, DNSRecord, and BaseURL nodes out of Neo4j for the project, plus any user-supplied subdomains injected by the modal.

Nodes that are not read: IP, Port, Service, Endpoint, Technology, Parameter, Header, Certificate, CVE, ExternalDomain, MitreData, Capec, Secret.

Output nodes (produced)

Graph node Operation Notes
Vulnerability MERGE by id One node per deduped finding. source="takeover_scan", type="subdomain_takeover". first_seen set on create, last_seen on every run, so rescans update in place
HAS_VULNERABILITY relationship MERGE From the anchor host (Subdomain or Domain) to the Vulnerability
Subdomain (defensive) MERGE by name Only when the finding's hostname has no existing Subdomain and is not the apex. The placeholder is created with source="takeover_scan" so the Vulnerability is reachable from the graph page

Nodes that are not produced or modified: Domain, IP, Port, Service, Endpoint, BaseURL, DNSRecord, Technology, Parameter, Header, Certificate, CVE, MitreData, Capec, Secret, ExternalDomain. The takeover module is deliberately narrow: it writes one node type plus at most one relationship per finding.

Anchor attachment precedence

For every finding, the mixin tries three anchor strategies in order and stops on the first that succeeds:

  1. Match existing Subdomain with {name: hostname, user_id, project_id} and MERGE the HAS_VULNERABILITY edge to it.
  2. Match existing Domain (only if the hostname equals the project apex) and MERGE the edge to it instead. This handles takeovers on the bare apex that do not have a matching Subdomain node.
  3. Create a defensive Subdomain with source="takeover_scan" and attach the Vulnerability to it. This mirrors how the main Nuclei vuln mixin handles orphan findings and guarantees every Vulnerability is reachable from the graph page.

Properties written on each Vulnerability

Property Value
id deterministic hash (takeover_<sha1_16>)
user_id, project_id tenant isolation
source "takeover_scan"
type "subdomain_takeover"
name e.g. "Subdomain Takeover -- Heroku (CNAME)"
severity high / medium / info (driven by verdict + scorer)
description human summary with verdict + confidence + sources
hostname lowercased subdomain
cname_target resolved CNAME target (when available)
takeover_provider canonical slug
takeover_method cname / dns / ns / mx / stale_a / txt / spf
confidence 0-100 integer
sources list of confirming tool names
confirmation_count len(sources)
verdict confirmed / likely / manual_review
evidence trimmed to 2,000 chars
tool_raw JSON-encoded per-source raw output (max 50,000 chars)
first_seen, last_seen ISO timestamps

Rules of Engagement

Subdomain Takeover inherits the project-level RoE applied during GROUP 1 (subdomain discovery) and GROUP 4 (HTTP probe). Targets already filtered by ROE_EXCLUDED_HOSTS never reach the takeover module because the input lists come straight from dns.subdomains and http_probe.by_url.


Stealth mode

When stealth mode is enabled on the project, the takeover module is toned down:

Setting Stealth Override
NUCLEI_TAKEOVERS_ENABLED false
BADDNS_ENABLED false
SUBJACK_ALL false
SUBJACK_CHECK_NS true (DNS-only, safe)
SUBJACK_CHECK_MAIL true (DNS-only, safe)
SUBJACK_THREADS 3
TAKEOVER_RATE_LIMIT 10

Subjack in DNS-only mode stays on: CNAME/NS/MX resolution does not generate HTTP traffic to the target and is safe at low concurrency. The HTTP-fingerprint Nuclei layer and the BadDNS sidecar are disabled outright.


Partial recon

Subdomain Takeover is available as a Partial Recon tool (tool_id="SubdomainTakeover"). The modal accepts user-provided custom subdomains that must be within the project scope (the entry must equal the apex or end with .<apex>). Out-of-scope entries are rejected with a log warning.

Partial-recon run behaviour:

  • SUBDOMAIN_TAKEOVER_ENABLED is force-set to true for the run, overriding the project toggle.
  • settings_overrides from the modal bypass stored project settings for that run.
  • User subdomains are resolved via the system resolver; dangling entries (no A/AAAA) are still scanned because they are the prime takeover candidates.
  • Graph-existing targets can be included or excluded via the "Include existing graph targets" checkbox. When both are empty, the run exits with a "no subdomains to scan" message.
  • Confirmed findings are written to the graph as if the full pipeline ran, so repeated partial runs converge on the same Vulnerability.id instead of duplicating.

See Recon Pipeline Workflow -- Partial Recon for the modal layout.


Implementation notes

  • Subjack install path: baked into the recon image as a pure-Go binary from github.com/haccer/subjack (built with Go 1.25 in a separate Docker stage). Upstream has no release tarball, so the image pins @latest at build time.
  • BadDNS upstream version: pinned to baddns==2.1.0. Bump in scanners/baddns_scan/Dockerfile only after verifying the normalizer in recon/helpers/takeover_helpers.py::normalize_baddns_finding still matches the Finding JSON schema.
  • Shared work directory: the runner uses /tmp/redamon/redamon_takeover_*/ which is bind-mounted between the recon container and the host, so Docker-in-Docker siblings (nuclei, baddns) see the same paths. The directory is chmod 755 so the non-root baddns user in the sidecar can read target files.
  • Orphan container reaping: the baddns invocation passes --name redamon-baddns-<pid>-<ts> so a Python TimeoutExpired can docker kill the named container instead of leaving a daemon-owned orphan.
  • Never --amend: rescans produce the same finding_id by design, so MERGE (v:Vulnerability {id: $id}) in the mixin updates in place (first_seen sticks, last_seen moves).

Related pages

Home


Getting Started

Core Workflow

Scanning & OSINT

AI & Automation

Plugins

HackLab

Analysis & Reporting

Contributing

Reference & Help

Clone this wiki locally