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) |
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:
- Subjack
servicefield (e.g."GitHub"->github-pages) - Nuclei
template-idsubstring (e.g.heroku-takeover->heroku) - BadDNS
signatureorindicatorstrings - Fallback CNAME resolution against the DNS map (e.g.
foo.herokuapp.com->heroku) - 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 provenanceevidence: 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:
- Match existing
Subdomainwith{name: hostname, user_id, project_id}andMERGEtheHAS_VULNERABILITYedge to it. - Match existing
Domain(only if the hostname equals the project apex) andMERGEthe edge to it instead. This handles takeovers on the bare apex that do not have a matchingSubdomainnode. - Create a defensive
Subdomainwithsource="takeover_scan"and attach theVulnerabilityto it. This mirrors how the main Nuclei vuln mixin handles orphan findings and guarantees everyVulnerabilityis 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_ENABLEDis force-set totruefor the run, overriding the project toggle.settings_overridesfrom 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.idinstead 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@latestat build time. - BadDNS upstream version: pinned to
baddns==2.1.0. Bump inscanners/baddns_scan/Dockerfileonly after verifying the normalizer inrecon/helpers/takeover_helpers.py::normalize_baddns_findingstill 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 PythonTimeoutExpiredcandocker killthe named container instead of leaving a daemon-owned orphan. - Never
--amend: rescans produce the samefinding_idby design, soMERGE (v:Vulnerability {id: $id})in the mixin updates in place (first_seensticks,last_seenmoves).
Related pages
- Project Settings Reference -- Subdomain Takeover -- full parameter tables
- Recon Pipeline Workflow -- pipeline diagram
- AI in the Recon Pipeline -- the takeover AI classifier that separates real unclaimed pages from WAF block pages
- Running Reconnaissance -- execution model
- GraphQL Security Testing -- sibling GROUP 6 Phase A scanner
- Rules of Engagement -- host exclusion rules applied before probes fire