VHost & SNI Enumeration
The VHost & SNI module discovers hidden virtual hosts living on each target IP. Modern infrastructure routinely puts dozens of distinct applications behind a single IP — admin panels, staging environments, internal APIs, k8s ingress backends — and DNS rarely advertises all of them. The module probes every candidate hostname against every target IP at two layers (HTTP Host: header and TLS SNI) and flags responses that diverge from the bare-IP baseline.
It runs as GROUP 6 Phase A (parallel with Nuclei, GraphQL Security, and Subdomain Takeover). Disabled by default. Enable in the project settings under the VHost & SNI Enumeration tab.
Why two layers
Web routing happens at two different points in the request lifecycle, and the ecosystem is split across both:
| Layer | What we lie about | Catches | Misses |
|---|---|---|---|
| L7 (HTTP Host header) | The HTTP Host: header sent inside the TLS tunnel | Classic Apache/Nginx vhosts that route on the application layer | Modern reverse proxies that decide where to send the connection before parsing HTTP |
| L4 (TLS SNI) | The hostname in the TLS ClientHello (server_name extension) | k8s ingress controllers, NGINX-ingress, Traefik, Cloudflare, AWS ALB SNI listeners — anything that routes at the TLS handshake | Plain Apache vhosts that only look at Host: |
Sending only one of the two leaves whole classes of hidden infrastructure undetected. The module sends both per candidate, so coverage compounds. When the L7 and L4 responses disagree on the same hostname, that itself is a high-severity finding (host_header_bypass) — a primitive for bypassing edge controls that filter at one layer but not the other.
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 || VHost & SNI <- 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 four threads never race on the same dict. VHost & SNI pulls inputs from earlier groups (port_scan IPs/ports, http_probe TLS SAN data, DNS resolver map, ExternalDomain co-residency).
Wall-time cost when added to a full scan: typically zero. Nuclei dominates this group at 5-30 minutes, while VHost & SNI completes in 5-15 seconds for a single-IP target with the default wordlist.
Target collection
The module assembles a per-IP scan plan from combined_result:
| Layer | Source in recon_data | Purpose |
|---|---|---|
| IP targets | port_scan.by_host[*].ip + ports list. Falls back to dns.subdomains[*].ips.ipv4 with default 80/443 when port_scan didn't run | Each IP gets its own baseline + candidate sweep |
| Per-IP candidate hostnames | Six in-scope sources merged + deduped per IP (see below) | The list of hostnames to test against the IP |
Candidate sources per IP
For each IP, the module merges hostnames from up to seven sources, lowercases everything, drops duplicates, and filters via _is_valid_hostname:
Subdomainnodes that resolve to this IP (viaRESOLVES_TO)ExternalDomainnodes that resolve to the same IP — out-of-scope subdomains co-resident on the IP, often the highest-signal source- TLS Subject Alternative Names captured on every
BaseURLserved from this IP — wildcard*.acme.comis stripped toacme.com - CNAME targets from
DNSRecordentries - Reverse-DNS PTR record stored on the
IPnode - Default wordlist (~2,380 curated admin/dev/staging/internal/modern-stack prefixes shipped with the recon image, expanded as
{prefix}.{target_apex}) - Custom wordlist uploaded per-project — bare prefixes are expanded against the apex, full hostnames (containing a dot) are used as-is
The combined set is capped at VHOST_SNI_MAX_CANDIDATES_PER_IP (default 2,000) per IP. Excess candidates are dropped deterministically (sorted alphabetically) so reruns hit the same set.
Probe sequence (per IP × port)
For every (IP, port) pair, the module runs one baseline plus two probes per candidate hostname:
Step 1 — Baseline
Single curl against the bare IP with no Host override. Records {status, size}.
curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
--connect-timeout 3 --max-time 9 \
https://1.2.3.4:443/
If this fails (TCP refused, TLS handshake fails, curl status code 0), the entire (IP, port) is skipped with a warning. There is no point comparing candidate responses against a missing baseline.
Step 2 — L7 probe (Host header trick)
For each candidate hostname, override the HTTP Host header while still dialling the bare IP:
curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
-H "Host: admin.example.com" \
--connect-timeout 3 --max-time 9 \
https://1.2.3.4:443/
The TLS handshake carries SNI = 1.2.3.4 (the URL hostname), but the HTTP request inside the tunnel claims admin.example.com. Apache/Nginx vhosts that route on Host: will hand back the matching backend.
Step 3 — L4 probe (TLS SNI trick)
Only runs on HTTPS ports. Uses --resolve to skip DNS while sending the candidate hostname as the TLS SNI:
curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
--resolve "admin.example.com:443:1.2.3.4" \
--connect-timeout 3 --max-time 9 \
https://admin.example.com:443/
The TCP connection still goes to 1.2.3.4, but the ClientHello carries server_name = admin.example.com and curl naturally sets the matching Host: header. Reverse proxies that decide which backend to talk to before reading any HTTP — k8s ingress controllers, Traefik, Cloudflare, NGINX-ingress — will route to the admin backend.
L7 and L4 probes for all candidates run concurrently per port via ThreadPoolExecutor(max_workers=VHOST_SNI_CONCURRENCY). Each probe gets a --connect-timeout of VHOST_SNI_TIMEOUT seconds with a hard --max-time triple that, plus a Python-side subprocess timeout adding two seconds of grace.
Step 4 — Anomaly detection
For every probe, compare the response to the baseline:
| Condition | Verdict |
|---|---|
| Status code differs from baseline | anomaly |
Status matches AND |size_observed - size_baseline| > VHOST_SNI_BASELINE_SIZE_TOLERANCE (default 50 bytes) | anomaly |
| Otherwise | match — no finding |
The size tolerance suppresses noise from Set-Cookie nonces, CSRF tokens, and per-request timestamps that legitimately change the body byte count without changing the underlying response.
Severity classification
Each anomaly is classified by _classify_severity into one of four tiers:
| Severity | Trigger | Vulnerability type |
|---|---|---|
high | L7 and L4 disagree on the same hostname (different status codes OR different size). One layer routed to a different backend than the other — a proxy-bypass primitive | host_header_bypass |
medium | The discovered hostname matches an internal-keyword pattern (admin, staging, internal, jenkins, vault, k8s, argocd, phpmyadmin, etc. — see INTERNAL_KEYWORDS) | hidden_vhost (L7-only) or hidden_sni_route (L4-only) |
low | Different status code than baseline, no internal-keyword match | (same as above) |
info | Same status code, only the body size differs beyond tolerance | (same as above) |
Internal-keyword matching is deterministic: when a label matches multiple keywords (e.g. admin-portal matches both admin and portal), the longest keyword wins (lexicographic tiebreak), making the classification stable across reruns.
Discovered URLs feed downstream tools
When VHOST_SNI_INJECT_DISCOVERED is on (default), each confirmed hidden vhost is added as a fresh entry in combined_result["http_probe"]["by_url"] with discovery_source: "vhost_sni_enum" and live: true. This means:
- Subdomain Takeover (sister tool in the same Phase A group) sees the new alive URLs in its
_collect_alive_urls()pass, even though they were discovered after Group 4 finished. - Follow-up partial recon runs (Katana, Nuclei) pick up the new BaseURL nodes from the graph and crawl/scan the discovered surface without rerunning the full pipeline.
The injection never overwrites existing by_url entries — already-known URLs are left untouched.
Tools used
| Tool | Where | Notes |
|---|---|---|
curl | Recon container base image | Standard Linux curl. Every probe is a single subprocess.run([...]) with the argument list — no shell, no string interpolation into a command line, no injection surface |
httpx | ProjectDiscovery, already pulled by GROUP 4 | Used only in partial recon mode to filter the user's subdomain candidates for liveness; not used in full-pipeline mode |
No new Docker image, no new pip dependency, no new API key. The module is intentionally lightweight.
Hostname injection safety
The candidate hostname pipeline ends with _is_valid_hostname (RFC-1123 label regex anchored with \Z so newlines cannot slip past $). The validator rejects:
- Colons (
:) — would corrupt--resolve "<host>:<port>:<ip>"syntax - Newlines (
\n) — would inject HTTP headers - Spaces, quotes, backticks, dollar signs, backslashes, NUL bytes — defence-in-depth even though
subprocess.runwith a list bypasses the shell - Labels longer than 63 chars or hostnames longer than 253 chars
- Underscores (
_) and characters outside[A-Z0-9-] - Labels starting or ending with
-
A malformed entry — whether it landed via the user's custom wordlist, a corrupted graph node, or a TLS SAN with weird shape — is silently dropped before it reaches curl.
Concurrency model
| Layer | Parallelism | Configurable |
|---|---|---|
| IP iteration | Sequential | No (deliberate — preserves ordering for deterministic logging) |
| Port iteration per IP | Sequential | No |
| Candidate × layer probes per port | Concurrent via ThreadPoolExecutor | Yes (VHOST_SNI_CONCURRENCY, default 20) |
| Phase A fan-out | Concurrent (vhost_sni runs alongside Nuclei, GraphQL, Takeover) | No (handled by the orchestrator) |
The per-IP serial design is deliberate: a typical scan has a handful of IPs, so the savings from inter-IP parallelism are small, while the deterministic single-IP reporting makes log triage easier. Within an IP, candidate-level concurrency dominates the total wall time.
A test pinned at VHOST_SNI_CONCURRENCY=8 confirms the module never exceeds the configured cap (max inflight ≤ concurrency + 2 thread-scheduling buffer).
Default wordlist
recon/wordlists/vhost-common.txt ships with the recon image. 2,380 unique entries organised into eleven topical sections:
- Admin / management surfaces (
admin,manage,console,panel, ...) - Dev / staging / pre-prod (
dev,staging,qa,uat,preprod, ...) - CI/CD / build / deploy (
jenkins,gitlab,argocd,harbor, ...) - Monitoring / logging (
grafana,prometheus,kibana,splunk, ...) - Containers / orchestration (
kubernetes,k8s,rancher,traefik, ...) - Databases / caches (
phpmyadmin,pgadmin,redis,mongo, ...) - Authentication / SSO (
keycloak,okta,vault,ldap, ...) - Email / communication (
mail,mx,exchange,webmail, ...) - File storage / sharing (
nextcloud,nas,synology,seafile, ...) - Security tools / SOC (
splunk,siem,crowdstrike,wazuh, ...) - ML / AI / blockchain / IoT (
mlflow,kubeflow,geth,ipfs, ...)
Each prefix is expanded as {prefix}.{target_apex} per target IP. Project-specific custom prefixes go into the Custom Wordlist textarea on the project form (stored as a Text column, no length limit).
Full parameter reference
Master toggle
| Parameter | Default | Description |
|---|---|---|
VHOST_SNI_ENABLED | false | Master toggle. When off, the module is skipped and recon output contains { "skipped_reason": "disabled" } |
Layer toggles
| Parameter | Default | Description |
|---|---|---|
VHOST_SNI_TEST_L7 | true | Run the HTTP Host-header probe per candidate. Catches classic Apache/Nginx vhosts |
VHOST_SNI_TEST_L4 | true | Run the TLS SNI probe per candidate. Only fires on HTTPS ports. Catches reverse-proxy / k8s ingress / CDN routing |
If both are off the module exits with { "skipped_reason": "all_layers_disabled" }.
Candidate sources
| Parameter | Default | Description |
|---|---|---|
VHOST_SNI_USE_GRAPH_CANDIDATES | true | Pull candidate hostnames from existing graph nodes (Subdomain, ExternalDomain, TLS SANs, CNAMEs, PTR). Highest-signal source |
VHOST_SNI_USE_DEFAULT_WORDLIST | true | Use the bundled vhost-common.txt (~2,380 prefixes), expanded as {prefix}.{apex} |
VHOST_SNI_CUSTOM_WORDLIST | "" | Optional newline-separated prefixes/hostnames from the project form (Text column, no size cap) |
VHOST_SNI_MAX_CANDIDATES_PER_IP | 2000 | Hard cap on candidates per IP. Excess entries are dropped deterministically (sorted alphabetically) |
Performance / behaviour
| Parameter | Default | Clamp | Description |
|---|---|---|---|
VHOST_SNI_TIMEOUT | 3 | >= 1 | curl --connect-timeout per probe (seconds). Total per-probe budget is 3× this value |
VHOST_SNI_CONCURRENCY | 20 | >= 1 | Parallel probes per (IP, port). Higher = faster, louder |
VHOST_SNI_BASELINE_SIZE_TOLERANCE | 50 | >= 0 | Bytes of size delta to ignore when status code matches baseline. Suppresses cookie/timestamp jitter |
VHOST_SNI_INJECT_DISCOVERED | true | - | When a hidden vhost is confirmed, create a BaseURL node and add it to http_probe.by_url so downstream tools (Subdomain Takeover, partial-recon Nuclei) pick it up |
Output structure
Results live under combined_result.vhost_sni:
{
"by_ip": {
"1.2.3.4": {
"ip": "1.2.3.4",
"baseline": {"status": 403, "size": 548},
"candidates_tested": 247,
"ports_tested": 2,
"anomalies": [
{
"hostname": "admin.example.com",
"ip": "1.2.3.4",
"port": 443,
"scheme": "https",
"layer": "L7",
"baseline_status": 403,
"baseline_size": 548,
"observed_status": 200,
"observed_size": 4823,
"size_delta": 4275,
"severity": "medium",
"internal_pattern_match": "admin"
}
],
"anomaly_count": 3,
"is_reverse_proxy": true,
"hosts_hidden_vhosts": true
}
},
"findings": [
{
"id": "vhost_sni_admin_example_com_1_2_3_4_443_l7",
"name": "Hidden Virtual Host: admin.example.com",
"type": "hidden_vhost",
"severity": "medium",
"source": "vhost_sni_enum",
"hostname": "admin.example.com",
"ip": "1.2.3.4",
"port": 443,
"scheme": "https",
"layer": "L7",
"baseline_status": 403,
"baseline_size": 548,
"observed_status": 200,
"observed_size": 4823,
"size_delta": 4275,
"internal_pattern_match": "admin",
"description": "Setting Host: admin.example.com on 1.2.3.4:443 returns a different response than the baseline IP request. The hostname pattern 'admin' suggests an internal/admin application.",
"discovered_at": "2026-04-25T14:30:00Z"
}
],
"discovered_baseurls": [
"https://admin.example.com"
],
"summary": {
"ips_tested": 5,
"candidates_total": 1247,
"anomalies_l7": 3,
"anomalies_l4": 1,
"high_severity": 1,
"medium_severity": 2,
"low_severity": 1,
"info_severity": 0
},
"scan_metadata": {
"duration_sec": 12.4,
"scan_timestamp": "2026-04-25T14:30:00Z",
"wordlist_default_used": true,
"wordlist_default_count": 2380,
"wordlist_custom_count": 0,
"graph_candidates_used": true,
"test_l7": true,
"test_l4": true,
"size_tolerance": 50,
"concurrency": 20,
"timeout": 3
}
}
finding.id is a deterministic hash of (hostname, IP, port, layer) so reruns update the same Vulnerability node instead of duplicating.
Graph schema
Input nodes (consumed)
The module never queries Neo4j directly during full-pipeline runs — all inputs come from the in-memory combined_result dict. The graph provenance of those inputs is:
| Graph node | Produced by | How this module uses it |
|---|---|---|
IP | DNS recon (GROUP 1), Naabu / Masscan (GROUP 3) | Target IPs to probe. Pulled from port_scan.by_host[*].ip with fallback to dns.subdomains[*].ips.ipv4. Reverse-DNS PTR (IP.reverse_dns) is also read as a candidate source |
Port | Naabu / Masscan (GROUP 3), Nmap (GROUP 3.5) | Ports to test per IP. Honours an explicit scheme field on the port spec when present (so an HTTPS service on port 9001 doesn't get probed as HTTP) |
Subdomain | Subdomain Discovery (GROUP 1) | First-tier candidate source — every Subdomain that resolves to the target IP is added |
ExternalDomain | Domain recon — out-of-scope subdomains | Co-residency check: ExternalDomain nodes that resolve to the same IP become candidates. Often higher signal than in-scope Subdomains |
BaseURL | Httpx (GROUP 4) | TLS Subject Alternative Names captured by httpx (tls_subject_alt_names / tls_sans) become candidates. Wildcards *.acme.com are stripped to acme.com |
Certificate | Httpx (GROUP 4) | Same SAN extraction as BaseURL when the Certificate node carries them |
DNSRecord (CNAME) | DNS recon (GROUP 1) | CNAME targets resolving to the target IP become candidates |
In Partial Recon, the module instead calls _build_vuln_scan_data_from_graph() which directly reads Subdomain, IP, Port, and BaseURL nodes for the project, plus any user-supplied subdomains/IPs injected by the modal.
Nodes that are not read: Service, Endpoint, Technology, Parameter, Header, CVE, MitreData, Capec, Secret, ThreatPulse, Malware.
Output nodes (produced)
| Graph node | Operation | Notes |
|---|---|---|
Vulnerability | MERGE by deterministic id | One node per anomaly. source="vhost_sni_enum", type="hidden_vhost" / "hidden_sni_route" / "host_header_bypass". first_seen set on create, last_seen on every run, so reruns update in place |
BaseURL | MERGE by url | Created for every confirmed hidden vhost (discovery_source="vhost_sni_enum") so Katana / Nuclei follow-up partial recon can scan the new surface |
Subdomain (defensive) | MERGE by name | Created on demand when a finding's hostname has no existing Subdomain node |
HAS_VULNERABILITY relationship | MERGE | From the discovered Subdomain to the Vulnerability. For host_header_bypass findings the IP also gets the same edge |
HAS_BASEURL relationship | MERGE | From the discovered Subdomain to the new BaseURL |
Properties enriched on existing nodes
Subdomain (every Subdomain that became an anomaly):
| Property | Value |
|---|---|
vhost_tested | true |
vhost_hidden | true (only when confirmed hidden) |
vhost_routing_layer | "L7" / "L4" / "both" |
vhost_status_code | observed status code |
vhost_size_delta | bytes difference vs baseline |
sni_routed | true if layer is L4 or both |
vhost_tested_at | ISO timestamp |
IP (every IP that was probed):
| Property | Value |
|---|---|
vhost_sni_tested | true |
vhost_baseline_status | baseline status code |
vhost_baseline_size | baseline body size |
hosts_hidden_vhosts | true if any hidden vhost was confirmed |
hidden_vhost_count | number of hidden vhosts found |
is_reverse_proxy | true if at least one L4 anomaly fired (SNI routing differs from default → likely k8s ingress / NGINX-ingress / Cloudflare) |
vhost_sni_tested_at | ISO timestamp |
No new node labels are introduced. No new relationship types are introduced. The module deliberately reuses existing types so the graph schema stays narrow.
Anchor attachment precedence
For every finding, the mixin tries three anchor strategies in order:
- Match existing
Subdomainwith{name: hostname, user_id, project_id}andMERGEtheHAS_VULNERABILITYedge to it. - For
host_header_bypassfindings, also attach to theIP— the IP is the vulnerable surface (the routing inconsistency is server-side), so the finding shows up in IP-level dashboards too. - Create a defensive
Subdomainwithsource="vhost_sni_enum"and attach theVulnerabilityto it. This guarantees every finding is reachable from the graph page.
Properties written on each Vulnerability
| Property | Value |
|---|---|
id | deterministic vhost_sni_<host_sanitised>_<ip>_<port>_<layer> |
user_id, project_id | tenant isolation |
source | "vhost_sni_enum" |
type | "hidden_vhost" (L7-only) / "hidden_sni_route" (L4-only) / "host_header_bypass" (both layers disagree) |
name | e.g. "Hidden Virtual Host: admin.example.com" |
severity | high / medium / low / info |
description | human summary including the discovered hostname, IP, port, layer, and any internal-keyword match |
hostname | lowercased FQDN of the discovered vhost |
host | same as hostname (for query convenience) |
ip | target IP that hosts the vhost |
port | target port |
scheme | "http" / "https" |
layer | "L7" / "L4" / "both" |
baseline_status, baseline_size | the bare-IP response used as comparison |
observed_status, observed_size | the response with the host/SNI lie applied |
size_delta | signed difference in bytes |
internal_pattern_match | matched internal-keyword (e.g. "admin", "jenkins") or null |
matched_at | the URL form (https://admin.example.com[:port]) |
is_dast_finding | false |
first_seen, last_seen | ISO timestamps |
Rules of Engagement
VHost & SNI inherits the project-level RoE applied during GROUP 1 (subdomain discovery) and GROUP 3 (port scanning). Targets already filtered by ROE_EXCLUDED_HOSTS never reach this module because the input lists come straight from port_scan.by_host and dns.subdomains.
The module sends real HTTP traffic to the target, so it is treated as active and respects the same in-scope/out-of-scope rules as the rest of the active-scan tools.
Recon presets
All 22 recon presets have an explicit decision recorded:
| Preset | VHost & SNI | Rationale |
|---|---|---|
| api-security | enabled | API gateways often hide behind reverse proxies + SNI routing |
| bug-bounty-deep | enabled | Bug-bounty staple — hidden vhosts are gold |
| bug-bounty-quick | enabled (graph-only, no default wordlist) | Speed-tuned: only data-driven candidates |
| cloud-exposure | enabled | k8s ingress / Cloudflare = SNI routing is the deciding factor |
| compliance-audit | enabled | host_header_bypass IS a compliance finding |
| cve-hunter | enabled | More attack surface = more CVE matches |
| directory-discovery | enabled | Hidden vhosts are hidden HTTP surfaces (same discovery class as ffuf dirs) |
| full-active-scan | enabled | "Every active tool at max intensity" |
| full-maximum-scan | enabled (concurrency 40, max candidates 5,000) | Everything maxed |
| graphql-recon | enabled | GraphQL endpoints often live on internal vhosts |
| infrastructure-mapper | enabled | Reveals reverse-proxy / ingress topology |
| parameter-injection | enabled | More endpoints = more parameters to fuzz |
| red-team-operator | enabled (L4 off, no default wordlist, concurrency 5) | Stealth-tuned: graph-only candidates, low concurrency, L7-only |
| secret-hunter | enabled | Admin panels (hidden vhosts) often leak secrets |
| subdomain-takeover | enabled (concurrency 30) | Aggressive — kindred concept (both expose hidden infrastructure) |
| web-app-pentester | enabled | Webapp pentest staple |
| full-passive-scan | explicit false | Preset's identity is "no packets to target" |
| osint-investigator | explicit false | "No active scanning" |
| stealth-recon | explicit false | 2,380 probes would be catastrophically slow and noisy |
| large-network | explicit false | Per-IP serial loop × thousands of IPs = days |
| dns-email-security | leave default | DNS/email-only, not a web preset |
| secret-miner | leave default | Pure JS analysis, no host discovery |
The decision matrix above is enforced by a content test in webapp/src/lib/recon-presets/vhost-sni.test.ts — adding a new preset that doesn't make a deliberate decision will fail CI.
Stealth mode
VHost & SNI does not currently apply automatic stealth-mode overrides at the runtime layer. Stealth tuning is handled at the preset layer instead — the red-team-operator preset sets VHOST_SNI_TEST_L4=false, VHOST_SNI_USE_DEFAULT_WORDLIST=false, and VHOST_SNI_CONCURRENCY=5 to keep noise low. The stealth-recon preset disables the module entirely.
If you build a custom stealth preset, the recommended quiet configuration is:
VHOST_SNI_ENABLED=true
VHOST_SNI_TEST_L7=true
VHOST_SNI_TEST_L4=false # SNI brute is louder
VHOST_SNI_USE_DEFAULT_WORDLIST=false # 2,380 probes = noisy
VHOST_SNI_USE_GRAPH_CANDIDATES=true # only test what's in the graph
VHOST_SNI_CONCURRENCY=5 # slow + quiet
VHOST_SNI_INJECT_DISCOVERED=true # still wire findings into the graph
Partial recon
VHost & SNI is available as a Partial Recon tool (tool_id="VhostSni"). The modal accepts two user-input types:
- Custom subdomains — added as candidate hostnames (must be in scope: equal to apex or end with
.<apex>) - Custom IPs — added as extra targets to probe (validated as IPv4 or CIDR /24-/32)
Partial-recon run behaviour:
VHOST_SNI_ENABLEDis force-set totruefor the run, overriding the project toggle.settings_overridesfrom the modal bypass stored project settings for that run.- Custom subdomains are resolved via the system resolver; resolved IPs are added as additional targets.
- Custom IPs without an attachment subdomain become a
UserInputnode withtool="VhostSni"and aPRODUCEDedge to each IP. - Graph-existing targets can be included or excluded via the Include existing graph targets checkbox. When both graph and custom inputs are empty, the run exits with a "no IP targets" 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
- No new dependencies. The module uses only
curl(already in the recon container base image) andhttpx(already pulled by GROUP 4). Norequirements.txtchange, no new Dockerfile entry, no new API key. - Deterministic output across reruns. Set iteration of
INTERNAL_KEYWORDSis non-deterministic across Python invocations, so the matcher collects all matches per label and picks the longest (lex tiebreak). Two runs against the same target produce the same finding IDs and the same severities. - BOM-safe wordlist loader. The default wordlist is read with
encoding="utf-8-sig"so a Windows-edited file with a UTF-8 BOM doesn't corrupt the first entry. - Hostname validator uses
\Z, not$. Python's$matches before a trailing\n, which would letevil\n.example.comslip past validation and inject HTTP headers viacurl --resolve. The module uses\Z(absolute end of string) explicitly. - Scheme override honoured. When the upstream port spec carries an explicit
schemefield (e.g. http_probe knows9001speaks HTTPS), the module honours it instead of re-deriving from the port number. - Subprocess-list invocation. Every curl call is
subprocess.run([...], shell=False)with the argument list — no shell, no string interpolation. Defense-in-depth: hostname validation rejects shell metacharacters even though they couldn't reach a shell. - Per-port baseline. Each (IP, port) combination gets its own baseline. A failed baseline skips that port only — other ports on the same IP still get scanned.
- Per-IP serial loop. Inter-IP parallelism is intentionally not exposed; per-port concurrency dominates wall time for typical targets.
Test coverage
The module ships with 282 tests across 11 files (~4,000 lines, ~282/282 passing):
| File | Container | Focus |
|---|---|---|
recon/tests/test_vhost_sni_enum.py | orchestrator | 102 unit + module-integration tests for every helper and the end-to-end runner with mocked curl |
recon/tests/test_vhost_sni_smoke.py | orchestrator | 10 real-curl + real-HTTP-server smoke tests against an in-process multi-vhost server |
recon/tests/test_vhost_sni_tls_smoke.py | orchestrator | 7 tests against a real TLS server with a self-signed cert (validates --resolve actually steers SNI) |
recon/tests/test_vhost_sni_edge_cases.py | orchestrator | 46 hostname-injection-safety, wordlist-encoding-edge, curl-output-parsing, and finding-multiplicity tests |
recon/tests/test_vhost_sni_stress.py | orchestrator | 6 concurrency stress tests (200 candidates, max-inflight cap, no thread leaks) |
recon/tests/test_vhost_sni_regression.py | both | 28 source-level wiring + settings + pipeline registration tests |
recon/tests/test_vhost_sni_partial.py | orchestrator | 14 partial-recon orchestration tests |
recon/tests/test_vhost_sni_graph.py | agent | 8 graph-mixin tests against real Neo4j (idempotency, multi-tenancy, defensive node creation) |
webapp/src/lib/recon-presets/vhost-sni.test.ts | webapp | 34 Zod-schema, RECON_PARAMETER_CATALOG, and per-preset content tests (covers all 22 presets) |
webapp/src/components/projects/ProjectForm/vhostSniWiring.test.ts | webapp | 16 workflow-definition + nodeMapping + tooltip wiring tests |
webapp/src/lib/report/vhostSniReport.test.ts | webapp | 17 report-data type-shape + risk-score-formula + reportTemplate.ts structure tests |
webapp/src/lib/vhostSniPrisma.test.ts | webapp | 4 real Prisma round-trip tests against PostgreSQL (defaults, write-then-read, large text column, snake_case mapping) |
Related pages
- Project Settings Reference -- VHost & SNI Enumeration -- full parameter tables
- Recon Pipeline Workflow -- pipeline diagram
- Running Reconnaissance -- execution model
- Subdomain Takeover Detection -- sister GROUP 6 Phase A scanner (kindred concept: both expose hidden infrastructure)
- GraphQL Security Testing -- another sibling GROUP 6 Phase A scanner
- Recon Presets -- full preset list with VHost & SNI defaults per preset
- Rules of Engagement -- host exclusion rules applied before probes fire