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:

EngineStrengthLicenseIsolation
SubjackFast DNS-first CNAME walker with built-in service fingerprints. High precision on classic provider takeoversApache-2.0Native 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 bodyMITReuses projectdiscovery/nuclei:latest via Docker-in-Docker
BadDNSDeep multi-module DNS audit (CNAME, NS, MX, TXT, SPF, DMARC, wildcard, NSEC-walk, zone-transfer, HTML references). Finds takeover vectors that pure CNAME scanners ignoreAGPL-3.0Runs 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:

SetSource in recon_dataConsumed by
Subdomainsdns.subdomains keys + the flat subdomains list + the project apex domainSubjack, BadDNS
Alive URLshttp_probe.by_url entries with status_code < 500, plus http_probe.by_host[*].live_urlsNuclei 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:

FlagSettingPurpose
-w(auto)Newline-separated targets file
-tSUBJACK_THREADSConcurrent worker count
-timeoutSUBJACK_TIMEOUTPer-request timeout (seconds)
-o(auto)JSON output path
-sslSUBJACK_SSLForce HTTPS on probes. Improves accuracy against HTTPS-only SaaS providers
-aSUBJACK_ALLTest every subdomain, not just ones with an obvious CNAME. Slower but more thorough
-nsSUBJACK_CHECK_NSDetect expired nameserver delegations and dangling cloud DNS zones
-arSUBJACK_CHECK_ARFlag stale A records pointing to dead cloud IPs (probabilistic, manual verification required)
-mailSUBJACK_CHECK_MAILAudit 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:

BehaviourMain NucleiTakeover Nuclei
TemplatesCommunity + customhttp/takeovers/ + dns/ only
NUCLEI_EXCLUDE_TAGSInherited from projectNot inherited (filters out takeover tags by accident)
Interactsh (OOB)OptionalDisabled (takeover templates do not need OOB)
Severity filterProject defaultTAKEOVER_SEVERITY (default critical,high,medium)
Rate limitNUCLEI_RATE_LIMITTAKEOVER_RATE_LIMIT
Run timeoutNUCLEI_RUN_TIMEOUTNUCLEI_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.

Moduletakeover_methodPurpose
cnamecnameDangling CNAME records + takeover potential
nsnsDangling NS records (expired nameservers, cloud DNS delegations)
mxmxDangling MX records + base-domain availability
txttxtTXT record takeover opportunities
spfspfSPF include/redirect chain pointing to dangling domains
dmarctxtMissing or misconfigured DMARC
wildcarddnsWildcard DNS enabling broad takeovers
nsecdnsSubdomain enumeration via NSEC-walking (slow)
referencescnameHTML links pointing to hijackable domains
zonetransferdnsAXFR zone-transfer attempts (slow)

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:

ConditionDelta
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:

ScoreVerdict
>= threshold + 10confirmed
>= thresholdlikely
otherwisemanual_review

Default threshold is 60, configurable via TAKEOVER_CONFIDENCE_THRESHOLD.

Severity mapping

VerdictSeverity (default)With TAKEOVER_MANUAL_REVIEW_AUTO_PUBLISH
confirmedhigh (or nuclei-assigned if present)unchanged
likelymedium (or nuclei-assigned if present)unchanged
manual_reviewinfomedium

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

ParameterDefaultDescription
SUBDOMAIN_TAKEOVER_ENABLEDfalseMaster toggle. When off, the whole module is skipped; recon output contains { "skipped_reason": "disabled" }

Subjack

ParameterDefaultClampDescription
SUBJACK_ENABLEDtrue-Enable the Subjack layer (only applied if the master toggle is on)
SUBJACK_THREADS101-100Concurrent subjack workers (-t)
SUBJACK_TIMEOUT30-Per-request connection timeout in seconds (-timeout)
SUBJACK_SSLtrue-Force HTTPS on probes (-ssl)
SUBJACK_ALLfalse-Probe every subdomain, not just CNAME-bearing ones (-a)
SUBJACK_CHECK_NSfalse-Check NS takeovers (-ns)
SUBJACK_CHECK_ARfalse-Check stale A records (-ar). Probabilistic, requires human verification
SUBJACK_CHECK_MAILfalse-Check SPF/MX takeovers (-mail)
SUBJACK_RUN_TIMEOUT900>= 60Overall hard cap on the subjack subprocess (seconds)

Nuclei takeover templates

ParameterDefaultDescription
NUCLEI_TAKEOVERS_ENABLEDtrueEnable the Nuclei takeover layer
NUCLEI_TAKEOVER_RUN_TIMEOUT1800Overall hard cap on the nuclei takeover subprocess (seconds)
NUCLEI_DOCKER_IMAGEprojectdiscovery/nuclei:latestShared 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

ParameterDefaultDescription
TAKEOVER_SEVERITY["critical", "high", "medium"]Severity filter passed to Nuclei (via -severity)
TAKEOVER_RATE_LIMIT50Nuclei req/s rate limit for this layer (does not affect the global vuln scan)
TAKEOVER_CONFIDENCE_THRESHOLD60Minimum score for likely; threshold + 10 for confirmed
TAKEOVER_MANUAL_REVIEW_AUTO_PUBLISHfalseWhen true, manual_review findings are promoted from info to medium so they appear in the main findings table

BadDNS sidecar

ParameterDefaultDescription
BADDNS_ENABLEDfalseEnable the AGPL-3.0 BadDNS sidecar (opt-in)
BADDNS_DOCKER_IMAGEredamon-baddns:latestSidecar 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_TIMEOUT1800Overall 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 nodeProduced byHow this module uses it
DomainSubdomain 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
SubdomainSubdomain Discovery (GROUP 1), DNS reconPrimary 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 URLHttpx (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 nodeOperationNotes
VulnerabilityMERGE by idOne 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 relationshipMERGEFrom the anchor host (Subdomain or Domain) to the Vulnerability
Subdomain (defensive)MERGE by nameOnly 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

PropertyValue
iddeterministic hash (takeover_<sha1_16>)
user_id, project_idtenant isolation
source"takeover_scan"
type"subdomain_takeover"
namee.g. "Subdomain Takeover -- Heroku (CNAME)"
severityhigh / medium / info (driven by verdict + scorer)
descriptionhuman summary with verdict + confidence + sources
hostnamelowercased subdomain
cname_targetresolved CNAME target (when available)
takeover_providercanonical slug
takeover_methodcname / dns / ns / mx / stale_a / txt / spf
confidence0-100 integer
sourceslist of confirming tool names
confirmation_countlen(sources)
verdictconfirmed / likely / manual_review
evidencetrimmed to 2,000 chars
tool_rawJSON-encoded per-source raw output (max 50,000 chars)
first_seen, last_seenISO 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:

SettingStealth Override
NUCLEI_TAKEOVERS_ENABLEDfalse
BADDNS_ENABLEDfalse
SUBJACK_ALLfalse
SUBJACK_CHECK_NStrue (DNS-only, safe)
SUBJACK_CHECK_MAILtrue (DNS-only, safe)
SUBJACK_THREADS3
TAKEOVER_RATE_LIMIT10

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).