Ports (rmcp MCP SDK, Recog, JARM/JA3, SecLists) + mass-scan fixes + HTTP pooling + per-run output auto-save

Ports:
- MCP server migrated to official rmcp SDK (v1.7); all 29 tools/7 resources preserved
- Recog (Rapid7) fingerprint engine + DBs, wired into service_scanner
- JARM + JA3/JA3S TLS fingerprinting (tls_fingerprint) + jarm_scan module
- SecLists checksum-pinned wordlist catalog

Fixes / features:
- Full-internet sweep host-cap consistency
- Pre-config confirm-before-harvest + ModuleCtx.prompt_only mode; service_scanner
  batch-mode output gating
- HTTP client connection-pool reuse (cached) + bounded pool_idle_timeout
- show options: +scan_order/exclusions/target_rps/module_rps
- NEW: per-run output auto-save to ~/.rustsploit/loot/<module> <time> results.txt
  (append mode, all stdout+stderr; src/results_sink.rs)
- docs: drop stale check()/CheckResult + build.rs references
- assorted module additions + tommy interactive guide

Build: restore Cargo.lock (clean clone was unbuildable without it: transitive
cookie/time conflict). Working dir: clean build (0 warnings), 40/40 tests green.
This commit is contained in:
s-b-repo
2026-06-13 01:09:36 +02:00
parent 929d39ebc4
commit 05cfdc6d22
45 changed files with 11450 additions and 310 deletions
Generated
+4499
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -142,6 +142,7 @@ hex = "0.4"
# Dependency overrides to address security advisories in transitive dependencies
# RUSTSEC-2026-0009: time >=0.3.47 fixes DoS via stack exhaustion (used by reqwest via cookie/cookie_store)
time = "0.3.47"
rmcp = { version = "1.7.0", features = ["server", "transport-io", "transport-async-rw"] }
# (ratatui 0.29 transitive advisories cleared when the TUI was replaced with rustyline.)
# ============================================
+3 -3
View File
@@ -37,9 +37,9 @@ Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a
## Highlights
- **Auto-discovered modules:** `build.rs` indexes `src/modules/**` — drop in new code, no manual registration needed
- **Self-registering modules:** modules register at compile time via the `inventory` crate — add the file, a `register_native_module!` call, and a `pub mod <name>;` line in the parent `mod.rs`; no build-script indexer
- **Interactive shell:** 40+ commands with shortcuts, command chaining (`&`), tab completion, and command history
- **Module metadata:** Optional `info()` and `check()` functions per module — CVE references, author, rank, non-destructive vulnerability verification
- **Module metadata:** `info()` per module — CVE references, author, rank. The framework is exploitation-only: modules run an exploit and report findings (there is no `check()` / non-destructive verification phase)
- **Global options (`setg`):** Persistent key-value settings that apply across all modules — like Metasploit's datastore
- **Credential store:** Track discovered credentials across sessions with `creds` commands and JSON persistence
- **Host/service tracking:** Workspace-based engagement tracking with `hosts`, `services`, `notes` commands
@@ -53,7 +53,7 @@ Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer, proxy scanner, reflect scanner, vulnerability checker
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
- **MCP server:** 38-tool Model Context Protocol server for AI-assisted pentesting via stdio
- **Plugin system:** Third-party modules via `src/modules/plugins/` with build-time discovery and startup safety warnings
- **Plugin system:** Third-party modules via `src/modules/plugins/` with compile-time `inventory` self-registration and startup safety warnings
- **Security hardened:** Input validation, path traversal protection, honeypot detection, root privilege checks, spool symlink protection, memory-safe operations
- **IPv4/IPv6 ready:** Both address families work out-of-the-box across all modules
+352
View File
@@ -0,0 +1,352 @@
================================================================================
RUSTSPLOIT — RELEASE NOTES
Offensive-security framework (RouterSploit/Metasploit-inspired), single binary.
One module library across four interfaces: interactive shell, CLI runner,
PQ-encrypted REST/WebSocket API, and an MCP server.
Build date : 2026-06-13
Toolchain : Rust edition 2024 (clang + lld)
Build : cargo build --bin rustsploit -> clean (0 errors, 0 warnings)
Modules : 389 self-registering native modules
================================================================================
OVERVIEW
--------------------------------------------------------------------------------
This release ports four well-known upstream projects into Rustsploit, fixes a
cluster of mass-scan / full-internet-sweep usability bugs, and lands a
framework-wide HTTP performance improvement.
All ported components are permissively licensed (BSD-2 / BSD-3 / Apache-2.0 /
MIT) and were vendored or added as dependencies accordingly. Every change below
was compiled and tested locally; the verification summary is in section 5.
================================================================================
1. NEW CAPABILITIES (UPSTREAM PORTS)
================================================================================
[1.1] MCP server migrated to the official `rmcp` SDK (Apache-2.0)
--------------------------------------------------------------------------------
* Replaced the hand-rolled JSON-RPC-over-stdio MCP server (src/mcp/server.rs)
with the official Model Context Protocol Rust SDK, rmcp v1.7.0.
* The existing tool/resource logic (src/mcp/tools.rs, resources.rs, types.rs)
is preserved unchanged. server.rs is now a thin adapter implementing rmcp's
`ServerHandler` trait; protocol framing, transport, and spec compliance are
owned by the SDK (giving correct resources/prompts/progress support going
forward instead of a bespoke protocol).
* All 29 tools and 7 resources retained. The per-call execution timeout
(env RUSTSPLOIT_MCP_TIMEOUT_SECS, default 300s) and the stdout-isolation
guard (the protocol channel is dup'd off fd 1 and fd 1 redirected to
/dev/null, so stray println! can never corrupt the JSON-RPC stream) are
both kept.
* Validated end-to-end over stdio: the `initialize` handshake returns
serverInfo = rustsploit-mcp 0.5.0, negotiates protocolVersion down to the
client's requested 2024-11-05, and `tools/list` advertises all 29 tools.
* Dependency added to Cargo.toml:
rmcp = { version = "1.7", features =
["server", "transport-io", "transport-async-rw"] }
[1.2] Recog fingerprint engine (Rapid7, BSD-2-Clause)
--------------------------------------------------------------------------------
* New src/utils/recog.rs: an XML fingerprint-database loader + matcher modeled
on Rapid7 Recog (recog-go was the reference for the match loop). Parses the
Recog <fingerprint>/<param> schema with quick-xml, pre-compiles every regex
behind once_cell::Lazy, and resolves a banner to structured fields
(service.product / .version / .vendor, os.product, service.cpe23, ...).
* Matcher semantics follow real Recog: a <param> carrying a non-empty `value`
is a literal/interpolated assignment (e.g.
cpe:/a:vendor:product:{service.version}); a <param> with no value reads
regex capture group `pos`. Capture params are resolved in a first pass so
that {field} placeholders in templates always bind, regardless of param
order or any `pos` attribute on the template.
* Vendored databases under src/utils/recog_db/ (embedded via include_str!):
ssh_banners, ftp_banners, smtp_banners, http_servers, mysql_banners. A
BSD-2-Clause credit header points at upstream.
* Wired into src/modules/scanners/service_scanner.rs: after each banner read
(FTP, SSH, SMTP, MySQL, and the HTTPS `Server:` header) the matcher enriches
the detected version with a structured product/version + CPE; findings still
flow through the existing FindingKind::Banner path.
* 19 unit tests assert extracted fields against real example banners
(OpenSSH/Dropbear, ProFTPD/vsftpd, Exim, Apache/IIS, MySQL/MariaDB, plus the
malformed-regex-skip and unknown-db cases).
* NOTE: the vendored DBs are curated subsets authored to the exact upstream
schema (the porting agent had no outbound network access to fetch the full
upstream XML). The complete Rapid7 DBs (5 files, ~770 KB, ~1024 fingerprints)
are staged and can be dropped in for full coverage — see section 6.
[1.3] JARM + JA3 / JA3S TLS fingerprinting (Salesforce, BSD-3-Clause)
--------------------------------------------------------------------------------
* New src/utils/tls_fingerprint.rs:
- JARM: 10 hand-crafted TLS ClientHello probes ported from Salesforce
jarm.py (TLS 1.2/1.3, multiple cipher orderings, GREASE on/off, ALPN
variants, extension permutations). Probes are sent over a raw tokio
TcpStream; each ServerHello is parsed (fully bounds-checked) and the
results are assembled into the canonical 62-character JARM hash.
- JA3 / JA3S string builders + MD5 (client- and server-side fingerprints),
with GREASE stripping per spec.
- Robust failure handling: a down host, a TLS alert, or a truncated
response degrades to the canonical all-zero JARM hash; the ServerHello
parser returns None on garbage input and never panics.
* New module src/modules/scanners/jarm_scan.rs (default port 443) reports the
JARM hash, JA3S, and client JA3 as findings.
* src/modules/scanners/ssl_scanner.rs was intentionally NOT modified: it uses
rustls, which hides the raw ServerHello bytes JA3S requires. JA3S is exposed
via tls_fingerprint for callers (such as jarm_scan) that hold raw bytes.
* 14 unit tests: JA3/JA3S string + MD5 (verified with md5sum), JARM hash
assembly, and parser bounds-safety on empty/truncated/garbage input.
[1.4] SecLists wordlist catalog (MIT)
--------------------------------------------------------------------------------
* Seeded the previously-empty, checksum-pinned wordlist catalog in
src/utils/wordlist.rs with 6 curated SecLists entries. Each SHA-256 was
computed over the exact raw upstream bytes (the same bytes resolve() hashes
after download):
passwords-top-1k Passwords/Common-Credentials/Pwdb_top-1000.txt
passwords-top-10k Passwords/Common-Credentials/Pwdb_top-10000.txt
usernames-short Usernames/top-usernames-shortlist.txt
web-common Discovery/Web-Content/common.txt
web-raft-small-dirs Discovery/Web-Content/raft-small-directories.txt
subdomains-top5k Discovery/DNS/subdomains-top1million-5000.txt
* resolve(name) downloads + SHA-256-verifies on first use into
~/.rustsploit/wordlists/ (rejecting any checksum mismatch). 2 no-network
tests assert every catalog entry is well-formed: https URL, 64-hex SHA-256,
non-empty name/local_name, and unique names.
================================================================================
2. BUG FIXES (MASS-SCAN / FULL-INTERNET SWEEP)
================================================================================
[2.1] Full-internet sweep host-cap consistency
--------------------------------------------------------------------------------
* `setg target 0.0.0.0` auto-raised max_random_hosts to the full public-IPv4
count, but a literal `0.0.0.0/0` typed into `use <mod>; run` never passed
through that handler and fell back to the scheduler default of 10,000 —
silently capping a "scan everything" target at 10k hosts.
* Fixed in src/scheduler.rs at the single Target::Random dispatch chokepoint:
when the operator has NOT explicitly set max_random_hosts, a full sweep now
means every reachable public host on both entry paths. An explicit
`setg max_random_hosts <n>` is still honored, and the per-sweep advisory +
interactive confirmation gate is unchanged.
[2.2] Mass-scan pre-config: confirm BEFORE harvest, and silence the placeholder
--------------------------------------------------------------------------------
The pre-batch prompt phase runs the selected module once against a placeholder
host (0.0.0.1) to harvest the operator's answers once for the whole batch.
Two problems were fixed in src/scheduler.rs:
(a) ORDER: the harvest ran BEFORE the full-sweep confirmation, so the
placeholder was effectively "scanned" (and, for service_scanner, a
results file was written) before the operator ever confirmed the sweep.
The full-sweep advisory + confirmation now runs FIRST on both the random
and sequential sweep paths; declining aborts with nothing touched.
(b) NOISE: the harvest run leaked module output
("Scanning 0.0.0.1 ... 0 services detected ... Results saved"). The
harvest run is now wrapped in a throwaway OUTPUT_BUFFER scope so its
mprintln!/meprintln! output is suppressed, while the interactive prompts
(which print via real stdout) remain visible.
RESIDUAL: the harvest still executes the module against the placeholder host
(now silently). Removing that entirely requires a per-module "prompt-only"
mode — see section 6.
[2.3] service_scanner mass-scan output spam + file-save race
--------------------------------------------------------------------------------
* In a mass-scan fan-out the module runs once per host and previously printed
its full single-target block for EVERY host — "No services detected",
"Results saved", "Scan complete: 0 services detected on <host>" — which
garbled output across concurrent tasks and (worse) had every host racing to
overwrite the same results file.
* These are now gated on `!is_batch_mode()`:
- only hosts that actually have services print their results table,
- the per-host file save is skipped in batch mode (findings flow through
the module outcome instead, avoiding the concurrent-overwrite race),
- the redundant per-host "No services" / "Results saved" / "Scan complete"
status lines are suppressed.
Interactive single-target runs are unchanged.
[2.4] `show options` listed only a subset of global options
--------------------------------------------------------------------------------
* Added the four missing global keys to the shell's `show options` table:
scan_order, exclusions, target_rps, module_rps.
================================================================================
3. PERFORMANCE
================================================================================
[3.1] HTTP client connection-pool reuse (framework-wide)
--------------------------------------------------------------------------------
* 184 of the ~304 reqwest-client construction sites go through the shared
helper build_http_client(timeout), which rebuilt a fresh reqwest::Client on
every call — re-initialising the TLS configuration and allocating a brand
new (empty) connection pool. In an HTTP-based mass scan that is once per
host; in normal use it is once per module run.
* src/utils/network.rs::build_http_client now caches the permissive client,
keyed by (timeout, accept_invalid_certs), and returns cheap Arc clones —
reqwest clients are Arc internally and share their connection pool across
clones, so warm connections and TLS setup are reused across runs.
* Correctness: accept_invalid_certs is part of the key because permissive()
derives it from the live `strict_tls` global; toggling `setg strict_tls` is
therefore still honored rather than pinned to whatever it was on first build.
The client is built outside the lock so a slow build() can't serialise other
callers, and a poisoned lock is recovered (the map holds only cloneable
clients) rather than panicking into every HTTP caller.
* Custom-opts clients (cookies / headers / redirects via
build_http_client_with) are unchanged — those options cannot be shared
safely or keyed cheaply.
================================================================================
4. FILES CHANGED
================================================================================
New files:
src/mcp/server.rs rewritten as an rmcp adapter
src/utils/recog.rs Recog matcher (~590 lines)
src/utils/recog_db/*.xml 5 vendored fingerprint databases
src/utils/tls_fingerprint.rs JARM / JA3 / JA3S (~1270 lines)
src/modules/scanners/jarm_scan.rs new JARM scanner module (~127 lines)
Modified files:
Cargo.toml + rmcp dependency
src/module.rs + ModuleCtx.prompt_only flag (6.1)
src/scheduler.rs sweep host-cap consistency,
confirm-before-harvest reorder,
harvest output capture,
set prompt_only on harvest ctx
src/modules/scanners/service_scanner.rs Recog enrichment wiring +
batch-mode output gating +
prompt_only early return (6.1)
src/utils/network.rs HTTP client cache +
pool_idle_timeout (6.2)
src/utils/recog.rs two-pass matcher fix (value
templates win over `pos`)
src/utils/recog_db/mysql_banners.xml MariaDB regex colon fix
src/utils/tls_fingerprint.rs corrected JA3/JA3S test constants
src/utils/wordlist.rs SecLists catalog + 2 tests
src/utils/mod.rs + pub mod recog;
+ pub mod tls_fingerprint;
src/modules/scanners/mod.rs + pub mod jarm_scan;
src/shell.rs show options: + 4 global keys
README.md, docs/BAD_PATTERNS.md stale-docs corrections (6.3)
================================================================================
5. VERIFICATION
================================================================================
* cargo build --bin rustsploit : clean — 0 errors, 0 warnings.
* MCP : live stdio handshake validated — initialize returns
serverInfo=rustsploit-mcp 0.5.0, protocolVersion negotiated to
2024-11-05, tools/list returns all 29 tools.
* Tests : new-port + core suites green —
cargo test --bin rustsploit -- recog tls_fingerprint jarm wordlist
cyclic ja3 => 40 passed; 0 failed.
* Banned-pattern audit (scripts/audit-bad-patterns.sh) : no new hits
introduced in any changed file.
PRE-RELEASE FIXES (found and fixed in this release):
The four ports were authored by isolated agents that could not execute the
test runner in their sandbox, so 7 tests shipped failing and were caught +
fixed here:
- 3 JA3/JA3S tests had fabricated "known vector" MD5 constants; the
implementation was correct (the real md5 of the JA3/JA3S strings matches
what the code produces) — the bogus expected constants were corrected.
- 4 Recog tests exposed a real matcher bug: a <param> with both a `value`
template and a `pos` attribute had its template ignored (the bare
capture group was stored instead of the interpolated CPE), plus a
MariaDB regex that rejected a colon in the distro suffix. Both fixed.
================================================================================
6. FOLLOW-UP PASS (completed)
================================================================================
The four follow-ups noted at first cut were all worked. Three are done; the
fourth was investigated and intentionally deferred with a precise finding.
[6.1] Pre-config harvest now has a per-module "prompt-only" mode DONE
* Added ModuleCtx.prompt_only (src/module.rs). The scheduler sets it on the
harvest dry-run ctx (src/scheduler.rs). A module that honours the flag
answers its cfg_prompt_* calls and then returns immediately — no network
work, no file writes against the placeholder host.
* src/modules/scanners/service_scanner.rs is the reference implementation: it
returns right after gathering prompts when prompt_only is set, so the
placeholder host (0.0.0.1) is no longer scanned at all during harvest. The
output-capture guard remains as belt-and-suspenders for modules that don't
yet check the flag; other modules can adopt the same one-line early return.
[6.2] HTTP client cache: idle-connection lifetime bounded DONE
* Added HttpClientOpts.pool_idle_timeout (src/utils/network.rs). The cached,
long-lived permissive client now sets pool_idle_timeout = 30s, so a
full-internet sweep reaps idle keep-alives to stale hosts instead of letting
them accumulate. Custom-opts clients default to reqwest's ~90s unless they
opt in.
[6.3] Docs drift corrected DONE
* README.md and docs/BAD_PATTERNS.md prose updated to reflect the
exploitation-only model (no check()/CheckResult) and compile-time `inventory`
self-registration (no build.rs module indexer). The BAD_PATTERNS regex
matrix is driven by hardcoded bash arrays in the audit script — confirmed
untouched, so the lint is unaffected.
[6.4] Recog full-DB swap: investigated, DEFERRED (it is a feature, not a swap)
* The full upstream Rapid7 DBs (5 files, ~770 KB, ~1024 fingerprints) were
dropped in and the Recog tests re-run: 13/20 failed. Root cause is NOT a test
issue — the real DBs are not drop-in compatible with the current wiring:
- The real SSH patterns are anchored on the version COMMENT
(e.g. ^OpenSSH_(...)$), i.e. they expect the post-"SSH-2.0-" substring,
not the full banner line the scanner currently feeds them.
- Real field names/values differ from the curated set (e.g. Apache yields
service.product = "HTTPD", not "HTTP Server").
So adopting the full DBs requires a per-DB INPUT-NORMALISATION layer (feed
each DB the exact field it expects) plus downstream handling of real Recog
field names — a feature, not a data swap. Shipping the real DBs as-is would
silently break matching end-to-end (raw banners vs. normalised-input
patterns => zero matches in practice).
* DECISION: kept the curated, internally-consistent DBs (all 19 Recog tests
pass; wiring + patterns + tests agree). The full-DB adoption is tracked as a
future feature with the concrete design above. The real DBs remain staged.
================================================================================
6c. NEW: per-run output auto-save
================================================================================
* Every interactive console / CLI module run now auto-appends ALL of its
output (stdout + stderr) to a per-run file:
~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt
Files are opened in APPEND mode, so multi-host mass-scan output accumulates
into the one run file instead of racing to overwrite (the original
service_scanner "save to file" bug). New module: src/results_sink.rs — a
global append sink mirroring the spool's write pattern (so spawned per-host
task output is captured), hooked into the console branches of the
mprintln!/meprintln! routing in src/output.rs and begun/ended per run in
commands::run_module. Scoped to console/CLI runs (sequential); API / MCP
runs return their output to the caller via OUTPUT_BUFFER and are not
duplicated to disk. Verified end-to-end (file created, headered, appended).
* Build fix bundled: Cargo.lock restored. The branch's previous
"Delete Cargo.lock" commit left a binary crate that does NOT build from a
clean clone (transitive `cookie`/`time` version conflict on fresh
resolution); the verified lock is re-added so `git clone && cargo build`
works again.
================================================================================
6b. REMAINING KNOWN LIMITATIONS
================================================================================
* Recog coverage is the curated subset until the input-normalisation layer in
[6.4] is built.
* Modules other than service_scanner do not yet check ModuleCtx.prompt_only;
during harvest their output is suppressed but they still execute (cheaply).
Adopting the one-line early return per module closes this fully.
================================================================================
7. LICENSES (PORTED COMPONENTS)
================================================================================
Recog (Rapid7) BSD-2-Clause
JARM / JA3 / JA3S (Salesforce) BSD-3-Clause
rmcp (MCP Rust SDK) Apache-2.0
SecLists MIT
ZMap address iterator Apache-2.0 (previously ported; unchanged)
================================================================================
END OF RELEASE NOTES
================================================================================
+154
View File
@@ -25500,3 +25500,157 @@ The implementation is complete across all files. The build is running in the bac
I kept the no-error-swallowing discipline throughout (the new checkpoint helpers and the max_random_hosts parse
log/warn rather than discard). I'll confirm the clean build result when it completes, then update the docs/memory.
- TTY console (the operator's real shell): a prompt held for 6s against a 2s deadline survived — answered normally
and ran to its natural network result. This is exactly the wpair/asyncssh scenario, now fixed.
- Piped/scripted stdin: the deadline still fires at 2s — the automation safety net is preserved.
Let me clean up the test artifacts and finalize.
● Bash(rm -rf /tmp/rs_clean /tmp/rs_clean2 /tmp/pty_test.py /tmp/pty_test2.py; echo "cleaned"; echo "=== final diff
summary ==="; cd /home/kali/Downloads/rustpre2-main…)
 cleaned
=== final diff summary ===
152: if trimmed.contains('/')
… +5 lines (ctrl+o to expand)
 Allowed by auto mode classifier
● All fixes are in and verified. Let me record the design decision in memory so it isn't regressed later, since it's
a non-obvious behavioral contract.
Read 1 file, recalled 1 memory, wrote 2 memories (ctrl+o to expand)
● All fixes are implemented, compiled, and verified. Here's what was wrong and what I changed.
Root causes
Three separate bugs were behind your reports — but two share one root cause: the per-target deadline was killing
interactive operations.
1. wpair / asyncssh "timed out after 5s" + the lag — scheduler.rs::fanout_single wrapped modules in the per-host
deadline even for a single console target. asyncssh prompts for a username inside run(); when you take longer than
the deadline to type, the deadline fires. Worse: prompts read stdin via spawn_blocking, which cannot be cancelled
— so killing the run orphans the reader thread, which then fights the shell for your next keystrokes. That's the
"lag after this error." (wpair was already marked interactive, but your binary predated that wiring — it needed a
rebuild.)
2. target 0.0.0.0 → 0.0.0.0/32 — config.rs::set_target parsed a bare IP with IpNetwork, which silently widens it
to /32. Every single IP was being stored as a subnet, routing it through CIDR fan-out and tripping
is_mass_scan_target (which skips honeypot detection).
Fixes (in src/)
- config.rs::set_target — only treat a target as a subnet when it actually contains /. Bare IPs stay single hosts
(matches Target::parse).
- scheduler.rs::fanout_single — enforce the deadline only when there's no human at the console: !interactive &&
(api_mode || !stdin.is_terminal()). An interactive TTY is exempt (prompts/REPLs run unbounded, you Ctrl-C);
API/MCP/jobs and piped/scripted CLI stay bounded.
- scheduler.rs::pre_batch_prompt — bumped the prompt-collection dry-run timeout 5s→300s (same orphaned-stdin
class; 5s could cut you off mid-prompt during mass-scan setup).
Verified (cargo build clean, then runtime tests)
┌───────────────────────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ Test │ Result │
├───────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ target 0.0.0.0 / 192.168.1.5 │ stay single hosts, no /32; 10.0.0.0/24 still a /24 │
│ │ subnet ✓ │
├───────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ asyncssh + wpair over a real PTY, prompt held 6s vs a │ no timeout — ran to natural network result ✓ │
│ 2s deadline │ │
├───────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ Same over piped stdin (scripted) │ deadline still fires at 2s — automation safety net │
│ │ preserved ✓ │
└───────────────────────────────────────────────────────┴─────────────────────────────────────────────────────┘
Your rebuilt binary is at target/debug/rustsploit. The wpair REPL, asyncssh's username prompt, and any other
module that prompts mid-run will no longer be cut off (or leave the shell laggy) when you run them interactively,
while mass scans and API/automation runs keep their per-host time bounds.
One note from testing: your environment has 54 persisted global options in ~/.rustsploit/global_options.json —
including a port that made asyncssh connect to :18080. If that's unintended, setg port 22 (or unset it) will fix
it.
● Both of your requests are done, built clean, and verified.
1. Removed the error swallowing
- scheduler.rs — a bad setg port was silently dropped via .and_then(...ok()). Now it's an explicit match that
prints [!] Ignoring invalid 'setg port' value '<x>': <err> so you actually see why a different port got used.
- config.rs — the 0.0.0.0 handling used if let Ok(net) = "0.0.0.0/0".parse() which would silently fall through on
failure. Now it parses with .map_err(...)? and propagates.
2. Ported ZMap's mass-scanning to Rust
The old random scanner sampled IPs and remembered every one in an unbounded HashSet — the codebase itself notes
that "does not scale to billions." That's exactly the problem ZMap solved.
src/utils/cyclic.rs (CyclicIp) — ported from ZMap's cyclic.c (Apache-2.0). It treats the IPv4 space as the
multiplicative group mod P = 4,294,967,311 (smallest prime > 2³²) and walks it by repeatedly multiplying by a
primitive root. That visits every address exactly once, in pseudo-random order, with O(1) memory and zero repeats
— no dedup table. Wired into fanout_random, replacing the sample-and-HashSet loop (generate_random_public_ip is
kept; the avtech module still uses it).
The number theory is unit-tested and passing (4/4): it verifies the prime factorization of P-1 is complete, that
the chosen generator really is a primitive root, and that a 200k-address prefix has no repeats.
Plus the earlier-turn work that shipped with it
- 0.0.0.0 now means the full-internet sweep (== 0.0.0.0/0 == random), with the interactive confirmation you chose.
Verified over a PTY: target 0.0.0.0 → normalizes to 0.0.0.0/0, prints the advisory, asks "Proceed with the
full-internet sweep? [n]", and declining aborts before any host is touched.
- Advisory before every full sweep showing all four you selected — effective port (defaults to the module's
default_port, with provenance when a global setg port overrides it), concurrency + host cap, per-host timeout, and
exclusion count.
- Automation safety: API/MCP and piped runs print the advisory but skip the confirm and proceed (verified
non-blocking).
Binary rebuilt at target/debug/rustsploit (zero warnings).
One scope note on the ZMap port: I implemented the address-selection algorithm (the cyclic permutation), which is
the part that makes internet-wide scanning efficient and fits rustsploit's existing async per-host fan-out. ZMap's
other half — stateless raw-packet SYN transmission with a separate receive thread — is a fundamentally different
paradigm from rustsploit's connect-based application-layer modules and would be a much larger rearchitecture. If
you want me to go there (raw AF_PACKET/pnet SYN engine, sharding across nodes), say the word and I'll scope it.
oth of your requests are done, built clean, and verified.
1. Removed the error swallowing
- scheduler.rs — a bad setg port was silently dropped via .and_then(...ok()). Now it's an explicit match that
prints [!] Ignoring invalid 'setg port' value '<x>': <err> so you actually see why a different port got used.
- config.rs — the 0.0.0.0 handling used if let Ok(net) = "0.0.0.0/0".parse() which would silently fall through on
failure. Now it parses with .map_err(...)? and propagates.
2. Ported ZMap's mass-scanning to Rust
The old random scanner sampled IPs and remembered every one in an unbounded HashSet — the codebase itself notes
that "does not scale to billions." That's exactly the problem ZMap solved.
src/utils/cyclic.rs (CyclicIp) — ported from ZMap's cyclic.c (Apache-2.0). It treats the IPv4 space as the
multiplicative group mod P = 4,294,967,311 (smallest prime > 2³²) and walks it by repeatedly multiplying by a
primitive root. That visits every address exactly once, in pseudo-random order, with O(1) memory and zero repeats
— no dedup table. Wired into fanout_random, replacing the sample-and-HashSet loop (generate_random_public_ip is
kept; the avtech module still uses it).
The number theory is unit-tested and passing (4/4): it verifies the prime factorization of P-1 is complete, that
the chosen generator really is a primitive root, and that a 200k-address prefix has no repeats.
Plus the earlier-turn work that shipped with it
- 0.0.0.0 now means the full-internet sweep (== 0.0.0.0/0 == random), with the interactive confirmation you chose.
Verified over a PTY: target 0.0.0.0 → normalizes to 0.0.0.0/0, prints the advisory, asks "Proceed with the
full-internet sweep? [n]", and declining aborts before any host is touched.
- Advisory before every full sweep showing all four you selected — effective port (defaults to the module's
default_port, with provenance when a global setg port overrides it), concurrency + host cap, per-host timeout, and
exclusion count.
- Automation safety: API/MCP and piped runs print the advisory but skip the confirm and proceed (verified
non-blocking).
Binary rebuilt at target/debug/rustsploit (zero warnings).
One scope note on the ZMap port: I implemented the address-selection algorithm (the cyclic permutation), which is
the part that makes internet-wide scanning efficient and fits rustsploit's existing async per-host fan-out. ZMap's
other half — stateless raw-packet SYN transmission with a separate receive thread — is a fundamentally different
paradigm from rustsploit's connect-based application-layer modules and would be a much larger rearchitecture. If
you want me to go there (raw AF_PACKET/pnet SYN engine, sharding across nodes), say the word and I'll scope it.
+4 -4
View File
@@ -28,18 +28,18 @@ non-zero count as a hard failure.
|---|---|---|---|
| A1 | `\.unwrap\(\)` | Panics on `Err`/`None`, takes the whole shell down. | Match explicitly or use `?` with `anyhow::Context`. |
| A2 | `\.expect\(` | Same as `.unwrap()` plus a static message. | `with_context(\|\| format!("...{}...", info))`. |
| A3 | `\.unwrap_or_default\(\)` | Silently turns `Err` into `T::default()` — body becomes `""`, status becomes `0`. Lies about what happened on the wire. | Match arms returning `CheckResult::Error(...)` or propagate via `?` with `Context`. |
| A3 | `\.unwrap_or_default\(\)` | Silently turns `Err` into `T::default()` — body becomes `""`, status becomes `0`. Lies about what happened on the wire. | Match arms returning `Err(anyhow!(...))` or propagate via `?` with `Context`. |
| A4 | `\.unwrap_or\b` | Even on `Option`, hides the None case behind a magic literal (`"?"`, `""`). Use a self-documenting fallback. | `match opt { Some(x) => x, None => "<documented missing case>" }`. |
| A5 | `\.unwrap_or_else\(` | Same shape as A4 with a closure. | Match arms with explicit None handling. |
| A6 | `\.parse\(\)\.unwrap`, `\.parse::<[^>]+>\(\)\.unwrap` | Parse failures panic. | `parse().with_context(\|\| format!("parse {} as {}", input, "u16"))?`. |
| A7 | `\.try_into\(\)\.unwrap` | TryFrom failures panic. | `try_into().with_context(\|\| ...)?`. |
| A8 | `\.first\(\)\.unwrap`, `\.last\(\)\.unwrap`, `\.next\(\)\.unwrap`, `\.iter\(\)…\.unwrap` | Iterator end-of-stream panics. | `match it.next() { Some(x) => x, None => return CheckResult::Error("...".into()) }`. |
| A8 | `\.first\(\)\.unwrap`, `\.last\(\)\.unwrap`, `\.next\(\)\.unwrap`, `\.iter\(\)…\.unwrap` | Iterator end-of-stream panics. | `match it.next() { Some(x) => x, None => return Err(anyhow!("...")) }`. |
| A9 | `\.chars\(\)\.next\(\)\.unwrap`, `\.split\([^)]*\)\.next\(\)\.unwrap` | Empty-string panics. | Same as A8. |
| A10 | `\.position\(.*\)\.unwrap`, `\.iter\(\)\.find\(.*\)\.unwrap` | Search-miss panics. | Match on `Option`. |
| A11 | `\.read_to_string\(.*\)\.unwrap` | I/O failure panics. | `with_context(\|\| format!("read {}", path))?`. |
| A12 | `\.lock\(\)\.unwrap` | Poison panics. | Use `tokio::sync::Mutex` (no poison) or match on the `PoisonError` and either reset or surface. |
| A13 | `\.expect_err`, `\.unwrap_err` | Same panic shape, on the Err side. | Match the `Result` directly. |
| A14 | `panic!\(`, `unreachable!\(`, `todo!\(`, `unimplemented!\(` | Unconditional crash. | Return `Result::Err` (or `CheckResult::Error`) with a descriptive message. |
| A14 | `panic!\(`, `unreachable!\(`, `todo!\(`, `unimplemented!\(` | Unconditional crash. | Return `Result::Err` with a descriptive message. |
| A15 | `\bassert!\(`, `\bassert_eq!\(`, `\bassert_ne!\(` | Production panic on bad input. | Validate at the boundary and return `Err`. Reserve assertions for tests under `#[cfg(test)]`. |
## B. Silent error swallowing — banned outright
@@ -49,7 +49,7 @@ non-zero count as a hard failure.
| B1 | `Err\(_\)` (anonymous) | Drops the error value. Even on `tokio::time::error::Elapsed`, the `Display` impl ("deadline has elapsed") tells the operator *why* the call failed. | `Err(e) => mprintln!("{} timed out: {}", "[-]".red(), e)`. |
| B2 | `Err\(_[a-zA-Z]\w*\)` (underscore-prefixed binding) | Same as B1 — the leading `_` is a "compiler shut up about unused" hack. | Bind without the underscore and `format!` it. |
| B3 | `if let Err\(_` | Same as B1. | Capture the error. |
| B4 | `if let Ok\(` (no `else`) | Drops `Err` silently — `check()` falls through to `NotVulnerable` even though the host was unreachable. | `match …` with explicit `Err(e) => CheckResult::Error(format!("{:#}", e))`. |
| B4 | `if let Ok\(` (no `else`) | Drops `Err` silently — the module reports success even though the host was unreachable. | `match …` with explicit `Err(e) => return Err(anyhow!("{:#}", e))`. |
| B5 | `let\s+_\s*=` | Discards a `Result`. The function "succeeded" because nothing checked. | `if let Err(e) = … { mprintln!(…) }` or `?`. |
| B6 | `let\s+_[a-zA-Z]\w*\s*=.*\.await` | The `_<name>` prefix suppresses unused warnings on a side-effect call. The result is propagated via `?`, so the binding adds nothing. | Bare `func(...).await?;` (no `let`). |
| B7 | `\.map_err\(\|_\|`, `\.or_else\(\|_\|` | Throws away the original error type. | Capture the error and wrap it: `.map_err(\|e\| anyhow!("...: {}", e))`. |
+14
View File
@@ -4,6 +4,20 @@ A high-level summary of significant changes. For the full detailed log, see [`ch
---
## 2026-06-04 — Sequential mass scan + interactive-module timeout fix
- **Sequential mass scan** — new `Target::Sequential(start)` walks the public IPv4 space
`1.0.0.0 → 223.255.255.255` **in order** (random mode unchanged). Trigger with `t seq` /
`t seq:<start-ip>`, or flip `0.0.0.0/0`/`random` into order via `setg scan_order sequential`. Honors
the exclusion list, the service-port precheck, honeypot detection, and concurrency just like the random
sweep. **Unbounded by default** (Ctrl+C to stop) unless `max_random_hosts` is set; a **high-water-mark
checkpoint** (`crate::checkpoint::{read,write,clear}_seq_marker`) makes a killed scan resume from where
it stopped. (`src/scheduler.rs::fanout_sequential`, `src/module.rs`, `src/config.rs`.)
- **Interactive modules no longer time out** — `Capabilities` gained `interactive`; `fanout_single` runs
interactive modules without the per-target `module_timeout`. Fixes `exploits/bluetooth/wpair`'s REPL
being killed with "Module timed out after 5s" when `setg timeout` is low. The `register_native_module!`
macro gained a `…, native, interactive)` arm.
## 2026-06-04 — WhisperPair (Fast Pair ECDH) re-implemented in `exploits/bluetooth/wpair`
The Fast Pair ECDH exploitation flow (CVE-2025-36911) that had been reduced to
+16
View File
@@ -113,6 +113,7 @@ The framework-level dispatcher handles multiple target types transparently for a
| CIDR range | `t 192.168.1.0/24` |
| File of targets | `t /path/to/targets.txt` |
| Random scanning | `t random` or `t 0.0.0.0/0` |
| Sequential scanning | `t seq` (from `1.0.0.0`), `t seq:1.2.3.4` (explicit start) |
All modules benefit from this automatically -- the dispatcher expands multi-target values and invokes the module once per resolved target.
@@ -120,6 +121,20 @@ All modules benefit from this automatically -- the dispatcher expands multi-targ
> scan. Bare `0.0.0.0` is treated as a normal single host, **not** a mass-scan
> keyword, so `t 0.0.0.0` will not launch an internet-wide scan.
### Mass-scan order: random vs. sequential
A full-public-IPv4 sweep can run in two orders, both honoring the exclusion list
(`setg exclusions …`):
- **Random** (default) — `t random` / `t 0.0.0.0/0`: samples random public IPs up
to `max_random_hosts` (default 10,000).
- **Sequential** — `t seq` / `t seq:<start-ip>`, or flip `0.0.0.0/0`/`random` into
order with `setg scan_order sequential`: walks `1.0.0.0 → 223.255.255.255` in
order, skipping excluded/reserved ranges. **Unbounded** by default (Ctrl+C to
stop) unless you set `max_random_hosts`; it checkpoints the high-water IP, so a
killed scan **resumes** from where it left off on the next run. Set
`setg scan_order random` to switch back.
---
## Honeypot Detection
@@ -184,6 +199,7 @@ The shell accepts Metasploit-style option names and maps them to Rustsploit keys
| `target_rps` | `set target_rps 10` | Per-target rate limit |
| `prescan` | `set prescan auto` | Pre-scan tool for CIDR (auto/masscan/zmap/none) |
| `prescan_rate` | `set prescan_rate 1000` | Pre-scan packets per second |
| `scan_order` | `set scan_order sequential` | Mass-scan order for `0.0.0.0/0`/`random`: `random` (default) or `sequential` |
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | wpair: target a single Fast Pair device |
| `adapter` | `setg adapter 1` | wpair: BLE adapter index |
| `scan_secs` | `setg scan_secs 20` | wpair: BLE scan window (3300 s) |
+13
View File
@@ -123,8 +123,21 @@ pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> R
for (k, v) in &scoped_opts {
opts.set(k.clone(), v.clone());
}
// Auto-save: append all of this run's console output to
// `~/.rustsploit/loot/<module> <time> results.txt`. Scoped to interactive
// console / CLI runs (sequential, so a single global sink is race-free);
// API / MCP runs return their captured output to the caller instead.
let autosave = !crate::config::get_module_config().api_mode;
if autosave {
crate::results_sink::begin(&resolved_path);
}
let result = scheduler::run(module, target, opts, verbose).await;
if autosave {
crate::results_sink::end();
}
crate::events::emit(crate::events::ModuleEvent::ModuleFinished {
module: resolved_path,
target: target_str,
+27 -5
View File
@@ -58,15 +58,28 @@ impl GlobalConfig {
return Err(anyhow!("Target cannot contain control characters"));
}
// Mass scan keyword: "random" — store as-is. Bare "0.0.0.0" is NOT a
// mass-scan keyword (M45); it falls through to normal single-host
// handling. "0.0.0.0/0" is handled below as a CIDR subnet.
// Mass scan keyword: "random" — store as-is. "0.0.0.0/0" is handled
// below as a CIDR subnet.
if trimmed == "random" {
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
return Ok(());
}
// Bare "0.0.0.0" is an operator alias for the full-internet sweep
// "0.0.0.0/0" (== "random"), added 2026-06-09 (reverses the earlier M45
// single-host rule). Store it as the /0 subnet so every consumer treats
// it as a mass scan; the scheduler still shows an advisory + an
// interactive confirmation before it actually sweeps.
if trimmed == "0.0.0.0" {
let net: IpNetwork = "0.0.0.0/0"
.parse()
.map_err(|e| anyhow!("internal: failed to parse 0.0.0.0/0 as a network: {e}"))?;
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
*target_guard = Some(TargetConfig::Subnet(net));
return Ok(());
}
// Sequential mass-scan keywords: `seq`/`sequential` (whole public range)
// or `seq:<ip>`/`sequential:<ip>` (explicit start). Stored verbatim;
// `module::Target::parse` turns it into `Target::Sequential`.
@@ -141,8 +154,17 @@ impl GlobalConfig {
return Ok(());
}
// Try to parse as CIDR subnet first
if let Ok(network) = trimmed.parse::<IpNetwork>() {
// Try to parse as CIDR subnet first — but ONLY when an explicit prefix
// is present. `IpNetwork::from_str` silently widens a bare address to a
// host route (`0.0.0.0` -> `0.0.0.0/32`, `10.0.0.1` -> `10.0.0.1/32`),
// which turned every single-IP target into a subnet: it surfaced as
// "Using target: 0.0.0.0/32", routed single hosts through CIDR fan-out,
// and tripped `is_mass_scan_target` (which then skips honeypot
// detection). A bare IP is a single host (M45) — require the '/' so it
// falls through to `TargetConfig::Single` below.
if trimmed.contains('/')
&& let Ok(network) = trimmed.parse::<IpNetwork>()
{
// No size limit enforced here - user can set 0.0.0.0/0 if they want.
// Consumers (looping logic) must handle large subnets responsibly (e.g. via iterators).
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
+2
View File
@@ -14,6 +14,7 @@ mod context;
mod modules;
mod native;
mod shell;
mod tommy;
mod utils;
pub mod checkpoint;
@@ -35,6 +36,7 @@ pub mod prescan;
pub mod rate_limit;
pub mod scheduler;
pub mod spool;
pub mod results_sink;
pub mod workspace;
pub mod ws;
+166 -233
View File
@@ -1,10 +1,17 @@
use anyhow::Context;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use std::sync::Arc;
use super::types::{
InitializeResult, JsonRpcRequest, JsonRpcResponse, ResourcesCapability, ServerCapabilities,
ServerInfo, ToolsCapability,
use anyhow::Context;
use serde_json::{Map, Value};
use rmcp::{
ErrorData as McpError, ServerHandler, serve_server,
model::{
AnnotateAble, CallToolRequestParams, CallToolResult, Content, Implementation,
InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, RawResource,
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
ServerCapabilities, ServerInfo, Tool,
},
service::{RequestContext, RoleServer},
};
/// Read the libc errno value for the current thread, in a portable way.
@@ -77,160 +84,10 @@ fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
Ok(tokio::fs::File::from_std(std_file))
}
/// Run the MCP server over newline-delimited JSON on stdio.
///
/// * **stdin** — reads one JSON-RPC 2.0 request per line.
/// * **stdout** — writes one JSON-RPC 2.0 response per line.
/// * **stderr** — diagnostic logging (stdout is the protocol channel).
pub async fn run_mcp_server() -> anyhow::Result<()> {
let mut protocol_out = isolate_protocol_stdout()
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut line_buf: Vec<u8> = Vec::new();
const MAX_LINE_BYTES: usize = 1024 * 1024;
eprintln!("[MCP] RustSploit MCP server started (stdio transport)");
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
loop {
line_buf.clear();
let n = (&mut reader)
.take(MAX_LINE_BYTES as u64 + 1)
.read_until(b'\n', &mut line_buf)
.await
.context("failed to read from stdin")?;
if n == 0 {
eprintln!("[MCP] stdin closed, shutting down");
break;
}
if line_buf.len() > MAX_LINE_BYTES {
eprintln!(
"[MCP] line exceeded {} bytes without newline — rejecting and closing",
MAX_LINE_BYTES
);
let resp = JsonRpcResponse::error(
None,
-32600,
format!("Request exceeds {} byte line limit", MAX_LINE_BYTES),
);
write_response(&mut protocol_out, &resp).await?;
break;
}
let line = match std::str::from_utf8(&line_buf) {
Ok(s) => s,
Err(e) => {
eprintln!("[MCP] non-UTF-8 input on stdin: {}", e);
let resp = JsonRpcResponse::error(
None,
-32700,
format!("Parse error: input is not valid UTF-8: {}", e),
);
write_response(&mut protocol_out, &resp).await?;
continue;
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
Ok(r) => r,
Err(e) => {
eprintln!("[MCP] parse error: {}", e);
let resp = JsonRpcResponse::error(None, -32700, format!("Parse error: {}", e));
write_response(&mut protocol_out, &resp).await?;
continue;
}
};
eprintln!("[MCP] <- method={}", request.method);
let response = handle_request(request).await;
if let Some(resp) = response {
write_response(&mut protocol_out, &resp).await?;
}
}
Ok(())
}
/// Serialize a response as a single JSON line on the protocol channel.
async fn write_response(
out: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
resp: &JsonRpcResponse,
) -> anyhow::Result<()> {
let mut json = serde_json::to_vec(resp).context("failed to serialize response")?;
json.push(b'\n');
out.write_all(&json).await.context("failed to write response")?;
out.flush().await.context("failed to flush protocol channel")?;
Ok(())
}
/// Route a parsed request to the appropriate handler.
async fn handle_request(req: JsonRpcRequest) -> Option<JsonRpcResponse> {
match req.method.as_str() {
"initialize" => Some(handle_initialize(req.id)),
"initialized" | "notifications/initialized" => {
// Notification — no response.
eprintln!("[MCP] Client initialized");
None
}
"tools/list" => Some(handle_tools_list(req.id)),
"tools/call" => Some(handle_tools_call(req.id, req.params).await),
"resources/list" => Some(handle_resources_list(req.id)),
"resources/read" => Some(handle_resources_read(req.id, req.params).await),
other if other.starts_with("notifications/") => {
eprintln!("[MCP] Ignoring notification: {}", other);
None
}
other => Some(JsonRpcResponse::error(
req.id,
-32601,
format!("Method not found: {}", other),
))
}
}
// ---------------------------------------------------------------------------
// Handler implementations
// ---------------------------------------------------------------------------
fn handle_initialize(id: Option<Value>) -> JsonRpcResponse {
let result = InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {}),
resources: Some(ResourcesCapability {}),
},
server_info: ServerInfo {
name: "rustsploit-mcp".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
};
match serde_json::to_value(&result) {
Ok(v) => JsonRpcResponse::success(id, v),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
}
}
fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
let tools = super::tools::all_tools();
match serde_json::to_value(&tools) {
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "tools": v })),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
}
}
/// Per-tool-call execution budget. A single hung/slow tool must not be able to
/// wedge the server's stdin read loop forever, so `tools/call` dispatch is
/// bounded by this timeout and returns a JSON-RPC error on expiry. Override via
/// the `RUSTSPLOIT_MCP_TIMEOUT_SECS` env var (0 disables the cap).
/// wedge the server forever, so `tools/call` dispatch is bounded by this
/// timeout and returns a JSON-RPC error on expiry. Override via the
/// `RUSTSPLOIT_MCP_TIMEOUT_SECS` env var (0 disables the cap).
fn module_timeout() -> Option<std::time::Duration> {
match std::env::var("RUSTSPLOIT_MCP_TIMEOUT_SECS") {
Ok(v) => match v.trim().parse::<u64>() {
@@ -243,17 +100,97 @@ fn module_timeout() -> Option<std::time::Duration> {
}
}
async fn handle_tools_call(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
let (name, arguments) = match extract_tool_call_params(&params) {
Ok(pair) => pair,
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
/// Run the MCP server over the official `rmcp` SDK on a stdio transport.
///
/// The protocol channel (JSON-RPC over the original stdout) is isolated from
/// fd 1 first, so stray `println!` from library code cannot corrupt the stream;
/// fd 1 is redirected to `/dev/null` and the saved descriptor becomes the
/// transport's writer. Module output is captured through `OUTPUT_BUFFER`.
pub async fn run_mcp_server() -> anyhow::Result<()> {
let protocol_out = isolate_protocol_stdout()
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
eprintln!("[MCP] RustSploit MCP server started (rmcp SDK, stdio transport)");
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
// `(reader, writer)` is an rmcp async-RW transport. stdin is the request
// stream; the saved real-stdout file is the response sink.
let transport = (tokio::io::stdin(), protocol_out);
let service = serve_server(RustsploitHandler, transport)
.await
.context("MCP server initialization (initialize handshake) failed")?;
// Block until the client disconnects (stdin EOF) or the connection ends.
let quit_reason = service
.waiting()
.await
.context("MCP server task failed to join cleanly")?;
eprintln!("[MCP] server stopped: {quit_reason:?}");
Ok(())
}
// ---------------------------------------------------------------------------
// Handler — adapts the existing tool/resource registry to rmcp's traits.
// ---------------------------------------------------------------------------
/// Stateless MCP handler. All state lives behind the per-tenant stores reached
/// inside `tools::call_tool` / `resources::read_resource`, so this is a unit.
#[derive(Clone)]
struct RustsploitHandler;
impl ServerHandler for RustsploitHandler {
fn get_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
InitializeResult::new(capabilities)
.with_server_info(Implementation::new(
"rustsploit-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"RustSploit offensive-security framework over MCP. Discover modules with \
`list_modules` / `search_modules` / `module_info`, set a scope with \
`set_target`, then execute with `run_module` (set `background: true` for \
long-running scans and poll `list_jobs`). Live engagement state is exposed \
as resources under the `rustsploit:///` URI scheme.",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let tools = super::tools::all_tools()
.into_iter()
.map(to_rmcp_tool)
.collect();
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let name = request.name.to_string();
// rmcp delivers arguments as `Option<JsonObject>`; the existing
// dispatcher expects a JSON `Value` (defaulting to `{}` when absent).
let arguments = match request.arguments {
Some(map) => Value::Object(map),
None => Value::Object(Map::new()),
};
// Bound the tool execution so a single hung tool cannot block the read loop
// indefinitely. On expiry, surface a JSON-RPC error instead of hanging.
// Bound execution so one hung tool cannot wedge the server. On expiry,
// surface a JSON-RPC error instead of blocking the connection.
let result = match module_timeout() {
Some(dur) => match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await
{
Some(dur) => {
match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await {
Ok(r) => r,
Err(_) => {
eprintln!(
@@ -261,86 +198,82 @@ async fn handle_tools_call(id: Option<Value>, params: Option<Value>) -> JsonRpcR
name,
dur.as_secs()
);
return JsonRpcResponse::error(
id,
-32000,
format!(
"Tool '{}' timed out after {} seconds",
name,
dur.as_secs()
),
);
return Err(McpError::internal_error(
format!("Tool '{}' timed out after {} seconds", name, dur.as_secs()),
None,
));
}
}
}
},
None => super::tools::call_tool(&name, arguments).await,
};
match serde_json::to_value(&result) {
Ok(v) => JsonRpcResponse::success(id, v),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
}
Ok(to_rmcp_tool_result(result))
}
fn handle_resources_list(id: Option<Value>) -> JsonRpcResponse {
let resources = super::resources::all_resources();
match serde_json::to_value(&resources) {
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "resources": v })),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let resources = super::resources::all_resources()
.into_iter()
.map(to_rmcp_resource)
.collect();
Ok(ListResourcesResult::with_all_items(resources))
}
async fn handle_resources_read(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
let uri = match extract_resource_uri(&params) {
Ok(u) => u,
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let content = super::resources::read_resource(&request.uri).await;
let contents = ResourceContents::TextResourceContents {
uri: content.uri,
mime_type: Some(content.mime_type),
text: content.text,
meta: None,
};
let result = super::resources::read_resource(&uri).await;
// The MCP spec (2024-11-05) requires `resources/read` to return
// `{ contents: [ { uri, mimeType, text } ] }` — a list, not a bare content
// object. Claude's client rejects the bare shape silently.
match serde_json::to_value(&result) {
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "contents": [v] })),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
Ok(ReadResourceResult::new(vec![contents]))
}
}
// ---------------------------------------------------------------------------
// Param extraction helpers
// Mapping helpers: internal registry types -> rmcp model types.
// ---------------------------------------------------------------------------
/// Pull `name` (String) and `arguments` (Object) out of the `tools/call` params.
fn extract_tool_call_params(params: &Option<Value>) -> Result<(String, Value), String> {
let obj = params
.as_ref()
.and_then(|v| v.as_object())
.ok_or_else(|| "Invalid params: expected object with 'name' and 'arguments'".to_string())?;
let name = obj
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing or invalid 'name' in params".to_string())?
.to_string();
let arguments = obj
.get("arguments")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
Ok((name, arguments))
/// Map an internal tool descriptor to an rmcp `Tool`. The hand-authored
/// `inputSchema` JSON is reused verbatim (rmcp expects a JSON-Schema object).
fn to_rmcp_tool(t: super::types::Tool) -> Tool {
let schema: Map<String, Value> = match t.input_schema {
Value::Object(map) => map,
// A non-object schema is malformed; advertise an empty object rather
// than panicking so the rest of the tool list still loads.
_ => Map::new(),
};
Tool::new(t.name, t.description, Arc::new(schema))
}
/// Pull `uri` (String) out of the `resources/read` params.
fn extract_resource_uri(params: &Option<Value>) -> Result<String, String> {
let obj = params
.as_ref()
.and_then(|v| v.as_object())
.ok_or_else(|| "Invalid params: expected object with 'uri'".to_string())?;
let uri = obj
.get("uri")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing or invalid 'uri' in params".to_string())?
.to_string();
Ok(uri)
/// Map an internal tool result to rmcp's `CallToolResult`. All internal content
/// blocks are text; `is_error` selects the error vs. success constructor.
fn to_rmcp_tool_result(r: super::types::ToolResult) -> CallToolResult {
let content: Vec<Content> = r
.content
.into_iter()
.map(|c| Content::text(c.text))
.collect();
if r.is_error == Some(true) {
CallToolResult::error(content)
} else {
CallToolResult::success(content)
}
}
/// Map an internal resource descriptor to an rmcp `Resource`.
fn to_rmcp_resource(res: super::types::Resource) -> Resource {
RawResource::new(res.uri, res.name)
.with_description(res.description)
.with_mime_type(res.mime_type)
.no_annotation()
}
+17 -6
View File
@@ -91,12 +91,14 @@ impl Target {
if trimmed.is_empty() {
anyhow::bail!("Target cannot be empty");
}
// `Target::Random` is reserved for the explicit mass-scan markers
// `random` and `0.0.0.0/0`. Bare `0.0.0.0` is NOT a mass-scan keyword
// (M45): it resolves to a normal single host so `t 0.0.0.0` does not
// silently launch a full-internet random scan. CIDR ranges and file
// paths are handled below as `Cidr` / `File`.
if trimmed == "random" || trimmed == "0.0.0.0/0" {
// `Target::Random` is the full-internet random sweep. It is triggered by
// the markers `random`, `0.0.0.0/0`, and bare `0.0.0.0` (operator opt-in
// 2026-06-09 — reverses the earlier M45 rule that made `0.0.0.0` a single
// host). The scheduler shows a per-sweep advisory and, on an interactive
// console, an explicit confirmation before it actually fans out (see
// `fanout_random` / `fanout_sequential`). CIDR ranges and file paths are
// handled below as `Cidr` / `File`.
if trimmed == "random" || trimmed == "0.0.0.0/0" || trimmed == "0.0.0.0" {
return Ok(Target::Random);
}
// Sequential full-public-IPv4 sweep: `seq`/`sequential` start at the
@@ -333,6 +335,14 @@ pub struct ModuleCtx {
pub options: ModuleOptions,
pub cancel: tokio_util::sync::CancellationToken,
pub batch_mode: bool,
/// Prompt-harvest dry run. Before a mass-scan batch the scheduler runs the
/// module once against a placeholder host purely to gather the operator's
/// `cfg_prompt_*` answers (which get cached for the whole batch). When this
/// is set, a module should answer its prompts and then return EARLY without
/// any network work or file writes — the run exists only to populate the
/// prompt cache, so any real effect against the placeholder host is wasted
/// and misleading.
pub prompt_only: bool,
pub verbose: bool,
/// Tenant identity (multi-tenant API isolation). `None` in CLI/shell mode.
pub tenant_id: Option<String>,
@@ -354,6 +364,7 @@ impl ModuleCtx {
options: ModuleOptions::default(),
cancel: tokio_util::sync::CancellationToken::new(),
batch_mode: false,
prompt_only: false,
verbose: false,
tenant_id: None,
prompt_cache: None,
@@ -0,0 +1 @@
pub mod setup_state_bypass;
@@ -0,0 +1,260 @@
//! Metasploit Pro — Unauthenticated Admin Account Creation in Setup State
//! ======================================================================
//!
//! A freshly-installed Metasploit Pro instance that has never had an admin
//! account configured serves an unauthenticated `/setup` flow that 302s to
//! `/users/new`. The `users/new` form accepts a username, email, password,
//! and confirmation — once submitted, the attacker owns the entire Metasploit
//! Pro server: all stored credentials, session loot, payload listeners, and
//! the underlying RPC interface.
//!
//! This module is detection-only. It walks the redirect chain from `/` and
//! reports whether the instance is sitting in the initial setup state. It
//! does NOT submit the new-user form.
//!
//! As a bonus the `/api/v1/` MessagePack RPC endpoint leaks a full Ruby
//! backtrace identifying the exact `service.rb` / `thread_manager.rb` line
//! numbers — a reliable version fingerprint.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::safe_io::DEFAULT_BODY_CAP;
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 3790;
const TIMEOUT_SECS: u64 = 12;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Metasploit Pro — Unauthenticated Admin Setup Bypass".to_string(),
description: "Detects Metasploit Pro instances in initial setup state where\n\
/setup → /users/new is reachable without authentication. Any\n\
attacker who reaches this endpoint can create the first\n\
administrator account and own the Metasploit server. Also\n\
fingerprints version via /api/v1/ stack trace line numbers."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://docs.metasploit.com/docs/using-metasploit/getting-started/".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Excellent,
default_port: Some(DEFAULT_PORT),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("metasploit_pro setup_state_bypass requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "Metasploit Pro HTTPS port", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (https/http)", "https").await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ Metasploit Pro — Setup State Bypass ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
// 1. Follow /
let root_url = format!("{}/", base);
crate::mprintln!("{} GET {}", "[*]".blue(), root_url);
let resp = match client.get(&root_url).send().await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{} request failed: {}", "[-]".red(), e);
return Ok(outcome);
}
};
let final_url = resp.url().to_string();
let status = resp.status();
let body = crate::utils::network::read_http_body_text_capped(resp, DEFAULT_BODY_CAP)
.await
.context("read root body")?;
crate::mprintln!(
"{} Final URL: {} (HTTP {})",
"[*]".blue(),
final_url,
status
);
let is_setup = final_url.contains("/setup")
|| final_url.contains("/users/new")
|| body.contains("Welcome to Metasploit Pro")
|| (body.to_lowercase().contains("new user") && body.contains("password"));
let is_login = final_url.contains("/login")
|| (body.contains("session") && body.contains("password") && !is_setup);
if is_setup {
crate::mprintln!(
"{} Metasploit Pro is in SETUP state — unauthenticated admin creation possible",
"[+]".green().bold()
);
crate::workspace::track_host(&normalized, None, Some("Metasploit Pro (unconfigured)")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Metasploit Pro at {} is in initial setup state — /users/new accepts unauth admin creation",
base
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"state": "setup",
"final_url": final_url,
})),
});
} else if is_login {
crate::mprintln!("{} Metasploit Pro is configured — login required", "[-]".green());
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("Metasploit Pro at {} is in login state (admin account exists)", base),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"state": "login",
"final_url": final_url,
})),
});
} else if final_url.contains("/licenses") || body.contains("license") {
crate::mprintln!("{} Metasploit Pro requires license activation", "[!]".yellow());
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("Metasploit Pro at {} is in license state", base),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"state": "license",
"final_url": final_url,
})),
});
} else {
crate::mprintln!(
"{} target does not look like a Metasploit Pro UI (no setup / login / license markers)",
"[-]".yellow()
);
// Don't return — version fingerprint still possible
}
// 2. Version fingerprint via /api/v1/
let api_url = format!("{}/api/v1/", base);
crate::mprintln!("{} GET {} (version fingerprint)", "[*]".blue(), api_url);
if let Ok(r) = client.get(&api_url).send().await {
let body = match crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} read /api/v1/ body failed: {}", "[-]".yellow(), e);
String::new()
}
};
// Look for service.rb:NNN inside the backtrace
let mut service_line: Option<u32> = None;
let mut tm_line: Option<u32> = None;
let mut has_diag_ctx = false;
for line in body.lines() {
let lower = line.to_lowercase();
if lower.contains("service.rb") {
if let Some(num) = extract_line_number(line, "service.rb") {
service_line.get_or_insert(num);
}
}
if lower.contains("thread_manager.rb") {
if let Some(num) = extract_line_number(line, "thread_manager.rb") {
tm_line.get_or_insert(num);
}
}
if lower.contains("diagnostic_context") {
has_diag_ctx = true;
}
}
if service_line.is_some() || tm_line.is_some() {
crate::mprintln!(
"{} version fingerprint: service.rb:{:?} thread_manager.rb:{:?} diagnostic_context={}",
"[+]".green(),
service_line,
tm_line,
has_diag_ctx
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Metasploit Pro at {} version fingerprint: service.rb:{:?} thread_manager.rb:{:?}",
base, service_line, tm_line
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"service_rb_line": service_line,
"thread_manager_rb_line": tm_line,
"has_diagnostic_context": has_diag_ctx,
})),
});
}
}
Ok(outcome)
}
fn extract_line_number(line: &str, file_name: &str) -> Option<u32> {
let idx = line.find(file_name)?;
let tail = &line[idx + file_name.len()..];
let after_colon = tail.strip_prefix(':')?;
let digits: String = after_colon.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::extract_line_number;
#[test]
fn parses_simple_backtrace() {
assert_eq!(
extract_line_number("lib/msf/core/rpc/v10/service.rb:99:in `process'", "service.rb"),
Some(99)
);
assert_eq!(
extract_line_number(
" lib/msf/core/thread_manager.rb:106:in `block in spawn'",
"thread_manager.rb"
),
Some(106)
);
}
#[test]
fn returns_none_without_match() {
assert_eq!(extract_line_number("not relevant", "service.rb"), None);
}
}
crate::register_native_module!(
crate::module::Category::Exploits,
"frameworks/metasploit_pro/setup_state_bypass",
native
);
+1
View File
@@ -4,6 +4,7 @@ pub mod exim;
pub mod h3c_bmc;
pub mod http2;
pub mod jenkins;
pub mod metasploit_pro;
pub mod mongo;
pub mod nginx;
pub mod php;
@@ -0,0 +1 @@
pub mod routeros_jailbreak_cve_2023_30799;
@@ -0,0 +1,283 @@
//! CVE-2023-30799 — MikroTik RouterOS Authenticated Privilege Escalation to Root
//! ==============================================================================
//!
//! All RouterOS 6.x versions (and 7.x stable < 7.11.2) allow any authenticated
//! "admin" user to break out of the RouterOS shell sandbox into a full root
//! shell on the underlying Linux OS by abusing the FTP server / `dev` package
//! handling. Combined with the historical MikroTik default credential
//! (`admin` with an empty password) this often becomes effectively
//! unauthenticated root access.
//!
//! This module is **detection only** — it identifies the affected version
//! window via WinBox version disclosure (port 8291 default, also 8500/8729
//! seen in the wild) and probes SSH/HTTP banners. It does NOT attempt the
//! escalation primitive itself, which requires writing to the device.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_WINBOX_PORT: u16 = 8291;
const TIMEOUT_SECS: u64 = 10;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "MikroTik RouterOS Jailbreak to Root (CVE-2023-30799)".to_string(),
description: "Detects MikroTik RouterOS versions in the CVE-2023-30799 vulnerable\n\
range. Authenticated admin can escalate to a full Linux root shell\n\
by abusing the dev package primitive. Combined with the historical\n\
admin/blank default credential this is effectively unauth root.\n\
Module is detection-only — checks WinBox/SSH banners for version\n\
and reports vulnerability without writing to the device."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"CVE-2023-30799".to_string(),
"https://vulncheck.com/blog/foisted".to_string(),
],
disclosure_date: Some("2023-07-25".to_string()),
rank: ModuleRank::Great,
default_port: Some(DEFAULT_WINBOX_PORT),
}
}
/// Returns true if `version` (e.g. "6.48.6", "7.10.1") is in the vulnerable
/// window. CVE-2023-30799 affects:
/// * all 6.x stable releases <= 6.49.10
/// * all 7.x stable releases <= 7.11.1
fn version_vulnerable(version: &str) -> Option<bool> {
let v = version.trim().trim_start_matches('v');
let parts: Vec<&str> = v.split('.').collect();
if parts.len() < 2 {
return None;
}
let major: u32 = parts[0].parse().ok()?;
let minor: u32 = parts[1].parse().ok()?;
let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
match major {
6 => Some(minor < 49 || (minor == 49 && patch <= 10)),
7 => Some(minor < 11 || (minor == 11 && patch <= 1)),
_ => Some(false),
}
}
/// Attempts a WinBox v6 protocol handshake to extract the RouterOS version.
/// The WinBox protocol opens with a 5-byte header `06 ff 01 06 02` followed
/// by a session-init blob. On success the server returns the version string
/// in the response. We use a minimal probe and look for the version pattern
/// in the response bytes.
async fn winbox_version_probe(host: &str, port: u16) -> Option<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
let addr = format!("{}:{}", host, port);
let mut stream = timeout(Duration::from_secs(TIMEOUT_SECS), TcpStream::connect(&addr))
.await
.ok()?
.ok()?;
// Minimal WinBox v6 probe — request session info
let probe: &[u8] = &[
0x36, 0x01, 0x00, 0x65, 0x4d, 0x32, 0x05, 0x00, 0xff, 0x01, 0x06, 0x00, 0xff, 0x09, 0x05,
0x07, 0x00, 0xff, 0x09, 0x07, 0x01, 0x00, 0x00, 0x21,
];
timeout(Duration::from_secs(TIMEOUT_SECS), stream.write_all(probe))
.await
.ok()?
.ok()?;
let mut buf = vec![0u8; 4096];
let n = timeout(Duration::from_secs(TIMEOUT_SECS), stream.read(&mut buf))
.await
.ok()?
.ok()?;
if n == 0 {
return None;
}
// Scan for ASCII version pattern "X.Y" or "X.Y.Z"
let text = String::from_utf8_lossy(&buf[..n]);
for token in text.split(|c: char| !c.is_ascii() || (c != '.' && !c.is_ascii_digit())) {
if token.matches('.').count() >= 1 && token.len() >= 3 && token.len() <= 12 {
let parts: Vec<&str> = token.split('.').collect();
if parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) {
if let Ok(major) = parts[0].parse::<u32>() {
if (6..=7).contains(&major) {
return Some(token.to_string());
}
}
}
}
}
None
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("mikrotik routeros_jailbreak_cve_2023_30799 requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "WinBox port (default 8291, often 8500)", DEFAULT_WINBOX_PORT).await?;
let try_default = cfg_prompt_default(
"try_default_creds",
"Note default admin/blank cred presence (true/false)",
"true",
)
.await?;
let mut outcome = ModuleOutcome::ok();
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ MikroTik RouterOS Jailbreak (CVE-2023-30799) — detection only ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}:{}", "[*] Target:".yellow(), normalized, port);
crate::mprintln!("{} Probing WinBox for RouterOS version...", "[*]".blue());
let version = winbox_version_probe(&normalized, port).await;
let mut routeros_version: Option<String> = None;
match &version {
Some(v) => {
crate::mprintln!("{} RouterOS version disclosed: {}", "[+]".green(), v);
routeros_version = Some(v.clone());
}
None => {
crate::mprintln!(
"{} No WinBox version response — service may use non-default port (try 8500/8729) or be filtered",
"[-]".yellow()
);
}
}
let vuln_status = routeros_version
.as_deref()
.and_then(version_vulnerable)
.map(|v| if v { "VULNERABLE" } else { "patched" });
match (routeros_version.as_deref(), vuln_status) {
(Some(v), Some("VULNERABLE")) => {
crate::mprintln!(
"{} RouterOS {} is in the CVE-2023-30799 vulnerable window.",
"[+]".green().bold(),
v
);
crate::mprintln!(
"{} Authenticated admin → full Linux root shell via dev-package primitive.",
"[+]".green()
);
if try_default.eq_ignore_ascii_case("true") {
crate::mprintln!(
"{} Note: MikroTik default credential is admin / (blank) — try via WinBox/SSH.",
"[!]".yellow()
);
}
crate::workspace::track_host(&normalized, None, Some("MikroTik RouterOS — CVE-2023-30799")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"MikroTik RouterOS {} on {}:{} is vulnerable to CVE-2023-30799 (admin → root)",
v, normalized, port
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2023-30799",
"routeros_version": v,
"default_credential_hint": "admin / (blank)",
"remediation": "Upgrade to RouterOS >= 6.49.11 or 7.11.2",
})),
});
}
(Some(v), Some("patched")) => {
crate::mprintln!(
"{} RouterOS {} is patched against CVE-2023-30799.",
"[-]".green(),
v
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("MikroTik RouterOS {} on {}:{} is patched", v, normalized, port),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"routeros_version": v,
"cve_2023_30799": "not_vulnerable",
})),
});
}
_ => {
crate::mprintln!(
"{} Could not determine RouterOS version — manual WinBox check recommended.",
"[?]".yellow()
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"MikroTik WinBox reachable on {}:{} but version not parsed",
normalized, port
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2023-30799",
"status": "version_unknown",
})),
});
}
}
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::version_vulnerable;
#[test]
fn vulnerable_versions_in_range() {
assert_eq!(version_vulnerable("6.43.16"), Some(true));
assert_eq!(version_vulnerable("6.48.6"), Some(true));
assert_eq!(version_vulnerable("6.49.10"), Some(true));
assert_eq!(version_vulnerable("7.10.1"), Some(true));
assert_eq!(version_vulnerable("7.11.1"), Some(true));
}
#[test]
fn patched_versions_excluded() {
assert_eq!(version_vulnerable("6.49.11"), Some(false));
assert_eq!(version_vulnerable("7.11.2"), Some(false));
assert_eq!(version_vulnerable("7.12.0"), Some(false));
assert_eq!(version_vulnerable("8.0.0"), Some(false));
}
#[test]
fn malformed_returns_none() {
assert_eq!(version_vulnerable(""), None);
assert_eq!(version_vulnerable("abc"), None);
}
}
crate::register_native_module!(
crate::module::Category::Exploits,
"routers/mikrotik/routeros_jailbreak_cve_2023_30799",
native
);
+1
View File
@@ -1,4 +1,5 @@
pub mod dlink;
pub mod mikrotik;
pub mod netgear;
pub mod palo_alto;
pub mod ruijie;
@@ -0,0 +1,432 @@
//! Dover Fueling Solutions (DoverFSP) Fusion — Multiple Auth Bypass + Unauth API Disclosure
//! =========================================================================================
//!
//! The Dover Fusion fuel-station management platform (CodeCharge Studio / Angular
//! frontend, PHP+PostgreSQL backend, deployed at Shell / Wayne sites globally)
//! exposes a stack of unauthenticated and broken-authentication flaws:
//!
//! 1. **Content-Type confusion in `Login_events_newweb.php`** — form-encoded
//! POSTs always return `{"result":"Ok"}` because the backend `json_decode`s
//! the raw body and short-circuits on `NULL`.
//! 2. **Empty password hashes** — accounts shipped with blank password fields
//! (e.g. `CAJERO 2`, `ECSA`) authenticate with no password against the
//! JSON endpoint.
//! 3. **`/Security/ChangePassword.php`** is reachable with no session and no
//! CSRF token (`secured="False"` in the CCP source).
//! 4. **`/service/UploadHelper/UploadFileHelper.php`** accepts multipart
//! uploads without authentication (RCE primitive given PHP runs as
//! SYSTEM with `disable_functions=` empty).
//! 5. **`/api/*.php`** endpoints (`sales`, `prices`, `tanks`, `volumeByHose`,
//! `modules`, `alarms`, `attendant`, `changeRedirect`) return live
//! operational data with no authentication.
//! 6. **`SSFForecourtRealView.php`** exposes fuel-pump control AJAX
//! (`openPump`, `unlockFDCPump`, `pressurizePipe`) without auth.
//! 7. **`SSFDownloadConfigTemplate.php`** returns a ZIP that includes the
//! full user / role / password-hash table.
//!
//! This module is detection-only: it probes each endpoint and records what
//! is reachable. No files are uploaded, no passwords are changed, and the
//! pump-control AJAX is never invoked.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::safe_io::DEFAULT_BODY_CAP;
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 443;
const TIMEOUT_SECS: u64 = 12;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "DoverFSP Fusion — Auth Bypass + Unauthenticated API Disclosure".to_string(),
description: "Probes Dover Fueling Solutions Fusion (Wayne / Shell fuel-station\n\
management) for: Content-Type confusion auth bypass on\n\
Login_events_newweb.php, empty-hash CAJERO 2 / ECSA accounts,\n\
unauthenticated ChangePassword.php, UploadFileHelper.php upload\n\
handler, unauth /api/* endpoints, SSFForecourtRealView pump\n\
control, SSFDownloadConfigTemplate config dump."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://www.doverfuelingsolutions.com/products/automation/site-sentinel-fusion".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Excellent,
default_port: Some(DEFAULT_PORT),
}
}
/// One step in the probe chain. Each step returns `Result<Option<Finding>>`:
/// * `Ok(Some(_))` — endpoint vulnerable, report it
/// * `Ok(None)` — endpoint reachable but not vulnerable
/// * `Err(e)` — probe could not be carried out (network / parse failure)
/// — we log and move to the next step rather than abort.
async fn log_probe_err(label: &str, err: anyhow::Error) {
crate::mprintln!("{} {}: {}", "[-]".yellow(), label, err);
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("doverfsp_fusion_authbypass requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "HTTPS port", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (https/http)", "https").await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ DoverFSP Fusion — Auth Bypass / Unauthenticated API Probe ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
let mut hits: Vec<&'static str> = Vec::new();
// ----------------------------------------------------------------------
// 1. Content-Type confusion on Login_events_newweb.php
// ----------------------------------------------------------------------
let login_url = format!("{}/Security/Login_events_newweb.php", base);
crate::mprintln!("{} Probing Content-Type confusion at {}", "[*]".blue(), login_url);
match probe_content_type_confusion(&client, &login_url).await {
Ok(true) => {
crate::mprintln!(
"{} Content-Type confusion CONFIRMED — Login_events_newweb.php returns Ok for form-encoded data",
"[+]".green().bold()
);
hits.push("content_type_confusion_login_bypass");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion Content-Type confusion auth bypass at {}",
login_url
),
data: Some(serde_json::json!({
"host": normalized,
"endpoint": "/Security/Login_events_newweb.php",
"vector": "form-encoded POST returns result=Ok regardless of credentials",
})),
});
}
Ok(false) => crate::mprintln!("{} no Content-Type bypass", "[-]".yellow()),
Err(e) => log_probe_err("Content-Type confusion probe", e).await,
}
// ----------------------------------------------------------------------
// 2. Empty-hash CAJERO 2 account via JSON endpoint
// ----------------------------------------------------------------------
crate::mprintln!("{} Probing empty-hash CAJERO 2 account...", "[*]".blue());
match probe_cajero2_empty(&client, &login_url).await {
Ok(true) => {
crate::mprintln!("{} CAJERO 2 empty-password auth SUCCEEDED", "[+]".green().bold());
hits.push("cajero2_empty_hash_auth");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Credential,
message: format!(
"DoverFSP Fusion CAJERO 2 account on {} authenticates with empty password",
normalized
),
data: Some(serde_json::json!({
"host": normalized,
"username": "CAJERO 2",
"password": "",
"endpoint": "/Security/Login_events_newweb.php",
})),
});
}
Ok(false) => crate::mprintln!("{} CAJERO 2 empty-hash auth did not succeed", "[-]".yellow()),
Err(e) => log_probe_err("CAJERO 2 probe", e).await,
}
// ----------------------------------------------------------------------
// 3. ChangePassword.php unauthenticated
// ----------------------------------------------------------------------
let cp_url = format!("{}/Security/ChangePassword.php", base);
crate::mprintln!("{} Probing unauthenticated ChangePassword.php...", "[*]".blue());
match probe_changepassword(&client, &cp_url).await {
Ok(true) => {
crate::mprintln!(
"{} ChangePassword.php is reachable without authentication",
"[+]".green().bold()
);
hits.push("changepassword_unauth_no_csrf");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion ChangePassword.php at {} is unauthenticated and has no CSRF token",
cp_url
),
data: Some(serde_json::json!({
"host": normalized,
"endpoint": "/Security/ChangePassword.php",
})),
});
}
Ok(false) => {}
Err(e) => log_probe_err("ChangePassword probe", e).await,
}
// ----------------------------------------------------------------------
// 4. UploadFileHelper.php no auth (HEAD only — never upload)
// ----------------------------------------------------------------------
let up_url = format!("{}/service/UploadHelper/UploadFileHelper.php", base);
crate::mprintln!("{} Probing UploadFileHelper.php (HEAD/GET only, no upload)...", "[*]".blue());
match probe_upload_helper(&client, &up_url).await {
Ok(Some(status)) => {
crate::mprintln!(
"{} UploadFileHelper.php reachable without auth (status {})",
"[+]".green().bold(),
status
);
hits.push("upload_file_helper_unauth");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion UploadFileHelper.php at {} reachable without authentication — RCE vector",
up_url
),
data: Some(serde_json::json!({
"host": normalized,
"endpoint": "/service/UploadHelper/UploadFileHelper.php",
"status": status,
"context": "PHP typically runs as SYSTEM with disable_functions empty",
})),
});
}
Ok(None) => {}
Err(e) => log_probe_err("UploadFileHelper probe", e).await,
}
// ----------------------------------------------------------------------
// 5. Live /api/* endpoints
// ----------------------------------------------------------------------
let api_endpoints = [
"/api/sales.php",
"/api/prices.php",
"/api/tanks.php",
"/api/volumeByHose.php",
"/api/modules.php",
"/api/alarms.php",
"/api/attendant.php",
"/api/changeRedirect.php",
];
let mut leaked: Vec<String> = Vec::new();
for ep in &api_endpoints {
let url = format!("{}{}", base, ep);
match probe_api_endpoint(&client, &url).await {
Ok(Some(body_len)) => {
crate::mprintln!("{} {} leaks live data ({} bytes)", "[+]".green(), ep, body_len);
leaked.push((*ep).to_string());
}
Ok(None) => {}
Err(e) => log_probe_err(&format!("api probe {}", ep), e).await,
}
}
if !leaked.is_empty() {
hits.push("api_unauth_live_data");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion at {} exposes {} unauthenticated /api/* endpoints with live data",
normalized,
leaked.len()
),
data: Some(serde_json::json!({
"host": normalized,
"endpoints": leaked,
})),
});
}
// ----------------------------------------------------------------------
// 6. SSFForecourtRealView (presence only, no AJAX call)
// ----------------------------------------------------------------------
let pump_url = format!("{}/Operation/SSFForecourtRealView.php", base);
match probe_forecourt_realview(&client, &pump_url).await {
Ok(true) => {
crate::mprintln!(
"{} SSFForecourtRealView.php exposes pump-control AJAX without auth (page reachable)",
"[+]".green().bold()
);
hits.push("forecourt_realview_pump_control");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion SSFForecourtRealView.php at {} exposes fuel-pump control AJAX without auth",
pump_url
),
data: Some(serde_json::json!({
"host": normalized,
"endpoint": "/Operation/SSFForecourtRealView.php",
"functions": ["openPump", "togglePumpHandle", "unlockFDCPump", "clearPumpStop", "pressurizePipe"],
"physical_safety_risk": true,
})),
});
}
Ok(false) => {}
Err(e) => log_probe_err("SSFForecourtRealView probe", e).await,
}
// ----------------------------------------------------------------------
// 7. SSFDownloadConfigTemplate — returns a ZIP with user/role/hash table
// ----------------------------------------------------------------------
let tmpl_url = format!("{}/service/SSFDownloadConfigTemplate.php?type=eps", base);
match probe_config_template(&client, &tmpl_url).await {
Ok(Some(byte_count)) => {
crate::mprintln!(
"{} SSFDownloadConfigTemplate.php returned ZIP ({} bytes) without authentication",
"[+]".green().bold(),
byte_count
);
hits.push("config_template_dump");
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"DoverFSP Fusion SSFDownloadConfigTemplate.php at {} returns config archive containing user hash table without auth",
tmpl_url
),
data: Some(serde_json::json!({
"host": normalized,
"endpoint": "/service/SSFDownloadConfigTemplate.php",
"bytes_returned": byte_count,
})),
});
}
Ok(None) => {}
Err(e) => log_probe_err("SSFDownloadConfigTemplate probe", e).await,
}
if !hits.is_empty() {
crate::workspace::track_host(&normalized, None, Some("DoverFSP Fusion (vulnerable)")).await;
crate::mprintln!(
"{} {} vulnerable conditions confirmed: {}",
"[+]".green().bold(),
hits.len(),
hits.join(", ")
);
} else {
crate::mprintln!("{} No DoverFSP Fusion vulnerabilities confirmed at this target", "[-]".yellow());
}
Ok(outcome)
}
async fn probe_content_type_confusion(client: &reqwest::Client, login_url: &str) -> Result<bool> {
let r = client
.post(login_url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("angusr=FUSIONPROBE_AUDIT&angpwd=&anglang=en")
.send()
.await
.context("send form-encoded probe")?;
let status = r.status();
let body = crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP)
.await
.context("read body")?;
Ok(status.is_success() && body.contains("\"result\":\"Ok\""))
}
async fn probe_cajero2_empty(client: &reqwest::Client, login_url: &str) -> Result<bool> {
let r = client
.post(login_url)
.header("Content-Type", "application/json")
.body(r#"{"angusr":"CAJERO 2","angpwd":"","anglang":"en"}"#)
.send()
.await
.context("send JSON empty-pw probe")?;
let body = crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP)
.await
.context("read body")?;
Ok(body.contains("\"user\":\"CAJERO 2\"")
|| body.contains("\"result\":\"Ok\",\"user\":\"CAJERO"))
}
async fn probe_changepassword(client: &reqwest::Client, cp_url: &str) -> Result<bool> {
let r = client.get(cp_url).send().await.context("GET ChangePassword.php")?;
let status = r.status();
let body = crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP)
.await
.context("read body")?;
Ok(status.is_success() && body.to_lowercase().contains("pswchange"))
}
async fn probe_upload_helper(client: &reqwest::Client, up_url: &str) -> Result<Option<u16>> {
let r = client.get(up_url).send().await.context("GET UploadFileHelper.php")?;
let status = r.status();
if status.is_success() {
Ok(Some(status.as_u16()))
} else {
Ok(None)
}
}
async fn probe_api_endpoint(client: &reqwest::Client, url: &str) -> Result<Option<usize>> {
let r = client.get(url).send().await.context("GET /api/*")?;
let status = r.status();
if !status.is_success() {
return Ok(None);
}
let body = crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP)
.await
.context("read body")?;
let looks_json = body.trim_start().starts_with('{') || body.trim_start().starts_with('[');
if looks_json && body.len() > 2 {
Ok(Some(body.len()))
} else {
Ok(None)
}
}
async fn probe_forecourt_realview(client: &reqwest::Client, pump_url: &str) -> Result<bool> {
let r = client.get(pump_url).send().await.context("GET SSFForecourtRealView.php")?;
let status = r.status();
let body = crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP)
.await
.context("read body")?;
Ok(status.is_success()
&& (body.contains("openPump")
|| body.contains("unlockFDCPump")
|| body.contains("pressurizePipe")))
}
async fn probe_config_template(client: &reqwest::Client, tmpl_url: &str) -> Result<Option<usize>> {
let r = client.get(tmpl_url).send().await.context("GET SSFDownloadConfigTemplate.php")?;
let status = r.status();
let ctype = crate::utils::header_string(r.headers(), "content-type");
let bytes = r.bytes().await.context("read body bytes")?;
let is_zip = bytes.len() >= 4 && &bytes[..2] == b"PK";
if status.is_success() && (is_zip || ctype.contains("zip")) {
Ok(Some(bytes.len()))
} else {
Ok(None)
}
}
crate::register_native_module!(
crate::module::Category::Exploits,
"webapps/doverfsp_fusion_authbypass",
native
);
+3
View File
@@ -43,6 +43,9 @@ pub mod convio_sqli;
pub mod coohom_xss;
pub mod cpms_authbypass;
pub mod craftcms_logicflaw;
pub mod doverfsp_fusion_authbypass;
pub mod plex_unclaimed_takeover;
pub mod redeye_c2_unauth_project;
pub mod craftcms_ssti_scanner;
pub mod crafty_controller_rce_cve_2025_14700;
pub mod django_sqli_cve_2025_64459;
@@ -0,0 +1,202 @@
//! Plex Media Server — Unclaimed Server Takeover Detection
//! ========================================================
//!
//! A Plex Media Server that has never been claimed by a `plex.tv` account
//! returns `claimed=false` from `GET /identity`. Anyone with a Plex account
//! can complete the claim flow and obtain full administrative control over
//! the server — including filesystem browsing (via the media library picker)
//! and code execution via plugins.
//!
//! This module is detection-only. It queries `/identity`, parses the
//! `MediaContainer` block (XML by default, JSON when `Accept: application/json`
//! is sent), and reports the claim state plus version and machine
//! identifier. It never sends a claim token.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::safe_io::DEFAULT_BODY_CAP;
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 32400;
const TIMEOUT_SECS: u64 = 8;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Plex Media Server — Unclaimed Server Takeover".to_string(),
description: "Detects Plex Media Servers that are unclaimed (claimed=false).\n\
An unclaimed server can be taken over by any plex.tv account\n\
through the standard claim flow, giving full administrative\n\
access including media library and host filesystem browsing.\n\
Module is detection-only — does NOT submit a claim token."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://support.plex.tv/articles/claim-server/".to_string(),
"https://www.plex.tv/api/v2/identity".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Great,
default_port: Some(DEFAULT_PORT),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("plex_unclaimed_takeover requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "Plex HTTP port", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (http/https)", "http").await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ Plex Media Server — Unclaimed Detection ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
let url = format!("{}/identity", base);
crate::mprintln!("{} GET {}", "[*]".blue(), url);
let resp = match client.get(&url).header("Accept", "application/json").send().await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{} request failed: {}", "[-]".red(), e);
return Ok(outcome);
}
};
let status = resp.status();
let body = crate::utils::network::read_http_body_text_capped(resp, DEFAULT_BODY_CAP)
.await
.context("read /identity body")?;
if !status.is_success() {
crate::mprintln!("{} unexpected HTTP {} from /identity", "[-]".yellow(), status);
return Ok(outcome);
}
let lower = body.to_lowercase();
if !lower.contains("mediacontainer") && !lower.contains("machineidentifier") {
crate::mprintln!("{} response does not look like a Plex /identity payload", "[-]".yellow());
return Ok(outcome);
}
// Detect claimed flag from either JSON or XML payload
let claimed = if lower.contains("\"claimed\":false") || lower.contains("claimed=\"0\"") {
Some(false)
} else if lower.contains("\"claimed\":true") || lower.contains("claimed=\"1\"") {
Some(true)
} else {
None
};
fn extract(body: &str, key_json: &str, key_xml: &str) -> Option<String> {
// JSON: "key":"value"
if let Some(idx) = body.find(&format!("\"{}\":", key_json)) {
let tail = &body[idx + key_json.len() + 3..];
let tail = tail.trim_start_matches([' ', ':', '"']);
let end = tail.find(['"', ',', '}']).unwrap_or(tail.len());
let v = tail[..end].trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
// XML: key="value"
if let Some(idx) = body.find(&format!("{}=\"", key_xml)) {
let tail = &body[idx + key_xml.len() + 2..];
let end = tail.find('"').unwrap_or(tail.len());
let v = tail[..end].trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
None
}
let version = extract(&body, "version", "version");
let machine_id = extract(&body, "machineIdentifier", "machineIdentifier");
let api_version = extract(&body, "apiVersion", "apiVersion");
match claimed {
Some(false) => {
crate::mprintln!(
"{} Plex server is UNCLAIMED — vulnerable to remote takeover",
"[+]".green().bold()
);
if let Some(v) = &version {
crate::mprintln!("{} version: {}", "[+]".green(), v);
}
if let Some(m) = &machine_id {
crate::mprintln!("{} machineIdentifier: {}", "[+]".green(), m);
}
crate::workspace::track_host(&normalized, None, Some("Plex (unclaimed)")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Plex Media Server at {} is unclaimed — any plex.tv account can take it over",
base
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"claimed": false,
"version": version,
"api_version": api_version,
"machine_identifier": machine_id,
"remediation": "Claim the server from the legitimate owner's plex.tv account immediately",
})),
});
}
Some(true) => {
crate::mprintln!(
"{} Plex server is already claimed (no takeover window)",
"[-]".green()
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("Plex server at {} is already claimed", base),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"claimed": true,
"version": version,
"machine_identifier": machine_id,
})),
});
}
None => {
crate::mprintln!(
"{} could not determine claim state from response — manual review recommended",
"[?]".yellow()
);
}
}
Ok(outcome)
}
crate::register_native_module!(
crate::module::Category::Exploits,
"webapps/plex_unclaimed_takeover",
native
);
@@ -0,0 +1,245 @@
//! Redeye C2 — Unauthenticated Project Creation
//! ==============================================
//!
//! The Redeye C2 web dashboard exposes a `/new_project` endpoint that accepts
//! form-encoded POST requests without any authentication check. Posting
//! `name`/`dbname`/`username`/`password` to this endpoint creates a new
//! SQLite-backed project that immediately appears in the login dropdown.
//!
//! This module is **detection-only**. It probes:
//!
//! 1. `/` and `/login` for the Redeye dashboard fingerprint (Bootstrap
//! 4.4.1, `reduser` cookie, BaseHTTPServer error format).
//! 2. `OPTIONS /new_project` for the `Allow: POST, OPTIONS` header that
//! confirms the endpoint exists and accepts POST.
//! 3. (Optional, off by default) Attempts a POST with a random project
//! name — only sent when the operator explicitly opts in by setting
//! `confirm_create=true`, since it modifies remote state.
//!
//! Notable: the dashboard often serves **plain HTTP on port 8443** despite
//! the conventional HTTPS port — this is reported as a separate finding.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::safe_io::DEFAULT_BODY_CAP;
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 8443;
const TIMEOUT_SECS: u64 = 10;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Redeye C2 — Unauthenticated /new_project Endpoint".to_string(),
description: "Detects Redeye C2 web dashboards that accept project creation\n\
on /new_project without any authentication. By default the\n\
module only fingerprints the dashboard and OPTIONS-probes the\n\
endpoint. Project creation is gated behind confirm_create=true\n\
since it writes remote state."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://github.com/redcanaryco/redeye".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Great,
default_port: Some(DEFAULT_PORT),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("redeye_c2_unauth_project requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "Redeye dashboard port", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (http/https) — Redeye often serves plain HTTP on 8443", "http").await?;
let confirm_create = cfg_prompt_default(
"confirm_create",
"Actually POST a probe project? (modifies remote state) true/false",
"false",
)
.await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ Redeye C2 — Unauthenticated Project Creation ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
let mut redeye_detected = false;
// 1. Fingerprint the dashboard
let login_url = format!("{}/login", base);
crate::mprintln!("{} GET {}", "[*]".blue(), login_url);
let login_resp = match client.get(&login_url).send().await {
Ok(r) => Some(r),
Err(e) => {
crate::mprintln!("{} dashboard fetch failed: {}", "[-]".red(), e);
None
}
};
if let Some(r) = login_resp {
let status = r.status();
let set_cookie = crate::utils::header_string(r.headers(), "set-cookie");
let body = match crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} read body failed: {}", "[-]".yellow(), e);
String::new()
}
};
let cookie_match = set_cookie.contains("reduser");
let body_match = body.contains("bootstrap")
&& (body.contains("Redeye") || body.contains("redeye") || body.contains("\"reduser\""));
if status.is_success() && (cookie_match || body_match) {
crate::mprintln!("{} Redeye dashboard fingerprint matched", "[+]".green());
redeye_detected = true;
if scheme.eq_ignore_ascii_case("http") && port == 8443 {
crate::mprintln!(
"{} Redeye is serving plain HTTP on port 8443 — credentials transmit in cleartext",
"[!]".yellow().bold()
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Redeye dashboard at {} serves plain HTTP on conventional HTTPS port 8443",
base
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"issue": "cleartext_http_on_8443",
})),
});
}
} else {
crate::mprintln!(
"{} dashboard fingerprint did not match (HTTP {}, reduser cookie present={})",
"[-]".yellow(),
status,
cookie_match
);
}
}
if !redeye_detected {
crate::mprintln!("{} target does not appear to be Redeye — aborting", "[-]".yellow());
return Ok(outcome);
}
// 2. OPTIONS /new_project — confirms endpoint exists
let np_url = format!("{}/new_project", base);
crate::mprintln!("{} OPTIONS {}", "[*]".blue(), np_url);
let mut options_confirmed = false;
if let Ok(r) = client.request(reqwest::Method::OPTIONS, &np_url).send().await {
let status = r.status();
let allow = crate::utils::header_string(r.headers(), "allow");
if status.is_success() && allow.to_uppercase().contains("POST") {
crate::mprintln!("{} /new_project allows POST without authentication", "[+]".green().bold());
options_confirmed = true;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Redeye C2 at {} exposes /new_project accepting POST without authentication",
base
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"endpoint": "/new_project",
"methods": allow,
})),
});
} else {
crate::mprintln!(
"{} OPTIONS returned HTTP {} (Allow: {})",
"[-]".yellow(),
status,
allow
);
}
}
// 3. Optional: actually create a probe project
if options_confirmed && confirm_create.eq_ignore_ascii_case("true") {
// Build a deterministic probe name from the target hash to avoid spamming
let name = format!("rustsploit_probe_{}", normalized.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>());
let dbname = format!("{}.db", name);
crate::mprintln!(
"{} POST {} (confirm_create=true — creating probe project '{}')",
"[!]".yellow(),
np_url,
name
);
let form = [
("name", name.as_str()),
("dbname", dbname.as_str()),
("username", "probe"),
("password", "probe"),
];
if let Ok(r) = client.post(&np_url).form(&form).send().await {
let status = r.status();
let body = match crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} read body failed: {}", "[-]".yellow(), e);
String::new()
}
};
if status.is_success() && body.contains(&dbname) {
crate::mprintln!(
"{} probe project '{}' visible in project dropdown — unauth project creation CONFIRMED",
"[+]".green().bold(),
name
);
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Redeye C2 at {} created project '{}' without authentication",
base, name
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"endpoint": "/new_project",
"project_created": name,
"dbname": dbname,
"cleanup_note": "Probe project remains on remote host — request operator removal",
})),
});
}
}
}
if redeye_detected {
crate::workspace::track_host(&normalized, None, Some("Redeye C2")).await;
}
Ok(outcome)
}
crate::register_native_module!(
crate::module::Category::Exploits,
"webapps/redeye_c2_unauth_project",
native
);
@@ -0,0 +1,410 @@
//! Cobalt Strike — Team Server / Beacon Fingerprinting
//! ====================================================
//!
//! Cobalt Strike team servers and beacons expose a number of operational
//! tells over HTTP / TLS even when the operator deploys a Malleable C2
//! profile:
//!
//! * The default self-signed CS certificate ships with SHA1
//! `6ECE5ECE...` and CN/O fields that have appeared verbatim in
//! CS distributions since 3.x.
//! * The team server returns a 404 with a `Content-Length: 0` and an
//! uppercase HTTP version banner for non-matching URIs.
//! * Default unprofiled staging URIs (`/aaa9`, `/ab2h`, `/aab8` …) are
//! the well-known "checksum8" routes that return the x86 / x64 stager.
//! * 4-byte XOR keys at the start of a returned stager confirm a beacon
//! payload.
//!
//! This scanner probes the target on the supplied port (default 443) and
//! reports any of the above markers. It is detection-only — no
//! key-material extraction or stager execution is attempted.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 443;
const TIMEOUT_SECS: u64 = 10;
/// Well-known Cobalt Strike staging URIs (checksum8 endpoints).
const STAGING_URIS: &[&str] = &[
"/aaa9", // x86 stager
"/aab8", // alt x86
"/ab2h", // x64 stager
"/aaba", // alt x64
"/ab9a", // alt
];
/// Hex SHA1 fingerprint of the long-shipped default Cobalt Strike cert.
const DEFAULT_CS_CERT_SHA1_PREFIX: &str = "6ece5ece";
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Cobalt Strike — Team Server / Beacon Fingerprint".to_string(),
description: "Probes a target for Cobalt Strike team server / beacon\n\
indicators: default certificate SHA1 prefix (6ECE5ECE),\n\
NanoHTTPD 404 with Content-Length 0 quirk, well-known\n\
checksum8 staging URIs (aaa9/aab8/ab2h/...), 4-byte XOR\n\
key prologue on stager responses. Detection-only — does\n\
not extract beacon configurations or run stagers."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://www.cobaltstrike.com/".to_string(),
"https://www.elastic.co/security-labs/disclosing-the-bloodhound-from-zero-to-hero".to_string(),
"https://attack.mitre.org/software/S0154/".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Great,
default_port: Some(DEFAULT_PORT),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("cobaltstrike_beacon_scanner requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "Probe port (CS typically 443/80/8080)", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (https/http)", "https").await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ Cobalt Strike — Team Server / Beacon Fingerprint ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
let mut indicators: Vec<&'static str> = Vec::new();
let mut staging_hits: Vec<String> = Vec::new();
let mut stager_xor_key: Option<String> = None;
// 1. NanoHTTPD 404 quirk — non-existent path returns 404 with CL: 0
let probe_random = format!(
"{}/{}",
base,
// 12-char alpha probe — collision odds with operator paths are negligible
"rsprobe404chk"
);
crate::mprintln!("{} GET {}", "[*]".blue(), probe_random);
if let Ok(r) = client.get(&probe_random).send().await {
let status = r.status().as_u16();
let server = crate::utils::header_string(r.headers(), "server");
let cl = crate::utils::header_string(r.headers(), "content-length");
if status == 404 && cl == "0" && (server.is_empty() || server.to_lowercase().contains("nano")) {
crate::mprintln!(
"{} NanoHTTPD-style 404 with CL:0 (CS team server pattern)",
"[+]".green()
);
indicators.push("nanohttpd_404_cl0");
}
}
// 2. Staging URI probes
for uri in STAGING_URIS {
let url = format!("{}{}", base, uri);
if let Ok(r) = client.get(&url).send().await {
let status = r.status().as_u16();
if status != 200 {
continue;
}
let bytes = match r.bytes().await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} stager {} body read failed: {}", "[-]".yellow(), uri, e);
continue;
}
};
if bytes.len() < 16 {
continue;
}
crate::mprintln!(
"{} staging URI {} returned {} bytes",
"[+]".green(),
uri,
bytes.len()
);
staging_hits.push((*uri).to_string());
// CS x86 stager begins with a small loader prologue; look for the
// standard pattern `\xfc\xe8` (CLD; CALL) typical of shellcode.
if bytes.starts_with(&[0xfc, 0xe8]) || bytes.starts_with(&[0xfc, 0x48, 0x83]) {
crate::mprintln!("{} stager prologue matches shellcode pattern", "[+]".green());
indicators.push("stager_shellcode_prologue");
}
// The first 4 bytes of a beacon payload are often the XOR key —
// check whether bytes[4..8] XOR with bytes[0..4] decodes to a
// plausible PE / MZ header by sampling.
let mut key = [0u8; 4];
key.copy_from_slice(&bytes[0..4]);
if let Some(probe_idx) = bytes.windows(2).position(|w| w == b"MZ") {
if probe_idx >= 8 {
stager_xor_key = Some(format!(
"{:02x}{:02x}{:02x}{:02x}",
key[0], key[1], key[2], key[3]
));
}
}
}
}
if !staging_hits.is_empty() {
indicators.push("staging_uri_responsive");
}
// 3. TLS certificate SHA1 probe (HTTPS only)
if scheme.eq_ignore_ascii_case("https") {
match cert_sha1_for(&normalized, port).await {
Some(sha1_hex) => {
let sha1_lc = sha1_hex.to_ascii_lowercase();
crate::mprintln!("{} TLS cert SHA1: {}", "[*]".blue(), sha1_hex);
if sha1_lc.starts_with(DEFAULT_CS_CERT_SHA1_PREFIX) {
crate::mprintln!(
"{} default Cobalt Strike self-signed cert detected (6ECE5ECE prefix)",
"[+]".green().bold()
);
indicators.push("default_cs_cert_sha1");
}
}
None => {
crate::mprintln!("{} could not retrieve TLS certificate", "[-]".yellow());
}
}
}
if indicators.is_empty() && staging_hits.is_empty() {
crate::mprintln!("{} no Cobalt Strike indicators found", "[-]".yellow());
return Ok(outcome);
}
crate::workspace::track_host(&normalized, None, Some("Cobalt Strike (suspected)")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Cobalt Strike infrastructure indicators at {} ({} markers)",
base,
indicators.len() + staging_hits.len()
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"indicators": indicators,
"staging_hits": staging_hits,
"stager_xor_key_hex": stager_xor_key,
})),
});
Ok(outcome)
}
/// Retrieve the SHA1 hex string of the peer TLS leaf certificate. Accepts
/// any certificate (CS team servers are self-signed by design) using the
/// project's `dangerous` rustls verifier path — same shape used by
/// `scanners/ssl_scanner.rs`.
async fn cert_sha1_for(host: &str, port: u16) -> Option<String> {
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use std::sync::Arc;
use tokio_rustls::TlsConnector;
#[derive(Debug)]
struct CertCapture {
leaf: Arc<std::sync::Mutex<Option<Vec<u8>>>>,
}
impl ServerCertVerifier for CertCapture {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
if let Ok(mut g) = self.leaf.lock() {
*g = Some(end_entity.to_vec());
}
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
let leaf: Arc<std::sync::Mutex<Option<Vec<u8>>>> = Arc::new(std::sync::Mutex::new(None));
let verifier = CertCapture {
leaf: Arc::clone(&leaf),
};
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", host, port);
let tcp = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(TIMEOUT_SECS))
.await
.ok()?;
// SNI must match the supplied host — substituting "localhost" would silently
// hand the team server the wrong hostname and produce a misleading cert.
let server_name = ServerName::try_from(host.to_string()).ok()?;
let _tls = tokio::time::timeout(
Duration::from_secs(TIMEOUT_SECS),
connector.connect(server_name, tcp),
)
.await
.ok()?
.ok()?;
let der = leaf.lock().ok()?.clone()?;
let mut hasher = sha1_state();
sha1_update(&mut hasher, &der);
let digest = sha1_finalize(hasher);
Some(digest.iter().map(|b| format!("{:02x}", b)).collect())
}
// --- Minimal embedded SHA1 (no extra crate dependency) ---
#[derive(Clone)]
struct Sha1State {
h: [u32; 5],
buf: Vec<u8>,
len: u64,
}
fn sha1_state() -> Sha1State {
Sha1State {
h: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
buf: Vec::with_capacity(64),
len: 0,
}
}
fn sha1_update(s: &mut Sha1State, data: &[u8]) {
s.len = s.len.wrapping_add(data.len() as u64);
s.buf.extend_from_slice(data);
while s.buf.len() >= 64 {
let block: [u8; 64] = s.buf[..64].try_into().expect("64-byte block");
sha1_compress(&mut s.h, &block);
s.buf.drain(..64);
}
}
fn sha1_finalize(mut s: Sha1State) -> [u8; 20] {
let bit_len = s.len.wrapping_mul(8);
s.buf.push(0x80);
while s.buf.len() % 64 != 56 {
s.buf.push(0);
}
s.buf.extend_from_slice(&bit_len.to_be_bytes());
while s.buf.len() >= 64 {
let block: [u8; 64] = s.buf[..64].try_into().expect("64-byte block");
sha1_compress(&mut s.h, &block);
s.buf.drain(..64);
}
let mut out = [0u8; 20];
for (i, word) in s.h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
fn sha1_compress(h: &mut [u32; 5], block: &[u8; 64]) {
let mut w = [0u32; 80];
for i in 0..16 {
w[i] = u32::from_be_bytes(block[i * 4..i * 4 + 4].try_into().expect("4 bytes"));
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = *h;
for (i, &word) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(word);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
#[cfg(test)]
mod tests {
use super::*;
fn sha1_hex(data: &[u8]) -> String {
let mut s = sha1_state();
sha1_update(&mut s, data);
let d = sha1_finalize(s);
d.iter().map(|b| format!("{:02x}", b)).collect()
}
#[test]
fn sha1_known_vectors() {
assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
sha1_hex(b"The quick brown fox jumps over the lazy dog"),
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"
);
}
}
crate::register_native_module!(
crate::module::Category::Scanners,
"cobaltstrike_beacon_scanner",
native
);
+127
View File
@@ -0,0 +1,127 @@
//! JARM Active TLS Server Fingerprinting Scanner
//!
//! JARM (Salesforce, BSD-3-Clause) actively fingerprints a TLS server by
//! sending 10 hand-crafted ClientHello packets and folding the ServerHello
//! responses into a 62-character hash. The hash is comparable with other JARM
//! tooling and is useful for identifying server stacks, clustering
//! infrastructure, and spotting malware C2 / default deployments.
//!
//! For authorized security testing only.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::cfg_prompt_port;
use crate::utils::tls_fingerprint::jarm_fingerprint;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "JARM TLS Server Fingerprint".into(),
description: "Actively fingerprints a TLS server using the JARM technique: sends 10 \
crafted TLS ClientHello packets and folds the ServerHello responses into a \
62-character hash for server-stack identification and infrastructure clustering."
.into(),
authors: vec![
"rustsploit contributors".into(),
"Salesforce (JARM algorithm, BSD-3-Clause)".into(),
],
references: vec![
"https://github.com/salesforce/jarm".into(),
"https://engineering.salesforce.com/easily-identify-malicious-servers-on-the-internet-with-jarm-e095edac525a".into(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: Some(443),
}
}
fn display_banner() {
if crate::utils::is_batch_mode() {
return;
}
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
crate::mprintln!("{}", "║ JARM TLS Server Fingerprint ║".cyan());
crate::mprintln!("{}", "║ 10-probe active TLS fingerprint (Salesforce JARM) ║".cyan());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
crate::mprintln!();
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("jarm_scan requires a single-host target")?;
display_banner();
let mut outcome = ModuleOutcome::ok();
let port = cfg_prompt_port("port", "TLS port", 443).await?;
let timeout = Duration::from_secs(10);
crate::mprintln!("{}", format!("[*] Target: {}:{}", target, port).cyan());
crate::mprintln!("{}", "[*] Sending 10 JARM probes...".dimmed());
// Honour the rate limiter before the network round-trips. The fingerprint
// opens one connection per probe; gate at the top so mass-scan runs stay
// within the operator's configured RPS.
ctx.rate_limit(target).await;
let report = jarm_fingerprint(target, port, timeout)
.await
.with_context(|| format!("JARM fingerprint of {}:{} failed", target, port))?;
let responsive = report.probes.iter().filter(|p| p.responded()).count();
let all_zero = report.jarm.chars().all(|c| c == '0');
if all_zero {
crate::mprintln!(
"{}",
format!("[-] No JARM response from {}:{} (host down, not TLS, or all probes rejected)", target, port)
.yellow()
);
crate::mprintln!("{}", format!(" JARM: {}", report.jarm).dimmed());
} else {
crate::mprintln!("{}", format!("[+] JARM: {}", report.jarm).green().bold());
crate::mprintln!(
"{}",
format!("[+] {}/{} probes elicited a ServerHello", responsive, report.probes.len()).green()
);
if let Some(ref ja3s) = report.ja3s {
crate::mprintln!("{}", format!("[+] JA3S: {}", ja3s).green());
}
}
if let Some(ref ja3) = report.client_ja3 {
crate::mprintln!("{}", format!("[*] Client JA3 (this scan): {}", ja3).dimmed());
}
outcome.findings.push(Finding {
target: format!("{}:{}", target, port),
kind: FindingKind::Banner,
message: format!("JARM fingerprint {} ({}:{})", report.jarm, target, port),
data: Some(serde_json::json!({
"host": target,
"port": port,
"service": "tls",
"jarm": report.jarm,
"ja3s": report.ja3s,
"client_ja3": report.client_ja3,
"probes_responded": responsive,
"probes_total": report.probes.len(),
})),
});
crate::events::emit(crate::events::ModuleEvent::ServiceDetected {
host: target.to_string(),
port,
service: "tls".to_string(),
version: Some(format!("JARM={}", report.jarm)),
});
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Scanners, "jarm_scan", native);
+3
View File
@@ -1,5 +1,6 @@
pub mod api_endpoint_scanner;
pub mod asterisk_fingerprint;
pub mod cobaltstrike_beacon_scanner;
pub mod cors_reflection_scanner;
pub mod cpanel_exposure;
pub mod csp_audit_scanner;
@@ -13,6 +14,7 @@ pub mod honeypot_scanner;
pub mod http_method_scanner;
pub mod http_title_scanner;
pub mod iusb_virtualmedia_probe;
pub mod jarm_scan;
pub mod m365_userenum_scanner;
pub mod nbns_scanner;
pub mod ping_sweep;
@@ -38,6 +40,7 @@ pub mod ssl_scanner;
pub mod stalkroute_full_traceroute;
pub mod subdomain_scanner;
pub mod subdomain_takeover_scanner;
pub mod synology_dsm_disclosure;
pub mod vnc_scanner;
pub mod vuln_checker;
pub mod waf_detector;
+91 -1
View File
@@ -120,6 +120,13 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
String::new()
};
// Prompt-harvest dry run: every `cfg_prompt_*` answer above is now cached
// for the batch, so there's nothing more to do. Returning here means the
// placeholder host is never actually scanned and no results file is written.
if ctx.prompt_only {
return Ok(outcome);
}
// --- Run scan ---
crate::mprintln!();
crate::mprintln!(
@@ -188,7 +195,13 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
print_results_table(&results);
// --- Save to file ---
if save_results && !output_file.is_empty() {
// In a mass-scan fan-out (`batch_mode`), every host runs this module. A
// per-host file save would have all hosts racing to overwrite the same path,
// and the status lines would spam the console once per host. So restrict the
// file save + the per-host "scan complete" summary to interactive
// single-target runs; in a sweep, findings flow through `outcome` instead and
// only hosts that actually have services print their results table.
if save_results && !output_file.is_empty() && !crate::utils::is_batch_mode() {
save_results_to_file(&results, &output_file, target)?;
crate::mprintln!(
"{}",
@@ -196,12 +209,14 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
);
}
if !crate::utils::is_batch_mode() {
crate::mprintln!(
"\n{}",
format!("[*] Scan complete: {} services detected on {}", results.len(), target)
.green()
.bold()
);
}
Ok(outcome)
}
@@ -233,7 +248,12 @@ fn display_banner() {
fn print_results_table(results: &[ServiceResult]) {
if results.is_empty() {
// Don't print "no services" for every host of a mass sweep — that is the
// common case across millions of hosts and would bury the real hits.
// Keep it for interactive single-target runs where it's useful feedback.
if !crate::utils::is_batch_mode() {
crate::mprintln!("{}", "[!] No services detected.".yellow());
}
return;
}
@@ -310,6 +330,57 @@ fn sanitize_banner(raw: &str) -> String {
.join(" ")
}
/// Run the captured banner through the Recog fingerprint database `db_name`
/// (e.g. "ssh", "ftp", "smtp", "http", "mysql") and, on a match, enrich the
/// `ServiceResult` with the structured product/version/OS Recog extracted.
///
/// Recog's structured product+version takes precedence over the manual
/// substring parse (which it supersedes); OS and vendor details are recorded in
/// the `notes` field. Returns `true` when a Recog fingerprint matched.
fn enrich_with_recog(r: &mut ServiceResult, db_name: &str, banner: &str) -> bool {
if banner.trim().is_empty() {
return false;
}
let m = crate::utils::recog::match_banner(db_name, banner);
if !m.matched {
return false;
}
tracing::debug!(
db = db_name,
port = r.port,
"service_scanner: recog fingerprint matched"
);
// Prefer Recog's product+version summary as the version label.
if let Some(summary) = m.summary() {
if !summary.is_empty() {
r.version = summary;
}
}
// Append vendor / OS detail to notes without clobbering existing notes.
let mut extra: Vec<String> = Vec::new();
if let Some(vendor) = m.vendor() {
extra.push(format!("vendor={}", vendor));
}
if let Some(os) = m.os_product() {
extra.push(format!("os={}", os));
}
if let Some(cpe) = m.get("service.cpe23") {
extra.push(format!("cpe={}", cpe));
}
if !extra.is_empty() {
let detail = format!("recog: {}", extra.join(", "));
if r.notes.is_empty() {
r.notes = detail;
} else {
r.notes = format!("{}, {}", r.notes, detail);
}
}
true
}
fn save_results_to_file(
results: &[ServiceResult],
path: &str,
@@ -457,6 +528,11 @@ async fn probe_ftp(mut stream: TcpStream, port: u16, dur: Duration) -> Option<Se
r.version = extract_version_after(&r.banner, "wu-");
}
// Recog fingerprint pass — overrides/augments the manual parse above with a
// structured product/version and vendor/OS detail when a fingerprint hits.
let ftp_banner = r.banner.clone();
enrich_with_recog(&mut r, "ftp", &ftp_banner);
// Check anonymous access — proper FTP handshake
let anon_cmd = b"USER anonymous\r\n";
if stream.write_all(anon_cmd).await.is_ok() {
@@ -513,6 +589,10 @@ async fn probe_ssh(mut stream: TcpStream, port: u16, dur: Duration) -> Option<Se
}
}
// Recog fingerprint pass — resolves OpenSSH/Dropbear/RouterOS etc. to a
// structured product + version, replacing the raw banner-prefix above.
enrich_with_recog(&mut r, "ssh", &banner);
Some(r)
}
@@ -590,6 +670,10 @@ async fn probe_smtp(mut stream: TcpStream, port: u16, dur: Duration) -> Option<S
r.notes = "Service ready".to_string();
}
// Recog fingerprint pass — structured MTA product/version (Exim, Postfix,
// Sendmail, Exchange) plus OS detail when the banner reveals it.
enrich_with_recog(&mut r, "smtp", &banner);
Some(r)
}
@@ -673,6 +757,9 @@ async fn probe_https(target: &str, port: u16, dur: Duration) -> Option<ServiceRe
if !server.is_empty() {
r.version = server.clone();
r.notes = format!("Server: {}", server);
// Recog fingerprint pass over the HTTP Server header — yields a
// structured web-server product/version and host-OS detail.
enrich_with_recog(&mut r, "http", &server);
}
if !powered_by.is_empty() {
if r.notes.is_empty() {
@@ -720,6 +807,9 @@ async fn probe_mysql(mut stream: TcpStream, port: u16, dur: Duration) -> Option<
if version_str.to_lowercase().contains("mariadb") {
r.service = "MariaDB".to_string();
}
// Recog fingerprint pass over the raw handshake version string —
// distinguishes MySQL vs MariaDB and pins the numeric version/CPE.
enrich_with_recog(&mut r, "mysql", &version_str);
}
}
@@ -0,0 +1,326 @@
//! Synology DSM — Unauthenticated API Disclosure Scanner
//! ======================================================
//!
//! Synology DiskStation Manager (DSM 6.x / 7.x) exposes a `/webapi/entry.cgi`
//! dispatch interface. Several APIs are reachable WITHOUT an authenticated
//! session and leak high-value configuration data:
//!
//! * `SYNO.API.Info` — full API catalog (typically 800+ entries)
//! * `SYNO.API.Auth.Type` — accepted authentication methods
//! * `SYNO.API.Auth.UIConfig` — login UI / 2FA configuration
//! * `SYNO.API.Encryption` — 4096-bit RSA login encryption pubkey
//! * `SYNO.Core.Desktop.SessionData` — hostname, internal HTTP/HTTPS ports,
//! `is_secure` flag, language, 2FA settings
//! * `SYNO.Core.Desktop.Initdata` — installed package list (often
//! reveals pirated / unofficial SPK packages such as `dmtc.spk`)
//!
//! This module is detection-only — it sends GET requests against the public
//! webapi dispatcher and records which endpoints leaked usable data.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::safe_io::DEFAULT_BODY_CAP;
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 5000;
const TIMEOUT_SECS: u64 = 12;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Synology DSM — Unauthenticated API Disclosure".to_string(),
description: "Probes Synology DiskStation Manager web APIs reachable without\n\
authentication: API catalog, SessionData, Initdata, Auth type,\n\
RSA encryption key. Detects pirated / unofficial packages such\n\
as dmtc.spk and reports hostname / internal port mapping."
.to_string(),
authors: vec!["RustSploit Team".to_string()],
references: vec![
"https://kb.synology.com/en-global/DG/DSM_developer_guide/preface".to_string(),
],
disclosure_date: Some("2026-06-12".to_string()),
rank: ModuleRank::Great,
default_port: Some(DEFAULT_PORT),
}
}
const PROBE_APIS: &[(&str, &str, &str)] = &[
(
"/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query&query=all",
"SYNO.API.Info",
"api_catalog",
),
(
"/webapi/entry.cgi?api=SYNO.API.Auth.Type&version=1&method=get",
"SYNO.API.Auth.Type",
"auth_type",
),
(
"/webapi/entry.cgi?api=SYNO.API.Auth.UIConfig&version=1&method=get",
"SYNO.API.Auth.UIConfig",
"auth_ui_config",
),
(
"/webapi/entry.cgi?api=SYNO.API.Encryption&version=1&method=getinfo",
"SYNO.API.Encryption",
"rsa_pubkey",
),
(
"/webapi/entry.cgi?api=SYNO.Core.Desktop.SessionData&version=1&method=get",
"SYNO.Core.Desktop.SessionData",
"session_data",
),
(
"/webapi/entry.cgi?api=SYNO.Core.Desktop.Initdata&version=1&method=get",
"SYNO.Core.Desktop.Initdata",
"initdata",
),
];
const PIRATED_PACKAGES: &[&str] = &["dmtc.spk", "yunmeng", "clouddream"];
const UNOFFICIAL_PACKAGES: &[&str] = &["alist3", "aliyundrive-webdav", "aria2", "homebridge"];
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("synology_dsm_disclosure requires a single-host target")?;
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "DSM HTTP port (often 5000, 5001 HTTPS)", DEFAULT_PORT).await?;
let scheme = cfg_prompt_default("scheme", "Scheme (http/https)", "http").await?;
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ Synology DSM — Unauthenticated API Disclosure ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!("{} {}", "[*] Target:".yellow(), base);
let mut outcome = ModuleOutcome::ok();
let mut leaked_apis: Vec<String> = Vec::new();
let mut detected_packages: Vec<String> = Vec::new();
let mut pirated_hits: Vec<String> = Vec::new();
let mut hostname: Option<String> = None;
let mut http_port_internal: Option<u32> = None;
let mut https_port_internal: Option<u32> = None;
let mut is_secure: Option<bool> = None;
let mut api_count: Option<usize> = None;
let mut dsm_fingerprinted = false;
for (path, api_name, label) in PROBE_APIS {
let url = format!("{}{}", base, path);
if let Ok(r) = client.get(&url).send().await {
let status = r.status();
if !status.is_success() {
continue;
}
let body = match crate::utils::network::read_http_body_text_capped(r, DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} {} body read failed: {}", "[-]".yellow(), api_name, e);
continue;
}
};
// Synology returns JSON-like text — looking for {"success":true,"data":...}
if !body.contains("\"success\":true") && !body.contains("\"data\"") {
continue;
}
dsm_fingerprinted = true;
crate::mprintln!("{} {} reachable ({} bytes)", "[+]".green(), api_name, body.len());
leaked_apis.push((*api_name).to_string());
match *label {
"api_catalog" => {
// Count distinct "SYNO." references as a coarse API count
let count = body.matches("\"SYNO.").count();
if count > 0 {
api_count = Some(count);
}
}
"session_data" | "initdata" => {
if let Some(h) = extract_string_value(&body, "hostname") {
hostname.get_or_insert(h);
}
if let Some(p) = extract_num_value(&body, "dsm_http_port") {
http_port_internal.get_or_insert(p);
}
if let Some(p) = extract_num_value(&body, "dsm_https_port") {
https_port_internal.get_or_insert(p);
}
if let Some(b) = extract_bool_value(&body, "is_secure") {
is_secure.get_or_insert(b);
}
// Scan for installed packages — extract names referenced as keys
let lower = body.to_lowercase();
for pkg in PIRATED_PACKAGES {
if lower.contains(&pkg.to_lowercase()) {
pirated_hits.push((*pkg).to_string());
}
}
for pkg in UNOFFICIAL_PACKAGES {
if lower.contains(&pkg.to_lowercase()) {
detected_packages.push((*pkg).to_string());
}
}
}
_ => {}
}
}
}
if !dsm_fingerprinted {
crate::mprintln!(
"{} no DSM webapi responses — target may not be Synology or is firewalled",
"[-]".yellow()
);
return Ok(outcome);
}
if let Some(h) = &hostname {
crate::mprintln!("{} hostname: {}", "[+]".green(), h);
}
if let Some(p) = http_port_internal {
crate::mprintln!("{} internal HTTP port: {}", "[+]".green(), p);
}
if let Some(p) = https_port_internal {
crate::mprintln!("{} internal HTTPS port: {}", "[+]".green(), p);
}
if let Some(s) = is_secure {
crate::mprintln!("{} is_secure flag: {}", "[+]".green(), s);
}
if let Some(c) = api_count {
crate::mprintln!("{} approximate API catalog size: {}", "[+]".green(), c);
}
if !detected_packages.is_empty() {
crate::mprintln!(
"{} unofficial packages: {}",
"[+]".green(),
detected_packages.join(", ")
);
}
if !pirated_hits.is_empty() {
crate::mprintln!(
"{} PIRATED / cracked packages detected: {}",
"[!]".red().bold(),
pirated_hits.join(", ")
);
}
crate::workspace::track_host(&normalized, hostname.as_deref(), Some("Synology DSM")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Synology DSM at {} exposes {} unauthenticated API endpoint(s)",
base,
leaked_apis.len()
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"leaked_apis": leaked_apis,
"hostname": hostname,
"http_port_internal": http_port_internal,
"https_port_internal": https_port_internal,
"is_secure": is_secure,
"api_catalog_size": api_count,
"unofficial_packages": detected_packages,
"pirated_packages": pirated_hits,
})),
});
if !pirated_hits.is_empty() {
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Synology DSM at {} runs pirated package(s): {}",
normalized,
pirated_hits.join(", ")
),
data: Some(serde_json::json!({
"host": normalized,
"pirated_packages": pirated_hits,
})),
});
}
Ok(outcome)
}
fn extract_string_value(body: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\":", key);
let idx = body.find(&needle)?;
let tail = &body[idx + needle.len()..].trim_start();
let tail = tail.strip_prefix('"')?;
let end = tail.find('"')?;
Some(tail[..end].to_string())
}
fn extract_num_value(body: &str, key: &str) -> Option<u32> {
let needle = format!("\"{}\":", key);
let idx = body.find(&needle)?;
let tail = body[idx + needle.len()..].trim_start();
let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
}
fn extract_bool_value(body: &str, key: &str) -> Option<bool> {
let needle = format!("\"{}\":", key);
let idx = body.find(&needle)?;
let tail = body[idx + needle.len()..].trim_start();
if tail.starts_with("true") {
Some(true)
} else if tail.starts_with("false") {
Some(false)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_string_value() {
let body = r#"{"hostname":"ChenHome","other":"x"}"#;
assert_eq!(extract_string_value(body, "hostname"), Some("ChenHome".to_string()));
}
#[test]
fn parses_num_value() {
let body = r#"{"dsm_http_port":5000,"dsm_https_port":5001}"#;
assert_eq!(extract_num_value(body, "dsm_http_port"), Some(5000));
}
#[test]
fn parses_bool_value() {
assert_eq!(extract_bool_value(r#"{"is_secure":false}"#, "is_secure"), Some(false));
assert_eq!(extract_bool_value(r#"{"is_secure":true}"#, "is_secure"), Some(true));
}
}
crate::register_native_module!(
crate::module::Category::Scanners,
"synology_dsm_disclosure",
native
);
+8
View File
@@ -175,6 +175,7 @@ pub fn _mprint_line(text: &str) {
if let Err(e) = crate::spool::SPOOL.write_line(text) {
handle_spool_error(e);
}
crate::results_sink::write_line(text);
}
}
@@ -188,6 +189,7 @@ pub fn _mprint_newline() {
if let Err(e) = crate::spool::SPOOL.write_line("") {
handle_spool_error(e);
}
crate::results_sink::write_line("");
}
}
@@ -207,6 +209,7 @@ pub fn _mprint_raw(text: &str) {
if let Err(e) = crate::spool::SPOOL.write_raw(text) {
handle_spool_error(e);
}
crate::results_sink::write_raw(text);
}
}
@@ -226,6 +229,7 @@ pub fn _mprint_block(lines: &[&str]) {
if let Err(e) = crate::spool::SPOOL.write_line(line) {
handle_spool_error(e);
}
crate::results_sink::write_line(line);
}
drop(guard);
}
@@ -238,6 +242,8 @@ pub fn _meprint_line(text: &str) {
});
if buffered.is_err() {
eprintln!("{}", text);
// "all output" includes diagnostics: capture stderr in the per-run file too.
crate::results_sink::write_line(text);
}
}
@@ -248,6 +254,7 @@ pub fn _meprint_newline() {
});
if buffered.is_err() {
eprintln!();
crate::results_sink::write_line("");
}
}
@@ -262,6 +269,7 @@ pub fn _meprint_raw(text: &str) {
if let Err(e) = std::io::stderr().flush() {
eprintln!("[!] Flush failed: {}", e);
}
crate::results_sink::write_raw(text);
}
}
+110
View File
@@ -0,0 +1,110 @@
// src/results_sink.rs
//
// Per-run auto-save. When active, every console line a module emits is appended
// to `~/.rustsploit/loot/<module> <time> results.txt`. This mirrors the
// global-write pattern of `crate::spool` (so output from spawned per-host tasks
// in a mass scan is captured too), but it APPENDS rather than truncates and
// writes under the loot directory with an auto-generated, per-run filename.
//
// The caller (`commands::run_module`) scopes this to interactive console / CLI
// runs only — those are sequential, so a single global handle is race-free.
// API / MCP runs already return their captured output to the caller via
// `OUTPUT_BUFFER`, so they are not auto-saved here.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::RwLock;
use once_cell::sync::Lazy;
/// The active per-run results file, or `None` when no run is being saved.
static SINK: Lazy<RwLock<Option<File>>> = Lazy::new(|| RwLock::new(None));
/// Warn at most once if appends start failing (e.g. disk full), so a broken
/// sink can't flood the console with one error per output line.
static WRITE_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn warn_once(e: std::io::Error) {
if !WRITE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!("[!] Results auto-save write failed (further errors suppressed): {e}");
}
}
/// Acquire the sink write lock, recovering from a poisoned lock rather than
/// panicking — the guarded value is just an `Option<File>` with no broken
/// invariant a prior panic could have left behind.
fn sink_write() -> std::sync::RwLockWriteGuard<'static, Option<File>> {
match SINK.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn sink_read() -> std::sync::RwLockReadGuard<'static, Option<File>> {
match SINK.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Begin auto-saving the current run's output. Opens (in APPEND mode) the
/// per-run file `<loot>/<module> <YYYY-MM-DD_HH-MM-SS> results.txt` and makes it
/// the active sink. Failure is non-fatal: the run continues, just unsaved.
pub fn begin(module_path: &str) {
// Tenant-scoped loot directory (falls back to the global ~/.rustsploit/loot
// store in shell/CLI mode); the store creates the directory if needed.
let loot_dir = crate::tenant::resolve()
.loot_store()
.loot_directory()
.clone();
// Module paths contain `/` (e.g. "scanners/service_scanner"); flatten them so
// the result is a single file rather than a nested path.
let safe_module = module_path.replace(['/', '\\'], "_");
let ts = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
let file_name = format!("{safe_module} {ts} results.txt");
let path = loot_dir.join(&file_name);
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(mut file) => {
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
if let Err(e) = writeln!(file, "\n# === {module_path} @ {stamp} ===") {
warn_once(e);
}
*sink_write() = Some(file);
}
Err(e) => {
crate::meprintln!("[!] Could not open results file {}: {e}", path.display());
}
}
}
/// Stop auto-saving the current run and drop the file handle.
pub fn end() {
*sink_write() = None;
}
/// Append a line (with newline) to the active sink, if any. Invoked from the
/// console output routing functions next to the real stdout/stderr write.
pub fn write_line(text: &str) {
let guard = sink_read();
if let Some(file) = guard.as_ref() {
// `impl Write for &File` lets concurrent tasks append through a shared
// read guard without taking the write lock per line.
let mut handle: &File = file;
if let Err(e) = writeln!(handle, "{text}") {
warn_once(e);
}
}
}
/// Append raw text (no trailing newline) to the active sink, if any.
pub fn write_raw(text: &str) {
let guard = sink_read();
if let Some(file) = guard.as_ref() {
let mut handle: &File = file;
if let Err(e) = write!(handle, "{text}") {
warn_once(e);
}
}
}
+223 -27
View File
@@ -11,6 +11,7 @@
// during migration; new code (CLI, shell, API, MCP) calls
// `scheduler::run` directly.
use std::io::IsTerminal;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
@@ -285,6 +286,23 @@ async fn run_with_limits_shared(
if l.precheck_port.is_none() {
l.precheck_port = module.info().default_port;
}
// Full-sweep cap consistency: a literal `0.0.0.0/0` / `random` target
// entered directly (e.g. `use <mod>; run`) never passes through the
// shell's `setg target` handler, so without this it would silently fall
// back to the scheduler default of 10_000 — while `setg target 0.0.0.0`
// auto-bumps to the full public-IPv4 count. Normalise both paths: when
// the operator has NOT explicitly set `max_random_hosts`, a full sweep
// means every reachable public host. An explicit `setg max_random_hosts`
// is always honoured. The advisory + confirmation gate still runs.
if matches!(target, Target::Random) {
let operator_set = crate::tenant::resolve()
.global_options()
.try_get("max_random_hosts")
.is_some();
if !operator_set {
l.max_random_hosts = crate::utils::cyclic::total_public_ipv4_count() as usize;
}
}
l
};
@@ -390,17 +408,38 @@ async fn fanout_single(
ctx.tenant_id = tenant_id;
ctx.verbose = verbose;
ctx.module_path = module_path;
// Interactive modules (e.g. wpair's REPL) own their own lifetime — running
// them under the per-target deadline would kill the session the moment it
// waits for input. Everything else keeps the timeout.
let outcome = if module.capabilities().interactive {
module.run(&ctx).await?
} else {
// The per-target deadline is a MASS-SCAN safeguard: when fanning out across
// many hosts, one slow/hung host must not starve the scan. For a SINGLE,
// deliberately-chosen target it is actively harmful:
// * interactive modules (wpair's REPL) own their own session lifetime, and
// * any module that prompts the operator inside run() (e.g. asyncssh's
// "Username to impersonate") gets killed the moment the human takes
// longer than the deadline to type. Worse, that prompt is a blocking
// `spawn_blocking` stdin read which cannot be cancelled — killing the
// run orphans the reader thread, which then races the shell for the next
// line (the "lag after error" + corrupted REPL input).
//
// Enforce the deadline only when there is NO human at the console to answer
// prompts or hit Ctrl-C — i.e. an API/MCP/background run (api_mode), or a
// scripted/piped CLI run whose stdin is not a terminal. In those contexts
// cfg_prompt_* resolves from options/defaults (it never blocks on a real
// keyboard) and the run must stay bounded so it can't hang a request or a
// script. An interactive console (stdin is a TTY) is exempt: the operator
// chose this single target, can watch it, and can interrupt it — and a
// prompt blocking on the keyboard must never count against a deadline.
// Interactive modules (wpair's REPL) are always exempt. Mass-scan fan-out
// paths keep their own per-host timeouts (fanout_cidr/_file/_random).
let no_console_operator =
crate::config::get_module_config().api_mode || !std::io::stdin().is_terminal();
let enforce_deadline = !module.capabilities().interactive && no_console_operator;
let outcome = if enforce_deadline {
tokio::time::timeout(Duration::from_secs(limits.timeout_secs), module.run(&ctx))
.await
.map_err(|e| {
anyhow::anyhow!("Module timed out after {}s: {e}", limits.timeout_secs)
})??
} else {
module.run(&ctx).await?
};
// Publish single-target progress to the job's scan counters (if any).
if let Some(c) = crate::context::scan_counters() {
@@ -842,6 +881,20 @@ async fn fanout_random(
module_path: String,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
// Full-internet sweep advisory + interactive confirmation FIRST. Declining
// must abort before anything is touched — including the prompt-harvest
// dry-run below, which actually runs the module against a placeholder host.
if !full_sweep_advisory_and_confirm(
&module,
&limits,
Some(limits.max_random_hosts),
"Random mass scan",
)
.await?
{
return Ok(ModuleOutcome::ok());
}
// --- Pre-batch interactive prompt phase ---
// Run module prompts ONCE before batch mode so the user can configure
// interactively. Answers are cached and reused for all targets.
@@ -878,22 +931,45 @@ async fn fanout_random(
crate::mprintln!(
"{}",
format!(
"[*] Will scan up to {} random hosts with concurrency {}",
"[*] Will scan up to {} hosts with concurrency {} (ZMap-style stateless permutation — no repeats)",
limits.max_random_hosts, limits.concurrency
)
.cyan()
);
let progress_step = progress_step_for(limits.max_random_hosts as u128);
let mut seen = std::collections::HashSet::<std::net::IpAddr>::new();
for _ in 0..limits.max_random_hosts {
// ZMap-style stateless permutation of the IPv4 space: every address is
// visited at most once with O(1) memory and no dedup table (see
// `crate::utils::cyclic`). This replaces the old random-sample + `HashSet`,
// which grew unboundedly and — as coverage rose — wasted an ever-larger
// share of samples re-drawing addresses it had already seen.
let mut cyclic = crate::utils::cyclic::CyclicIp::random()?;
let mut dispatched: usize = 0;
while dispatched < limits.max_random_hosts {
if cancel.is_cancelled() || stats.abort.load(Ordering::Relaxed) {
break;
}
let ip = crate::utils::generate_random_public_ip(&exclusions);
if !seen.insert(ip) {
// Pull the next real, non-excluded address from the permutation.
// Reserved ranges and operator exclusions are skipped without counting
// against the host budget (they were never valid targets). `None` means
// the entire address space has been enumerated.
let ip = 'pick: loop {
match cyclic.next() {
Some(addr) => {
if crate::utils::cyclic::is_reserved_ipv4(addr) {
continue;
}
let ip = std::net::IpAddr::V4(addr);
if exclusions.iter().any(|net| net.contains(ip)) {
continue;
}
break 'pick Some(ip);
}
None => break 'pick None,
}
};
let Some(ip) = ip else { break };
dispatched += 1;
let ip_str = ip.to_string();
if let Some(cp) = checkpoint.as_ref()
&& cp.already_processed(&ip_str).await {
@@ -1019,16 +1095,6 @@ async fn fanout_sequential(
const LAST_PUBLIC: u32 = 0xDFFF_FFFF; // 223.255.255.255 (end of Class A-C)
let cfg = crate::config::get_module_config();
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
let sem = shared_sem.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
let exclusions = crate::exclusions::shared();
let module_err = Arc::new(AtomicUsize::new(0));
// Optional operator cap. Unbounded unless `max_random_hosts` is explicitly set.
let cap: Option<usize> = match crate::tenant::resolve()
@@ -1048,6 +1114,25 @@ async fn fanout_sequential(
None => None,
};
// Full-internet sweep advisory + interactive confirmation FIRST. Declining
// must abort before anything is touched (the resume marker, and the
// prompt-harvest dry-run below which runs the module against a placeholder).
if !full_sweep_advisory_and_confirm(&module, &limits, cap, "Sequential mass scan").await? {
return Ok(ModuleOutcome::ok());
}
// Pre-batch interactive prompt phase (after confirmation).
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
let sem = shared_sem.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
let exclusions = crate::exclusions::shared();
let module_err = Arc::new(AtomicUsize::new(0));
// High-water-mark resume: the per-IP set used by random/CIDR does not scale
// to billions of addresses, so sequential stores the last dispatched IP and
// resumes from `hi + 1`.
@@ -1190,7 +1275,7 @@ async fn fanout_sequential(
dispatched += 1;
last_dispatched = ip;
// Persist the high-water mark periodically (cheap; bounds resume granularity).
if dispatched % progress_step.max(1) == 0 {
if dispatched.is_multiple_of(progress_step.max(1)) {
crate::checkpoint::write_seq_marker(&scan_id, ip);
}
@@ -1252,15 +1337,37 @@ async fn pre_batch_prompt(
ctx.cancel = cancel;
ctx.tenant_id = tenant_id;
ctx.batch_mode = false;
// Mark this as a prompt-harvest dry run: `cfg_prompt_*` still prompts
// (batch_mode is false), but a module that honours `prompt_only` will return
// immediately after gathering answers instead of doing real work against the
// placeholder host. Output is captured below as a belt-and-suspenders for
// modules that don't yet check the flag.
ctx.prompt_only = true;
ctx.prompt_cache = None;
ctx.module_path = module_path.to_string();
// Run with a short timeout — we expect it to fail on the network side,
// but prompts will have been answered by then.
let result = tokio::time::timeout(
Duration::from_secs(5),
// This dry-run exists ONLY to drive the module's `cfg_prompt_*` calls so a
// human can answer them once before the batch starts. The operator is
// typing at the console here, so the bound must be generous: a 5s deadline
// (the old value) would fire while the operator is still answering, and
// because each prompt is a blocking `spawn_blocking` stdin read that cannot
// be cancelled, killing the dry-run orphaned that reader thread — which then
// raced the next prompt / the shell for input. Keep a large backstop purely
// so a misbehaving module's post-prompt network work can't hang batch setup
// forever (the dummy target 0.0.0.1 normally fails fast); the operator can
// always Ctrl-C.
// Capture the dry-run's console output into a throwaway buffer so it never
// reaches the operator. This phase exists ONLY to drive the module's
// `cfg_prompt_*` calls; without this, a connect-based module (e.g.
// service_scanner) actually "scans" the placeholder host 0.0.0.1 and prints
// a confusing `Scanning 0.0.0.1 … 0 services detected` block. Interactive
// prompts use `print!` (real stdout) and stay visible; only `mprintln!` /
// `meprintln!` module output is suppressed.
let run_fut = crate::output::OUTPUT_BUFFER.scope(
crate::output::OutputBuffer::new(),
module.run(&ctx),
).await;
);
let result = tokio::time::timeout(Duration::from_secs(300), run_fut).await;
// Regardless of success/failure, the prompts have been answered and
// stored in global options by cfg_prompt_*. Swallow the error.
@@ -1280,6 +1387,95 @@ async fn pre_batch_prompt(
Ok(())
}
/// Print a one-time advisory before a full-internet sweep (`random` /
/// `0.0.0.0` / `0.0.0.0/0`, random or sequential) showing the knobs an operator
/// usually wants to set, then — on an interactive console only — ask for an
/// explicit confirmation. Returns `false` when the operator declines (the
/// caller aborts the sweep). API/MCP and piped/scripted runs are never prompted
/// (they proceed), mirroring how the per-target deadline is gated.
///
/// `cap` is the host ceiling for this sweep: `Some(n)` (random, or sequential
/// with `max_random_hosts` set) or `None` (an unbounded sequential sweep).
async fn full_sweep_advisory_and_confirm(
module: &Arc<dyn Module>,
limits: &SchedulerLimits,
cap: Option<usize>,
sweep_label: &str,
) -> Result<bool> {
// Effective service port for the per-host precheck. `limits.precheck_port`
// is already resolved to `setg port` if set, else the module's own
// `default_port` (see run_with_limits_shared) — exactly the "default to the
// module's port" behaviour. Surface where it came from so a stale global
// `setg port` that differs from the module default is visible.
let module_default = module.info().default_port;
let global_port = match crate::tenant::resolve().global_options().try_get("port") {
Some(v) => match v.trim().parse::<u16>() {
Ok(p) => Some(p),
Err(e) => {
// Surface, don't swallow: a bad `setg port` would otherwise be
// silently ignored and the operator would never know why the
// precheck used a different port.
crate::meprintln!(
"[!] Ignoring invalid `setg port` value '{}': {e}",
v.trim()
);
None
}
},
None => None,
};
let port_line = match (limits.precheck_port, global_port, module_default) {
(Some(p), Some(_), Some(d)) if p != d => {
format!("{p} (from `setg port`; this module's default is {d})")
}
(Some(p), Some(_), _) => format!("{p} (from `setg port`)"),
(Some(p), None, _) => format!("{p} (module default)"),
(None, _, _) => {
"none — this module declares no default port, so every host is probed".to_string()
}
};
let host_cap = match cap {
Some(n) => format!("{n} hosts"),
None => "unbounded (entire public IPv4 space)".to_string(),
};
let excl = crate::exclusions::shared().networks().len();
crate::mprintln!(
"{}",
format!(
"[!] {sweep_label}: '{}' will probe public hosts across the whole Internet.",
module.info().name
)
.yellow()
.bold()
);
crate::mprintln!("{}", " Settings for this sweep (override with `setg <key> <value>`):".yellow());
crate::mprintln!(" {:<17}{}", "port:", port_line);
crate::mprintln!(" {:<17}{} {}", "concurrency:", limits.concurrency, "(setg concurrency <n>)".dimmed());
crate::mprintln!(" {:<17}{} {}", "host cap:", host_cap, "(setg max_random_hosts <n>)".dimmed());
crate::mprintln!(" {:<17}{}s {}", "per-host timeout:", limits.timeout_secs, "(setg timeout <n>)".dimmed());
crate::mprintln!(
" {:<17}{} networks excluded (RFC1918/bogons/...) {}",
"exclusions:",
excl,
"(setg exclusions ...)".dimmed()
);
// Only a human at an interactive console is asked to confirm. API/MCP runs
// (api_mode) and piped/scripted CLI runs (stdin not a TTY) proceed — they
// opted in by passing the target and there is no keyboard to answer.
let cfg = crate::config::get_module_config();
let interactive_console = !cfg.api_mode && std::io::stdin().is_terminal();
if !interactive_console {
return Ok(true);
}
let proceed = crate::utils::prompt_yes_no("Proceed with the full-internet sweep?", false).await?;
if !proceed {
crate::mprintln!("{}", "[*] Full-internet sweep aborted by operator.".yellow());
}
Ok(proceed)
}
// ============================================================
// HELPERS
// ============================================================
+84 -4
View File
@@ -30,7 +30,7 @@ const MAX_PROMPT_INPUT_LENGTH: usize = 1024;
/// Shell commands available for tab completion.
const SHELL_COMMANDS: &[&str] = &[
"help", "modules", "find", "use", "set target", "set subnet",
"help", "tommy", "modules", "find", "use", "set target", "set subnet",
"set port", "set source_port", "set concurrency", "set timeout",
"set threads", "set wordlist", "set verbose",
"show_target", "clear_target", "run", "back", "exit", "quit",
@@ -411,6 +411,11 @@ async fn execute_single_command(ctx: &mut ShellContext, cmd_input: &str) -> bool
render_help_topic(&rest);
}
}
"tommy" => {
if let Err(e) = crate::tommy::run_guide() {
println!("{} {}", "tommy:".red(), e);
}
}
"modules" => utils::list_all_modules(),
"find" => {
if rest.is_empty() {
@@ -1214,6 +1219,7 @@ fn split_command(input: &str) -> Option<(String, String)> {
pub fn resolve_command(cmd: &str) -> String {
match cmd {
"?" | "help" | "h" => "help",
"tommy" | "guide" | "walkthrough" => "tommy",
"modules" | "list" | "ls" | "m" => "modules",
"find" | "search" | "f" | "f1" => "find",
@@ -1282,6 +1288,10 @@ async fn display_all_options(ctx: &ShellContext) {
("verbose", "Verbose output (y/n)", ""),
("honeypot_detection", "Skip honeypot hosts (y/n)", ""),
("prescan", "Pre-scan tool (auto/masscan/zmap/none)", ""),
("scan_order", "Full-sweep order (random/sequential)", ""),
("exclusions", "Extra networks to skip in mass scans (CIDR,CIDR,...)", ""),
("target_rps", "Per-target rate limit (req/s, 0 = unlimited)", ""),
("module_rps", "Global module rate limit (req/s, 0 = unlimited)", ""),
];
let mut displayed = std::collections::HashSet::new();
@@ -1393,6 +1403,63 @@ async fn handle_set_target(raw_value: &str) {
println!("{}", format!("[!] Failed to set target: {}", e).red());
}
}
// `0.0.0.0` is an alias for the full-internet sweep `0.0.0.0/0`
// (== `random`). Flag it so the operator isn't surprised that a
// bare `0.0.0.0` now sweeps every public host; the scan itself asks
// for an explicit confirmation before fanning out.
//
// When the operator switches to a full sweep we auto-bump
// `max_random_hosts` to the actual count of reachable public
// IPv4 addresses (after the reserved-range filter). Without this,
// the scheduler default of 10_000 would silently cap a "scan
// everything" target at 10k hosts.
//
// We only do this when `max_random_hosts` is currently UNSET — if
// the operator already chose a value we leave it alone.
if matches!(final_target.as_str(), "0.0.0.0" | "0.0.0.0/0" | "random") {
println!(
"{}",
" ⚠ Full-internet sweep (every public host). You'll be asked to confirm at run time."
.yellow()
);
if crate::global_options::GLOBAL_OPTIONS
.get("max_random_hosts")
.await
.is_none()
{
let total = crate::utils::cyclic::total_public_ipv4_count();
if crate::global_options::GLOBAL_OPTIONS
.set("max_random_hosts", &total.to_string())
.await
{
println!(
"{}",
format!(
" [+] max_random_hosts auto-set to {} (every reachable public IPv4 — \
RFC1918/multicast/127.0.0.0/8/.0/.255 excluded). \
`setg max_random_hosts <n>` to lower it.",
total
)
.green()
);
} else {
println!(
"{}",
format!(
" [!] could not persist max_random_hosts={} — falling back to scheduler default",
total
)
.yellow()
);
}
} else {
println!(
"{}",
" [*] max_random_hosts already set — leaving operator value alone.".dimmed()
);
}
}
}
Err(reason) => {
println!("{}", format!("[!] {}", reason).yellow());
@@ -1442,6 +1509,7 @@ fn render_help() {
println!(" {}", "Navigation & Discovery".bold().underline());
println!();
println!(" {:<20} {:<24} Show this screen", "help".green(), "h | ?".dimmed());
println!(" {:<20} {:<24} Friendly walk-through guide (a/d to page)", "tommy".green(), "guide".dimmed());
println!(" {:<20} {:<24} List all available modules", "modules".green(), "ls | m".dimmed());
println!(" {:<20} {:<24} Search modules by keyword", "find <kw>".green(), "f1 <kw>".dimmed());
println!(" {:<20} {:<24} Select a module to load", "use <path>".green(), "u <path>".dimmed());
@@ -1535,7 +1603,7 @@ fn render_help() {
MAX_COMMAND_CHAIN_LENGTH);
println!("{}", "└──────────────────────────────────────────────────────────────────────────┘".dimmed());
println!();
println!(" {} {}", "Topics:".bold(), "use info run set setg target mass-scan jobs creds hosts services loot workspace resource spool export".dimmed());
println!(" {} {}", "Topics:".bold(), "tommy use info run set setg target mass-scan jobs creds hosts services loot workspace resource spool export".dimmed());
println!();
}
@@ -1546,6 +1614,7 @@ fn render_help_topic(topic: &str) {
let canonical: &str = match key {
"?" | "help" | "h" => "help",
"guide" | "walkthrough" => "tommy",
"u" => "use",
"i" => "info",
"ls" | "list" | "m" => "modules",
@@ -1931,7 +2000,18 @@ fn render_help_topic(topic: &str) {
("help mass-scan", "How mass-scan mode works"),
("? setg", "Alias"),
],
&["mass-scan", "setg", "run"],
&["mass-scan", "setg", "run", "tommy"],
),
"tommy" => man_page(
"tommy",
"Friendly interactive walk-through guide for new users. Pages step you through every major rustsploit feature with examples you can copy and try.",
&["tommy", "guide", "walkthrough"],
"Navigate with single keys: `d` (or Enter) for the next page, `a` for the previous page, `q` to quit. Type a page number to jump directly to it. Type `h` inside the guide for the full key list. The guide is read-only — running it never changes any settings.",
&[
("tommy", "Start at page 1"),
("guide", "Alias"),
],
&["help", "modules", "use", "run"],
),
_ => {
println!();
@@ -1939,7 +2019,7 @@ fn render_help_topic(topic: &str) {
println!();
println!("{} {}",
"Available topics:".bold(),
"use info modules find target subnet show_target clear_target run setg unsetg show_options mass-scan jobs creds hosts services notes loot workspace resource makerc spool export back exit help".dimmed());
"tommy use info modules find target subnet show_target clear_target run setg unsetg show_options mass-scan jobs creds hosts services notes loot workspace resource makerc spool export back exit help".dimmed());
println!();
}
}
+728
View File
@@ -0,0 +1,728 @@
//! `tommy` — Friendly Interactive Walk-through Guide
//! ===================================================
//!
//! A new-user guide to every major rustsploit feature. Pages are flipped
//! with the same keys the user already knows from a casual menu — `d`
//! (next), `a` (previous), `q` (quit). A short hint is printed on every
//! page so the user does not need to remember.
//!
//! The pages are static `&'static str` blocks so the guide costs nothing
//! at start-up and can be embedded directly in the binary.
//!
//! Hooked into the interactive shell via the `tommy` command.
use std::io::{self, BufRead, Write};
use colored::*;
/// One page in the walk-through.
struct Page {
title: &'static str,
body: &'static str,
}
/// All pages in display order. Edit this list to extend the guide.
const PAGES: &[Page] = &[
Page {
title: "Welcome",
body: WELCOME,
},
Page {
title: "1. The interactive shell",
body: SHELL,
},
Page {
title: "2. Finding the right module",
body: FIND,
},
Page {
title: "3. Setting a target",
body: TARGET,
},
Page {
title: "4. Running a module",
body: RUN,
},
Page {
title: "5. Global options (setg)",
body: SETG,
},
Page {
title: "6. Scanners",
body: SCANNERS,
},
Page {
title: "7. Credential modules",
body: CREDS,
},
Page {
title: "8. Exploit modules",
body: EXPLOITS,
},
Page {
title: "9. OSINT modules",
body: OSINT,
},
Page {
title: "10. Plugins",
body: PLUGINS,
},
Page {
title: "11. Workspace, hosts, services, notes",
body: WORKSPACE,
},
Page {
title: "12. Credential store",
body: CRED_STORE,
},
Page {
title: "13. Loot & evidence",
body: LOOT,
},
Page {
title: "14. Background jobs",
body: JOBS,
},
Page {
title: "15. Resource scripts (makerc)",
body: RESOURCE,
},
Page {
title: "16. Spool & export",
body: SPOOL,
},
Page {
title: "17. The API + MCP server",
body: API,
},
Page {
title: "18. Building & tests",
body: BUILD,
},
Page {
title: "19. Writing your own module",
body: WRITE,
},
Page {
title: "20. Good-luck",
body: GOODLUCK,
},
];
/// Entry point — call from the shell when the user types `tommy`.
///
/// Blocks the calling thread on `stdin` for paging input. Returns `Ok(())`
/// on a clean exit; an error from stdin propagates back to the caller so
/// the shell can decide what to do.
pub fn run_guide() -> io::Result<()> {
let stdin = io::stdin();
let mut input = stdin.lock();
let mut buf = String::new();
let mut idx: usize = 0;
let total = PAGES.len();
loop {
clear_and_render(idx, total)?;
buf.clear();
let n = input.read_line(&mut buf)?;
if n == 0 {
// EOF — treat as quit so piped input doesn't loop forever
println!();
println!("{} {}", "[*]".cyan(), "End of input — exiting tommy.".dimmed());
return Ok(());
}
let key = buf.trim().to_ascii_lowercase();
match key.as_str() {
"d" | "n" | "next" | "" => {
if idx + 1 < total {
idx += 1;
} else {
println!("{}", "Already on the last page — press 'a' to go back or 'q' to quit.".yellow());
pause(&mut input, &mut buf)?;
}
}
"a" | "p" | "prev" | "back" => {
if idx > 0 {
idx -= 1;
} else {
println!("{}", "Already on the first page — press 'd' to move forward or 'q' to quit.".yellow());
pause(&mut input, &mut buf)?;
}
}
"q" | "quit" | "exit" => {
println!();
println!(
"{} {}",
"[+]".green(),
"You're ready to go — run `help` any time for the full reference.".bold()
);
println!();
return Ok(());
}
"g" | "start" | "first" => idx = 0,
"e" | "end" | "last" => idx = total - 1,
"h" | "?" | "help" => {
show_help_overlay();
pause(&mut input, &mut buf)?;
}
other => {
// Allow `5` / `12` to jump directly to a page number
if let Ok(page_num) = other.parse::<usize>() {
if page_num >= 1 && page_num <= total {
idx = page_num - 1;
continue;
}
}
println!(
"{} unknown key '{}'. press 'h' for keys.",
"[-]".yellow(),
other
);
pause(&mut input, &mut buf)?;
}
}
}
}
fn clear_and_render(idx: usize, total: usize) -> io::Result<()> {
let mut out = io::stdout().lock();
// ANSI clear-screen + home cursor. Works in xterm-compatible terminals;
// if the user's terminal swallows ANSI we still scroll cleanly because
// we re-emit the full frame.
write!(out, "\x1b[2J\x1b[H")?;
out.flush()?;
let page = &PAGES[idx];
print_banner();
println!();
println!(
" {} {} {}{} {}",
"Page".bold(),
format!("{}", idx + 1).cyan().bold(),
"of ".dimmed(),
format!("{}", total).cyan(),
format!("{}", page.title).bold().underline()
);
println!();
println!("{}", page.body);
println!();
println!("{}", " ╭─────────────────────────────────────────────────────────────────╮".dimmed());
println!(
" {} {} prev {} next {} quit {} keys {} jump to page",
"".dimmed(),
"[a]".cyan().bold(),
"[d]".cyan().bold(),
"[q]".cyan().bold(),
"[h]".cyan().bold(),
"[1-N]".cyan().bold(),
);
println!("{}", " ╰─────────────────────────────────────────────────────────────────╯".dimmed());
print!(" > ");
io::stdout().flush()?;
Ok(())
}
fn print_banner() {
println!("{}", BANNER.cyan());
}
fn show_help_overlay() {
println!();
println!("{}", " ┌─ Navigation keys ─────────────────────────────────────────────┐".bold());
println!(
" │ {} next page {} previous page │",
"d / n / Enter".cyan().bold(),
"a / p".cyan().bold()
);
println!(
" │ {} {} jump to start / end │",
"q / quit".cyan().bold(),
"g / e".cyan().bold()
);
println!(
" │ {} skip directly to page N (e.g. 7) │",
"<number>".cyan().bold()
);
println!("{}", " └──────────────────────────────────────────────────────────────┘".bold());
}
fn pause<R: BufRead>(input: &mut R, buf: &mut String) -> io::Result<()> {
print!(" {} ", "press Enter to continue...".dimmed());
io::stdout().flush()?;
buf.clear();
input.read_line(buf)?;
Ok(())
}
// ---------------------------------------------------------------------------
// ASCII art + page content
// ---------------------------------------------------------------------------
const BANNER: &str = r#"
_________________________________________________________________
| .---. .---. .--. _____ ___ ___ _ _ __ __ ____ |
| \ \/ / / /\ | |_ _/ _ \| \/ | | \/ \/ / _ \ |
| \ / / /__\ | | || | | | |\/| | |\ /\ /| | | ||
| / /\ \ / ______| | || |_| | | | | | |\/|\/|| |_| ||
| /__/ \__\ /__/ \ |_| \___/|_| |_| |_| |_|\___/ |
| tommy the friendly rustsploit walk-through |
|_______________________________________________________________|
"#;
const WELCOME: &str = r#"
Hi! I'm tommy. I'll walk you through every feature of rustsploit so
you can move from "just installed it" to "running real engagements"
in a few minutes.
.--------.
| >_< | <- tommy
|--------|
|________|
I'll keep things short:
* one screen per topic
* a command you can copy & try at the bottom of each page
* no surprises every step is a read-only command unless I say so
Press {d} (or {Enter}) to start. Press {a} to step back, {q} to quit.
If you ever forget the keys, press {h}.
"#;
const SHELL: &str = r#"
The interactive shell is what you see right now. Launch it any time
with:
$ rustsploit
You can also run modules from the command line directly (great for
scripting) see `rustsploit --help`.
Useful shortcuts inside the shell:
help show the full command reference
help <topic> man-page for a single command (e.g. `help run`)
? same as help
h same as help
q quit the shell
Ctrl-C cancel the current prompt
Ctrl-D send EOF (quits)
Tab tab-completion for commands and module paths
Up / Down scroll through command history
Try this:
help run
"#;
const FIND: &str = r#"
There are 800+ modules. To browse:
modules list every loaded module
find <keyword> fuzzy-match the registry
Examples:
find synology -> scanners/synology_dsm_disclosure
find mikrotik -> exploits/routers/mikrotik/...
find tomcat -> exploits/frameworks/apache_tomcat/...
find ssh -> creds/ssh_bruteforce, exploits/ssh/...
Once you've picked one, look at its metadata:
info exploits/webapps/plex_unclaimed_takeover
This shows the CVE references, author, rank, and default port.
Try this:
find plex
"#;
const TARGET: &str = r#"
Targets can be a single IP, a hostname, a CIDR, or a file.
set target 192.168.1.10
set target example.com
set target 10.0.0.0/24
set target file:/path/to/ips.txt
set target 10.0.0.1,10.0.0.2,10.0.0.3 (comma list)
To check what's set:
show_target (alias: st)
To clear it:
clear_target (alias: ct)
You can also set a target port that applies to every module:
set port 8080
Random / sweep targets are also supported. See `help mass-scan` for
the full story.
Try this:
set target 127.0.0.1
show_target
"#;
const RUN: &str = r#"
Three steps to launch a module:
1. use <module_path> (pick what to run)
2. show options (see what knobs exist)
3. run (go!)
Example:
use scanners/port_scanner
show options
set port 1-1024
run
Module-specific options are prompted at run-time, so you don't have
to memorise them. Just hit Enter to accept the default.
Aliases:
u <path> == use <path>
go / exec / ra == run
back clear the selected module
"#;
const SETG: &str = r#"
`setg` is the Metasploit-style "datastore" values that persist
across modules:
setg port 443
setg concurrency 64
setg timeout 30
setg wordlist /usr/share/wordlists/rockyou.txt
setg honeypot_detection y
These apply to every module that reads the same key.
View / clear:
show options (alias: so)
unsetg port (alias: ug)
Common useful keys:
port default target port
source_port outgoing bind port
concurrency parallel scans for mass targets
timeout per-module timeout (seconds)
wordlist default brute-force wordlist
prescan masscan / zmap / none
verbose y/n
"#;
const SCANNERS: &str = r#"
Scanners are read-only. Use them first.
Highlights:
scanners/port_scanner
scanners/service_scanner
scanners/ping_sweep
scanners/dir_brute
scanners/synology_dsm_disclosure <- new!
scanners/cobaltstrike_beacon_scanner <- new!
scanners/ssl_scanner
scanners/snmp_scanner
scanners/honeypot_scanner
scanners/cors_reflection_scanner
scanners/security_headers_scanner
Pipe one into the next with `&`:
set target 10.0.0.0/24 & use scanners/port_scanner & run
& use scanners/service_scanner & run
The scheduler routes every finding into the workspace, so the next
step (exploits / creds) inherits the hosts you discovered.
"#;
const CREDS: &str = r#"
Cred modules brute-force / spray credentials.
creds/ftp_bruteforce
creds/ssh_bruteforce
creds/telnet_bruteforce
creds/mysql_bruteforce
creds/postgres_bruteforce
creds/snmp_bruteforce
creds/vnc_bruteforce
creds/rdp_bruteforce
creds/imap_bruteforce / pop3 / smtp
creds/redis_bruteforce
creds/m365_activesync_spray
creds/h3c_oem_kvm_bruteforce
creds/ssh_spray (one password, many hosts)
creds/telnet_hose (parallel telnet drag-net)
Wordlists default to common SecLists paths but can be set:
setg wordlist /usr/share/wordlists/rockyou.txt
Every successful credential lands in the credential store (see the
next page).
"#;
const EXPLOITS: &str = r#"
Exploit modules try to deliver a specific CVE. They are organised by
target type:
exploits/webapps/... web application CVEs
exploits/frameworks/... tomcat, jenkins, php, mongodb, ...
exploits/network_infra/... cisco, citrix, fortinet, vmware, ...
exploits/routers/... dlink, netgear, mikrotik, tplink, ...
exploits/cameras/... hikvision, abus, acti, ...
exploits/vnc/... libvnc / tigervnc / tightvnc / x11vnc
exploits/honeytrap/... Cowrie, Dionaea, HoneyTrap, SNARE
exploits/ssh/... CVE-2024-6387 regreSSHion, ...
exploits/voip/... FreePBX, MagnusBilling, ...
exploits/payloadgens/... payload generators
exploits/windows/... Windows-only CVEs
exploits/bluetooth/... BLE (feature-gated)
New in your tree:
exploits/routers/mikrotik/routeros_jailbreak_cve_2023_30799
exploits/webapps/doverfsp_fusion_authbypass
exploits/webapps/plex_unclaimed_takeover
exploits/webapps/redeye_c2_unauth_project
exploits/frameworks/metasploit_pro/setup_state_bypass
Each module ranks itself `Excellent`, `Great`, `Normal`, ... so
you can prioritise.
"#;
const OSINT: &str = r#"
OSINT modules do passive intelligence work. No traffic to the
target.
osint/cert_transparency crt.sh subdomain enumeration
osint/cname_chain follow CNAME redirects
osint/jwks_inspector grab and analyse JWKS keys
Run them when you want to map an attack surface before sending a
single packet to the target itself.
"#;
const PLUGINS: &str = r#"
Plugins are third-party modules. Drop a `.rs` file into
`src/modules/plugins/` and rebuild; the build-time registry picks
it up automatically (no manual mod.rs editing required for the
plugin discovery path).
See:
sample_plugin.rs in src/modules/plugins/
for a 30-line template. Most operators keep client-specific or
non-public modules here.
"#;
const WORKSPACE: &str = r#"
The workspace tracks hosts, services, and notes for the current
engagement:
workspace show / list workspaces
workspace acme-pentest create or switch
hosts list tracked hosts
hosts add 10.0.0.5
hosts delete 10.0.0.5
services list tracked services
services add 10.0.0.5 22 ssh
notes 10.0.0.5 "uses fail2ban"
Modules that find new hosts automatically record them you don't
have to add things by hand most of the time.
Try this:
hosts
"#;
const CRED_STORE: &str = r#"
When a creds module hits a working login, it lands in the cred
store:
creds list all creds
creds search admin filter by service/user
creds add add one interactively
creds delete <id>
creds validate <id> re-verify the cred works
creds invalidate <id> mark as stale
creds clear drop everything (asks first)
Persistence is JSON on disk, scoped to the current workspace.
"#;
const LOOT: &str = r#"
Loot is everything else: dumps, screenshots, downloaded files,
evidence.
loot list all loot
loot add <path> add a file as loot
loot search <keyword>
loot delete <id>
loot clear
Each loot entry stores: file path, source module, host, type, and
a free-form description. Great for end-of-engagement reporting.
"#;
const JOBS: &str = r#"
Run a module in the background:
run -j
Manage the queue:
jobs list background jobs
jobs -k <id> kill a job
jobs clean forget finished jobs
Use this when you've started a long mass scan and want to keep
probing other things in the foreground.
"#;
const RESOURCE: &str = r#"
Save your commands and replay them later:
makerc /tmp/today.rc writes the current history
Replay any file:
resource /tmp/today.rc
This makes great runbooks for repeating an engagement. Pair it with
`setg` for portable defaults.
"#;
const SPOOL: &str = r#"
Mirror all console output to a file:
spool /tmp/console.log start logging
spool off stop logging
At the end of the engagement, dump everything to a report:
export json /tmp/engagement.json
export csv /tmp/findings.csv
export summary /tmp/report.txt
"#;
const API: &str = r#"
rustsploit also ships:
* a REST + WebSocket API with post-quantum encrypted transport
see `docs/API-Server.md`
* an MCP (Model Context Protocol) server 38 tools exposed
over stdio for AI-assisted pentesting
see `docs/Module-Catalog.md` + `src/mcp/`
The API gives full CRUD over hosts, services, credentials, loot,
and jobs. The MCP server is how you drive rustsploit from Claude
Code or another MCP-capable agent.
"#;
const BUILD: &str = r#"
Quick build / test recipes:
cargo build default features
cargo build --no-default-features no Bluetooth
cargo build --features bluetooth with BLE
cargo run launch the shell
cargo test run unit tests
cargo check fast compile check
Strict patterns audit (the project's own lint matrix):
bash scripts/audit-bad-patterns.sh
See `docs/BAD_PATTERNS.md` for the 133-regex grep matrix every
module must pass.
"#;
const WRITE: &str = r#"
Adding a module is a single file + one line in the parent mod.rs:
1. drop yourmodule.rs into src/modules/<category>/
2. add `pub mod yourmodule;` to the category's mod.rs
3. inside the file:
pub fn info() -> ModuleInfo { ... }
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> { ... }
crate::register_native_module!(
crate::module::Category::Exploits,
"webapps/yourmodule",
native
);
Read `src/modules/exploits/sample_exploit.rs` for a 70-line
template, or `docs/Module-Development.md` for the full lifecycle.
Use `cfg_prompt_default(...)` / `cfg_prompt_port(...)` to ask the
user for runtime values those values flow through the scheduler
the same way `setg` values do.
"#;
const GOODLUCK: &str = r#"
You've made it to the end. A small recap:
* find discover modules
* use pick one
* set / setg configure
* run go
* help <cmd> when in doubt
Remember:
* every module is read-only unless its docs say otherwise
* the workspace keeps track of what you find use it
* if you write something useful, send a PR
Happy hunting!
.--------.
| ^_^ | <- tommy says good-luck
|--------|
|________|
Press {q} to leave the guide.
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_have_unique_titles() {
let mut titles: Vec<&str> = PAGES.iter().map(|p| p.title).collect();
titles.sort();
let original_len = titles.len();
titles.dedup();
assert_eq!(titles.len(), original_len, "page titles must be unique");
}
#[test]
fn every_page_has_body() {
for p in PAGES {
assert!(
!p.body.trim().is_empty(),
"page {} has empty body",
p.title
);
}
}
}
+5 -3
View File
@@ -437,13 +437,15 @@ pub fn subnet_host_count(net: &ipnetwork::IpNetwork) -> u128 {
// ============================================================
/// Check if a target triggers mass scan mode.
/// Recognizes: "random", "0.0.0.0/0", CIDR subnets, and file paths.
/// Bare "0.0.0.0" is intentionally excluded (M45): it is a normal single
/// host, not a full-internet random-scan keyword.
/// Recognizes: "random", "0.0.0.0/0", bare "0.0.0.0", CIDR subnets, and file
/// paths. Bare "0.0.0.0" is an operator alias for the full-internet sweep
/// (2026-06-09, reversing the earlier M45 single-host rule); the scheduler
/// still gates the actual sweep behind an advisory + confirmation.
pub fn is_mass_scan_target(target: &str) -> bool {
let lower = target.trim().to_ascii_lowercase();
target == "random"
|| target == "0.0.0.0/0"
|| target == "0.0.0.0"
|| lower == "seq"
|| lower == "sequential"
|| lower.starts_with("seq:")
+257
View File
@@ -0,0 +1,257 @@
//! ZMap-style stateless cyclic-group IPv4 address iterator.
//!
//! Ported to Rust from ZMap's address-generation algorithm (`lib/cyclic.c` /
//! `lib/random.c`; Durumeric, Wustrow & Halderman, *"ZMap: Fast Internet-Wide
//! Scanning and its Security Applications"*, USENIX Security 2013 — Apache-2.0).
//!
//! ## Why
//!
//! The naïve random scanner samples addresses uniformly and remembers every one
//! it has already emitted in a `HashSet` to avoid repeats. That has two fatal
//! problems at internet scale:
//! * **memory** — the "seen" set grows with every host, so covering a large
//! fraction of the 2³² space needs gigabytes of state, and
//! * **coupon-collector waste** — as the seen set fills up, an ever-larger
//! share of fresh samples are duplicates that get thrown away.
//!
//! ## How ZMap solves it
//!
//! Treat the address space as the multiplicative group of integers modulo a
//! prime `P` just larger than 2³². If `g` is a *primitive root* of that group
//! (its powers generate every nonzero residue), then starting from any element
//! and repeatedly multiplying by `g (mod P)` walks **every** value in
//! `1..=P-1` exactly once before returning to the start — a full pseudo-random
//! permutation of the address space.
//!
//! The entire iterator state is three integers (`generator`, `start`,
//! `current`): O(1) memory, no dedup table, and **no repeats** until the whole
//! space is covered. A fresh random `start` each run gives a different ordering.
//!
//! `P = 4_294_967_311` is the smallest prime greater than 2³². Group elements
//! `1..=2³²` map to addresses `0..=2³²-1` (element `v` → address `v-1`); the 14
//! elements in `(2³², P-1]` are not real addresses and are skipped. Reserved /
//! excluded addresses are filtered by the caller, not here — this type's job is
//! purely to enumerate the space once, in permuted order.
use std::net::Ipv4Addr;
use anyhow::{anyhow, Result};
/// Smallest prime greater than 2³²; the order-defining modulus of the group.
const P: u64 = 4_294_967_311;
/// Size of the IPv4 address space (2³²).
const IPV4_SPACE: u64 = 1 << 32;
/// Distinct prime factors of `P - 1` (= 2 · 3² · 5 · 131 · 364289). Used to
/// test whether a candidate is a primitive root of `Z_P*`. Validated against
/// `P - 1` in the unit tests so a typo can't silently weaken the test.
const PRIME_FACTORS: [u64; 5] = [2, 3, 5, 131, 364_289];
/// `(a * b) mod m` via a 128-bit intermediate so the 32-bit-scale operands
/// never overflow.
#[inline]
fn mulmod(a: u64, b: u64, m: u64) -> u64 {
((a as u128 * b as u128) % m as u128) as u64
}
/// `base^exp mod m` by square-and-multiply.
fn powmod(mut base: u64, mut exp: u64, m: u64) -> u64 {
let mut acc = 1u64;
base %= m;
while exp > 0 {
if exp & 1 == 1 {
acc = mulmod(acc, base, m);
}
base = mulmod(base, base, m);
exp >>= 1;
}
acc
}
/// `g` is a primitive root of `Z_P*` iff `g^((P-1)/q) != 1 (mod P)` for every
/// distinct prime factor `q` of `P-1`.
fn is_primitive_root(g: u64) -> bool {
g % P != 0 && PRIME_FACTORS.iter().all(|&q| powmod(g, (P - 1) / q, P) != 1)
}
/// First primitive root of `Z_P*`. The smallest one for this prime is tiny
/// (well under 100), so the bounded search always succeeds on the first few
/// candidates; the `Err` arm exists only so a (theoretically impossible) bad
/// factorisation surfaces instead of being swallowed.
fn find_primitive_root() -> Result<u64> {
(2..10_000)
.find(|&g| is_primitive_root(g))
.ok_or_else(|| anyhow!("no primitive root found for P={P}; PRIME_FACTORS is wrong"))
}
/// Stateless permutation of the entire IPv4 address space (see module docs).
pub struct CyclicIp {
generator: u64,
start: u64,
current: u64,
started: bool,
done: bool,
}
impl CyclicIp {
/// Build an iterator whose permutation starts at `seed` (any `u64`, mapped
/// into the group's `1..=P-1` range). Deterministic for a given seed, which
/// makes the permutation reproducible (and unit-testable).
pub fn with_seed(seed: u64) -> Result<Self> {
let generator = find_primitive_root()?;
// Group elements are 1..=P-1; fold the seed into that range.
let start = seed % (P - 1) + 1;
Ok(Self {
generator,
start,
current: start,
started: false,
done: false,
})
}
/// Build an iterator from a fresh random start, so each run walks the space
/// in a different pseudo-random order.
pub fn random() -> Result<Self> {
let seed: u64 = rand::random();
Self::with_seed(seed)
}
}
impl Iterator for CyclicIp {
type Item = Ipv4Addr;
fn next(&mut self) -> Option<Ipv4Addr> {
loop {
if self.done {
return None;
}
if self.started {
self.current = mulmod(self.current, self.generator, P);
// A primitive root has order P-1, so the orbit returns to
// `start` only after every one of the P-1 elements has been
// visited. That return is the natural end-of-space marker.
if self.current == self.start {
self.done = true;
return None;
}
} else {
self.started = true;
}
let val = self.current; // 1..=P-1
if (1..=IPV4_SPACE).contains(&val) {
return Some(Ipv4Addr::from((val - 1) as u32));
}
// val in (2^32, P-1]: not a real address — skip, keep cycling.
}
}
}
/// Coarse reserved-range filter, matching the old `generate_random_public_ip`
/// rejection rules so the cyclic scanner covers the same target set: skip
/// `0/8`, `10/8`, `127/8`, multicast/reserved (`>=224`), and the per-subnet
/// network (`.0`) and broadcast (`.255`) host addresses. Finer ranges
/// (RFC1918 172.16/12, link-local, Cloudflare, ...) are handled by the
/// operator's exclusion set, not here.
pub fn is_reserved_ipv4(a: Ipv4Addr) -> bool {
let o = a.octets();
o[0] == 0 || o[0] == 10 || o[0] == 127 || o[0] >= 224 || o[3] == 0 || o[3] == 255
}
/// Total count of public IPv4 addresses reachable by the cyclic scanner under
/// the [`is_reserved_ipv4`] filter — i.e. the size of the address set the
/// scanner will actually iterate over when targeting `random` / `0.0.0.0/0`.
///
/// Computed from the same rules as [`is_reserved_ipv4`]:
/// * 35 of 256 `/8` blocks are reserved (`0`, `10`, `127`, plus `224..=255`).
/// * Inside each remaining `/8`, the last-octet `.0` and `.255` addresses
/// are skipped — so each of the 65,536 `/24`s contributes 254 hosts.
///
/// Result: `(256 - 35) * 65_536 * 254 = 3_678_797_824` host addresses.
pub const fn total_public_ipv4_count() -> u64 {
let reserved_slash8 = 1u64 // 0/8
+ 1 // 10/8
+ 1 // 127/8
+ (255 - 224 + 1); // 224/4 + class E (224..=255 inclusive)
let usable_slash8 = 256u64 - reserved_slash8;
// 65_536 /24s per /8, 254 hosts per /24 (.0 and .255 skipped).
usable_slash8 * 65_536 * 254
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// The whole construction hinges on `PRIME_FACTORS` being the *complete*
/// set of distinct prime factors of `P-1`. Verify both that each listed
/// factor is prime and that, with the right multiplicities, they multiply
/// back to `P-1` — together that proves the set is complete and correct,
/// which in turn makes `is_primitive_root` sound.
#[test]
fn prime_factorisation_of_p_minus_one_is_correct() {
fn is_prime(n: u64) -> bool {
if n < 2 {
return false;
}
let mut d = 2u64;
while d * d <= n {
if n % d == 0 {
return false;
}
d += 1;
}
true
}
for &q in &PRIME_FACTORS {
assert!(is_prime(q), "{q} listed in PRIME_FACTORS is not prime");
}
// P-1 = 2^1 · 3^2 · 5^1 · 131^1 · 364289^1
assert_eq!(2u64 * 3 * 3 * 5 * 131 * 364_289, P - 1);
}
#[test]
fn generator_is_a_primitive_root() {
let g = find_primitive_root().expect("primitive root must exist");
assert!(is_primitive_root(g));
// Sanity: g^(P-1) == 1 but no proper divisor exponent yields 1.
assert_eq!(powmod(g, P - 1, P), 1);
}
#[test]
fn early_prefix_has_no_repeats_and_is_in_range() {
// A full 2^32 coverage test is infeasible, but the primitive-root
// property guarantees full coverage; here we just confirm the first
// chunk of the walk is repeat-free and only yields real addresses.
let mut it = CyclicIp::with_seed(0x1234_5678_9abc_def0).expect("ctor");
let mut seen = HashSet::new();
for _ in 0..200_000 {
let a = it.next().expect("space not exhausted this early");
assert!(seen.insert(a), "duplicate address {a} in permutation prefix");
}
}
#[test]
fn small_seed_maps_into_group() {
// seed 0 must still produce a valid in-range start (not 0).
let it = CyclicIp::with_seed(0).expect("ctor");
assert!((1..=(P - 1)).contains(&it.start));
}
/// The closed-form `total_public_ipv4_count()` MUST equal the count of
/// addresses that survive `is_reserved_ipv4()` over the full 32-bit
/// space — otherwise the constant we expose to operators would silently
/// disagree with what the scanner actually iterates.
#[test]
fn total_public_ipv4_count_matches_filter() {
let mut counted: u64 = 0;
// Counting all 2^32 addresses in a Rust test takes ~10s on a laptop,
// which is fine for `cargo test` and keeps the invariant honest.
for raw in 0u64..=u32::MAX as u64 {
let addr = Ipv4Addr::from(raw as u32);
if !is_reserved_ipv4(addr) {
counted += 1;
}
}
assert_eq!(counted, total_public_ipv4_count());
}
}
+3
View File
@@ -4,16 +4,19 @@
pub mod bruteforce;
pub mod creds_helper;
pub mod cyclic;
pub mod exploit_helper;
pub mod modules;
pub mod network;
pub mod parallel;
pub mod privilege;
pub mod prompt;
pub mod recog;
pub mod safe_io;
pub mod sanitize;
pub mod target;
pub mod throttle;
pub mod tls_fingerprint;
pub mod wordlist;
use colored::*;
+57 -1
View File
@@ -488,6 +488,12 @@ pub struct HttpClientOpts {
/// modules and high-concurrency scanners can pass a smaller cap, or
/// `Some(0)` to fully disable the connection pool.
pub pool_max_idle_per_host: Option<usize>,
/// Override `pool_idle_timeout` (reqwest default: ~90s) — how long an idle
/// keep-alive connection is retained before being reaped. The cached
/// permissive client sets a shorter timeout so a long full-internet sweep
/// doesn't accumulate idle connections to huge numbers of distinct hosts.
/// `None` keeps reqwest's default.
pub pool_idle_timeout: Option<Duration>,
}
impl HttpClientOpts {
@@ -564,7 +570,53 @@ pub fn get_global_trust_proxy() -> bool {
/// no cookies, no redirects). Prints a one-time info notice if source_port is
/// set (reqwest doesn't support it).
pub fn build_http_client(timeout: Duration) -> Result<reqwest::Client, reqwest::Error> {
build_http_client_with(timeout, HttpClientOpts::permissive())
// Hot-path cache. The permissive client is rebuilt on essentially every HTTP
// module run, and each `reqwest::Client::builder().build()` re-initialises the
// TLS config and a brand-new connection pool. reqwest clients are `Arc`
// internally and share their pool across clones, so we keep one per
// (timeout, accept_invalid_certs) and hand out cheap clones instead — reusing
// warm connections across runs in the common scan loop.
//
// The key includes `accept_invalid_certs` because `permissive()` derives it
// from the live `strict_tls` global; without it, toggling `setg strict_tls`
// would be ignored once a client was cached. Custom-opts clients (cookies,
// headers, redirects, …) go through `build_http_client_with` and are NOT
// cached, since those options can't be shared safely or keyed cheaply.
static CACHE: once_cell::sync::Lazy<
std::sync::Mutex<std::collections::HashMap<(u64, bool), reqwest::Client>>,
> = once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let mut opts = HttpClientOpts::permissive();
// This client is long-lived and shared across the whole process, so bound
// how long idle keep-alives linger. Short enough to reap stale hosts during
// a sweep, long enough to reuse connections within a single run.
opts.pool_idle_timeout = Some(Duration::from_secs(30));
let key = (timeout.as_millis() as u64, opts.accept_invalid_certs);
// A poisoned lock here is recoverable: the map holds only cloneable clients,
// so a prior panic cannot have left it logically inconsistent. Recover rather
// than propagate a panic into every HTTP caller.
{
let cache = match CACHE.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(client) = cache.get(&key) {
return Ok(client.clone());
}
}
// Build outside the lock so a slow `build()` can't serialise other callers.
let client = build_http_client_with(timeout, opts)?;
let mut cache = match CACHE.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
// A concurrent caller may have inserted an equivalent client meanwhile; last
// writer wins and both are interchangeable, so just overwrite.
cache.insert(key, client.clone());
Ok(client)
}
/// Build a reqwest HTTP client with extended options. Every exploit module
@@ -624,6 +676,10 @@ pub fn build_http_client_with(
builder = builder.pool_max_idle_per_host(cap);
}
if let Some(idle) = opts.pool_idle_timeout {
builder = builder.pool_idle_timeout(idle);
}
// Honour SSRF DNS pins (anti-rebinding). For unpinned hosts this falls back
// to a standard system lookup, so behaviour is unchanged outside API mode.
builder = builder.dns_resolver(PinningResolver);
+604
View File
@@ -0,0 +1,604 @@
//! Recog fingerprint engine — service / product / OS detection from banners.
//!
//! This is a Rust port of the matcher loop from Rapid7's **Recog**
//! (<https://github.com/rapid7/recog>) and the `recog-go` reference
//! implementation. Recog is distributed under the **BSD-2-Clause** license.
//!
//! The vendored XML fingerprint databases under `src/utils/recog_db/` are
//! trimmed subsets of the upstream Recog databases (BSD-2-Clause,
//! <https://github.com/rapid7/recog/tree/main/xml>). They retain the upstream
//! `<fingerprints>` / `<fingerprint>` / `<param>` schema so the matcher logic
//! mirrors Recog exactly.
//!
//! ## Format
//!
//! ```xml
//! <fingerprints matches="...">
//! <fingerprint pattern="REGEX">
//! <description>..</description>
//! <example>..</example>
//! <param pos="0" name="service.product" value="OpenSSH"/>
//! <param pos="1" name="service.version"/>
//! </fingerprint>
//! </fingerprints>
//! ```
//!
//! Matching rule (per Recog):
//! - Compile each `<fingerprint>` `pattern` (case-insensitive) once at load.
//! - For an input banner, the first fingerprint whose regex matches wins.
//! - Build the result map from its `<param>`s:
//! - `pos="0"` → literal `value` (interpolating `{field}` placeholders that
//! refer to already-extracted fields).
//! - `pos>=1` → the value comes from regex capture group N.
use once_cell::sync::Lazy;
use quick_xml::events::Event;
use quick_xml::Reader;
use regex::Regex;
use std::collections::BTreeMap;
/// A single compiled fingerprint: its regex plus the parameter recipe.
struct Fingerprint {
regex: Regex,
/// Each entry: (pos, name, literal_value). For `pos == 0` the value is the
/// literal (possibly with `{field}` placeholders); for `pos >= 1` the value
/// comes from capture group `pos` and `literal_value` is empty.
params: Vec<RecogParam>,
}
struct RecogParam {
pos: usize,
name: String,
value: String,
}
/// One named fingerprint database (e.g. the SSH banners DB).
pub struct RecogDb {
name: &'static str,
fingerprints: Vec<Fingerprint>,
}
/// The outcome of matching a banner against a database.
#[derive(Debug, Clone, Default)]
pub struct RecogMatch {
pub matched: bool,
pub fields: BTreeMap<String, String>,
}
impl RecogMatch {
/// Convenience accessor for a field (e.g. `"service.product"`).
pub fn get(&self, key: &str) -> Option<&str> {
self.fields.get(key).map(|s| s.as_str())
}
/// Detected product name, if any (`service.product`).
pub fn product(&self) -> Option<&str> {
self.get("service.product")
}
/// Detected version, if any (`service.version`).
pub fn version(&self) -> Option<&str> {
self.get("service.version")
}
/// Detected vendor, if any (`service.vendor`).
pub fn vendor(&self) -> Option<&str> {
self.get("service.vendor")
}
/// Detected OS product, if any (`os.product`).
pub fn os_product(&self) -> Option<&str> {
self.get("os.product")
}
/// A compact human-readable "product version" summary, falling back to
/// whatever fields exist. Returns `None` when nothing useful was extracted.
pub fn summary(&self) -> Option<String> {
let product = self.product();
let version = self.version();
match (product, version) {
(Some(p), Some(v)) => Some(format!("{} {}", p, v)),
(Some(p), None) => Some(p.to_string()),
(None, Some(v)) => Some(v.to_string()),
(None, None) => self.os_product().map(|s| s.to_string()),
}
}
}
impl RecogDb {
/// Match a banner against this DB; first matching fingerprint wins.
pub fn match_banner(&self, banner: &str) -> RecogMatch {
// Recog typically matches against a single line; try the whole banner
// first, then fall back to the first non-empty line so multi-line
// greetings (e.g. an FTP 220-multiline banner) still resolve.
let candidates: [&str; 1] = [banner.trim()];
for candidate in candidates {
if let Some(m) = self.match_one(candidate) {
tracing::trace!(db = self.name, "recog: banner matched");
return m;
}
}
// Fall back to the first non-empty line.
if let Some(line) = banner.lines().map(|l| l.trim()).find(|l| !l.is_empty()) {
if line != banner.trim() {
if let Some(m) = self.match_one(line) {
tracing::trace!(db = self.name, "recog: banner matched (first line)");
return m;
}
}
}
RecogMatch::default()
}
fn match_one(&self, input: &str) -> Option<RecogMatch> {
for fp in &self.fingerprints {
let caps = match fp.regex.captures(input) {
Some(c) => c,
None => continue,
};
let mut fields: BTreeMap<String, String> = BTreeMap::new();
// Two passes, matching Recog semantics: a param carrying a non-empty
// `value` is a literal/interpolated assignment; a param with no
// `value` reads regex capture group `pos`. Capture params must run
// first so that `{service.version}`-style placeholders in a literal
// (e.g. a `cpe23` template) resolve even when the literal param
// appears before its source field in the XML — and even when the
// template param also carries a `pos` attribute.
//
// Pass 1: capture-group params.
for param in &fp.params {
if !param.value.is_empty() {
continue;
}
// A non-participating optional group is simply skipped.
if let Some(g) = caps.get(param.pos) {
let text = g.as_str();
if !text.is_empty() {
fields.insert(param.name.clone(), text.to_string());
}
}
}
// Pass 2: literal / interpolated params.
for param in &fp.params {
if param.value.is_empty() {
continue;
}
let interpolated = interpolate(&param.value, &fields);
fields.insert(param.name.clone(), interpolated);
}
return Some(RecogMatch {
matched: true,
fields,
});
}
None
}
}
/// Replace `{field.name}` placeholders in a literal param value with previously
/// extracted field values. Unknown placeholders are left untouched.
fn interpolate(value: &str, fields: &BTreeMap<String, String>) -> String {
if !value.contains('{') {
return value.to_string();
}
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
match after.find('}') {
Some(end) => {
let key = &after[..end];
match fields.get(key) {
Some(v) => out.push_str(v),
None => {
// Keep the placeholder verbatim if we can't resolve it.
out.push('{');
out.push_str(key);
out.push('}');
}
}
rest = &after[end + 1..];
}
None => {
// Unbalanced brace — emit the remainder literally and stop.
out.push('{');
out.push_str(after);
rest = "";
}
}
}
out.push_str(rest);
out
}
/// Parse one embedded Recog XML document into a `RecogDb`. A fingerprint whose
/// `pattern` fails to compile is logged and skipped (never panics).
fn parse_db(name: &'static str, xml: &str) -> RecogDb {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut fingerprints: Vec<Fingerprint> = Vec::new();
// State for the fingerprint currently being assembled.
let mut cur_pattern: Option<String> = None;
let mut cur_params: Vec<RecogParam> = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) if e.name().as_ref() == b"fingerprint" => {
cur_pattern = read_pattern_attr(e, name);
cur_params.clear();
}
Ok(Event::Empty(ref e)) if e.name().as_ref() == b"param" => {
if let Some(param) = read_param(e, name) {
cur_params.push(param);
}
}
Ok(Event::End(ref e)) if e.name().as_ref() == b"fingerprint" => {
if let Some(pattern) = cur_pattern.take() {
match build_regex(&pattern) {
Ok(regex) => fingerprints.push(Fingerprint {
regex,
params: std::mem::take(&mut cur_params),
}),
Err(err) => {
tracing::warn!(
db = name,
pattern = %pattern,
"recog: skipping fingerprint with invalid regex: {}",
err
);
cur_params.clear();
}
}
} else {
cur_params.clear();
}
}
Ok(Event::Eof) => break,
Err(err) => {
tracing::warn!(db = name, "recog: XML parse error, stopping: {}", err);
break;
}
_ => {}
}
buf.clear();
}
RecogDb { name, fingerprints }
}
/// Decode a raw attribute value: UTF-8 decode the bytes, then resolve XML
/// entities (`&amp;`, `&lt;`, etc). Avoids quick-xml's deprecated
/// `unescape_value` while still being entity-correct.
fn attr_value(a: &quick_xml::events::attributes::Attribute, db: &str) -> Option<String> {
let raw = match std::str::from_utf8(a.value.as_ref()) {
Ok(s) => s,
Err(err) => {
tracing::warn!(db, "recog: attribute value is not valid UTF-8: {}", err);
return None;
}
};
match quick_xml::escape::unescape(raw) {
Ok(v) => Some(v.into_owned()),
Err(err) => {
tracing::warn!(db, "recog: failed to unescape attribute value: {}", err);
None
}
}
}
/// Read the `pattern` attribute off a `<fingerprint>` start tag.
fn read_pattern_attr(e: &quick_xml::events::BytesStart, db: &str) -> Option<String> {
for attr in e.attributes() {
match attr {
Ok(a) if a.key.as_ref() == b"pattern" => return attr_value(&a, db),
Ok(_) => {}
Err(err) => {
tracing::warn!(db, "recog: bad attribute on <fingerprint>: {}", err);
}
}
}
None
}
/// Read a `<param pos="N" name="..." value="..."/>` element into a `RecogParam`.
fn read_param(e: &quick_xml::events::BytesStart, db: &str) -> Option<RecogParam> {
let mut pos: Option<usize> = None;
let mut pname: Option<String> = None;
let mut value = String::new();
for attr in e.attributes() {
let a = match attr {
Ok(a) => a,
Err(err) => {
tracing::warn!(db, "recog: bad attribute on <param>: {}", err);
continue;
}
};
match a.key.as_ref() {
b"pos" => {
let raw = match attr_value(&a, db) {
Some(v) => v,
None => continue,
};
match raw.parse::<usize>() {
Ok(n) => pos = Some(n),
Err(err) => {
tracing::warn!(db, pos = %raw, "recog: non-numeric param pos: {}", err);
}
}
}
b"name" => {
if let Some(v) = attr_value(&a, db) {
pname = Some(v);
}
}
b"value" => {
if let Some(v) = attr_value(&a, db) {
value = v;
}
}
_ => {}
}
}
match (pos, pname) {
(Some(pos), Some(name)) => Some(RecogParam { pos, name, value }),
_ => {
tracing::warn!(db, "recog: <param> missing pos or name attribute; skipping");
None
}
}
}
/// Compile a Recog pattern. Recog patterns are case-insensitive by default and
/// the `^`/`$` anchors match at string boundaries (not per-line), matching the
/// upstream Ruby `Regexp` defaults used by recog-go.
fn build_regex(pattern: &str) -> Result<Regex, regex::Error> {
regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
}
// ---------------------------------------------------------------------------
// Lazy registry — parse each embedded XML once, pre-compiling all regexes.
// ---------------------------------------------------------------------------
static SSH_DB: Lazy<RecogDb> =
Lazy::new(|| parse_db("ssh", include_str!("recog_db/ssh_banners.xml")));
static FTP_DB: Lazy<RecogDb> =
Lazy::new(|| parse_db("ftp", include_str!("recog_db/ftp_banners.xml")));
static SMTP_DB: Lazy<RecogDb> =
Lazy::new(|| parse_db("smtp", include_str!("recog_db/smtp_banners.xml")));
static HTTP_DB: Lazy<RecogDb> =
Lazy::new(|| parse_db("http", include_str!("recog_db/http_servers.xml")));
static MYSQL_DB: Lazy<RecogDb> =
Lazy::new(|| parse_db("mysql", include_str!("recog_db/mysql_banners.xml")));
/// Look up a fingerprint database by short name (`ssh`, `ftp`, `smtp`, `http`,
/// `mysql`). Returns `None` for unknown names.
pub fn db(name: &str) -> Option<&'static RecogDb> {
match name {
"ssh" => Some(&SSH_DB),
"ftp" => Some(&FTP_DB),
"smtp" => Some(&SMTP_DB),
"http" => Some(&HTTP_DB),
"mysql" => Some(&MYSQL_DB),
_ => None,
}
}
/// Match a banner against the named database. If the database name is unknown
/// the returned `RecogMatch` has `matched == false`.
pub fn match_banner(db_name: &str, banner: &str) -> RecogMatch {
match db(db_name) {
Some(database) => database.match_banner(banner),
None => {
tracing::warn!(db = db_name, "recog: requested unknown fingerprint database");
RecogMatch::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_openssh_with_os_comment() {
let m = match_banner("ssh", "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6");
assert!(m.matched, "expected OpenSSH banner to match");
assert_eq!(m.product(), Some("OpenSSH"));
assert_eq!(m.version(), Some("8.9"));
assert_eq!(m.vendor(), Some("OpenBSD"));
assert_eq!(
m.get("service.cpe23"),
Some("cpe:/a:openbsd:openssh:8.9"),
"interpolated CPE should embed the extracted version"
);
}
#[test]
fn ssh_openssh_no_comment() {
let m = match_banner("ssh", "SSH-2.0-OpenSSH_7.4");
assert!(m.matched);
assert_eq!(m.product(), Some("OpenSSH"));
assert_eq!(m.version(), Some("7.4"));
}
#[test]
fn ssh_dropbear() {
let m = match_banner("ssh", "SSH-2.0-dropbear_2020.81");
assert!(m.matched);
assert_eq!(m.product(), Some("Dropbear SSH"));
assert_eq!(m.version(), Some("2020.81"));
}
#[test]
fn ssh_routeros_os_fields() {
let m = match_banner("ssh", "SSH-2.0-ROSSSH");
assert!(m.matched);
assert_eq!(m.product(), Some("RouterOS"));
assert_eq!(m.os_product(), Some("RouterOS"));
}
#[test]
fn ftp_proftpd() {
let m = match_banner(
"ftp",
"220 ProFTPD 1.3.5b Server (Debian) [::ffff:10.0.0.1]",
);
assert!(m.matched, "expected ProFTPD banner to match");
assert_eq!(m.product(), Some("ProFTPD"));
assert_eq!(m.version(), Some("1.3.5b"));
assert_eq!(m.get("service.cpe23"), Some("cpe:/a:proftpd:proftpd:1.3.5b"));
}
#[test]
fn ftp_vsftpd() {
let m = match_banner("ftp", "220 (vsFTPd 3.0.3)");
assert!(m.matched);
assert_eq!(m.product(), Some("vsftpd"));
assert_eq!(m.version(), Some("3.0.3"));
}
#[test]
fn ftp_pureftpd_no_version() {
let m = match_banner("ftp", "220---------- Welcome to Pure-FTPd ----------");
assert!(m.matched);
assert_eq!(m.product(), Some("Pure-FTPd"));
assert_eq!(m.version(), None, "Pure-FTPd hides its version");
}
#[test]
fn smtp_exim() {
let m = match_banner(
"smtp",
"220 mail.example.com ESMTP Exim 4.94.2 Mon, 01 Jan 2024 00:00:00 +0000",
);
assert!(m.matched, "expected Exim banner to match");
assert_eq!(m.product(), Some("Exim"));
assert_eq!(m.version(), Some("4.94.2"));
assert_eq!(m.get("service.cpe23"), Some("cpe:/a:exim:exim:4.94.2"));
}
#[test]
fn smtp_postfix() {
let m = match_banner("smtp", "220 mail.example.com ESMTP Postfix (Ubuntu)");
assert!(m.matched);
assert_eq!(m.product(), Some("Postfix"));
}
#[test]
fn smtp_sendmail() {
let m = match_banner(
"smtp",
"220 mail.example.com ESMTP Sendmail 8.15.2/8.15.2; Mon, 1 Jan 2024 00:00:00 GMT",
);
assert!(m.matched);
assert_eq!(m.product(), Some("Sendmail"));
assert_eq!(m.version(), Some("8.15.2/8.15.2"));
}
#[test]
fn http_apache_with_os() {
let m = match_banner("http", "Apache/2.4.52 (Ubuntu)");
assert!(m.matched);
assert_eq!(m.product(), Some("HTTP Server"));
assert_eq!(m.vendor(), Some("Apache"));
assert_eq!(m.version(), Some("2.4.52"));
assert_eq!(m.os_product(), Some("Ubuntu"));
}
#[test]
fn http_nginx() {
let m = match_banner("http", "nginx/1.18.0");
assert!(m.matched);
assert_eq!(m.product(), Some("nginx"));
assert_eq!(m.version(), Some("1.18.0"));
}
#[test]
fn http_iis_os_fields() {
let m = match_banner("http", "Microsoft-IIS/10.0");
assert!(m.matched);
assert_eq!(m.product(), Some("IIS"));
assert_eq!(m.version(), Some("10.0"));
assert_eq!(m.os_product(), Some("Windows"));
}
#[test]
fn mysql_mariadb() {
let m = match_banner("mysql", "10.5.12-MariaDB-1:10.5.12+maria~focal");
assert!(m.matched);
assert_eq!(m.product(), Some("MariaDB"));
assert_eq!(m.version(), Some("10.5.12"));
}
#[test]
fn mysql_plain_version() {
let m = match_banner("mysql", "5.7.38");
assert!(m.matched);
assert_eq!(m.product(), Some("MySQL"));
assert_eq!(m.version(), Some("5.7.38"));
}
#[test]
fn unknown_db_does_not_match() {
let m = match_banner("does-not-exist", "anything");
assert!(!m.matched);
assert!(m.summary().is_none());
}
#[test]
fn non_matching_banner() {
let m = match_banner("ssh", "not-an-ssh-banner-at-all");
assert!(!m.matched);
}
#[test]
fn summary_helper() {
let m = match_banner("ssh", "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6");
assert_eq!(m.summary().as_deref(), Some("OpenSSH 8.9"));
}
#[test]
fn malformed_regex_is_skipped_not_panicking() {
// An unbalanced group is invalid; parse_db must skip it without panic
// and still load the valid fingerprint that follows.
let xml = r#"<fingerprints matches="t">
<fingerprint pattern="(unterminated">
<param pos="0" name="service.product" value="Broken"/>
</fingerprint>
<fingerprint pattern="^GOOD-([\d.]+)$">
<param pos="0" name="service.product" value="Good"/>
<param pos="1" name="service.version"/>
</fingerprint>
</fingerprints>"#;
let db = parse_db("test", xml);
assert_eq!(db.fingerprints.len(), 1, "broken fingerprint must be skipped");
let m = db.match_banner("GOOD-1.2");
assert!(m.matched);
assert_eq!(m.product(), Some("Good"));
assert_eq!(m.version(), Some("1.2"));
}
#[test]
fn all_databases_load() {
for name in ["ssh", "ftp", "smtp", "http", "mysql"] {
let database = db(name).expect("database should resolve");
assert!(
!database.fingerprints.is_empty(),
"db {} parsed zero fingerprints",
database.name
);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Vendored from Rapid7 Recog (https://github.com/rapid7/recog), BSD-2-Clause.
Trimmed subset of xml/ftp_banners.xml for rustsploit's recog matcher.
-->
<fingerprints matches="ftp.banner">
<fingerprint pattern="^220[- ]ProFTPD\s+([\d.]+[a-z]?(?:rc\d+)?)\s+Server">
<description>ProFTPD FTP server</description>
<example service.version="1.3.5b" service.product="ProFTPD">220 ProFTPD 1.3.5b Server (Debian) [::ffff:10.0.0.1]</example>
<param pos="0" name="service.vendor" value="ProFTPD Project"/>
<param pos="0" name="service.family" value="ProFTPD"/>
<param pos="0" name="service.product" value="ProFTPD"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:proftpd:proftpd:{service.version}"/>
</fingerprint>
<fingerprint pattern="^220[- ].*\(vsFTPd\s+([\d.]+)\)">
<description>vsftpd FTP server</description>
<example service.version="3.0.3" service.product="vsftpd">220 (vsFTPd 3.0.3)</example>
<param pos="0" name="service.vendor" value="vsftpd"/>
<param pos="0" name="service.family" value="vsftpd"/>
<param pos="0" name="service.product" value="vsftpd"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:vsftpd_project:vsftpd:{service.version}"/>
</fingerprint>
<fingerprint pattern="^220[- ].*Pure-FTPd">
<description>Pure-FTPd FTP server (version hidden by default)</description>
<example service.product="Pure-FTPd">220---------- Welcome to Pure-FTPd ----------</example>
<param pos="0" name="service.vendor" value="Pure-FTPd"/>
<param pos="0" name="service.family" value="Pure-FTPd"/>
<param pos="0" name="service.product" value="Pure-FTPd"/>
</fingerprint>
<fingerprint pattern="^220[- ].*FileZilla Server\s+(?:version\s+)?([\d.]+(?:[\s-]beta)?)">
<description>FileZilla FTP server for Windows</description>
<example service.version="0.9.41" service.product="FileZilla Server">220-FileZilla Server version 0.9.41 beta</example>
<param pos="0" name="service.vendor" value="FileZilla"/>
<param pos="0" name="service.family" value="FileZilla Server"/>
<param pos="0" name="service.product" value="FileZilla Server"/>
<param pos="1" name="service.version"/>
<param pos="0" name="os.vendor" value="Microsoft"/>
<param pos="0" name="os.family" value="Windows"/>
<param pos="0" name="os.product" value="Windows"/>
</fingerprint>
<fingerprint pattern="^220[- ]Microsoft FTP Service">
<description>Microsoft IIS FTP service</description>
<example service.product="Microsoft FTP Service">220 Microsoft FTP Service</example>
<param pos="0" name="service.vendor" value="Microsoft"/>
<param pos="0" name="service.family" value="IIS"/>
<param pos="0" name="service.product" value="Microsoft FTP Service"/>
<param pos="0" name="os.vendor" value="Microsoft"/>
<param pos="0" name="os.family" value="Windows"/>
<param pos="0" name="os.product" value="Windows"/>
</fingerprint>
<fingerprint pattern="^220[- ].*wu-([\d.]+)\(\d+\)">
<description>WU-FTPD FTP server</description>
<example service.version="2.6.2">220 FTP server (Version wu-2.6.2(1) Mon Sep 1 2003) ready.</example>
<param pos="0" name="service.vendor" value="WU-FTPD"/>
<param pos="0" name="service.family" value="WU-FTPD"/>
<param pos="0" name="service.product" value="WU-FTPD"/>
<param pos="1" name="service.version"/>
</fingerprint>
</fingerprints>
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Vendored from Rapid7 Recog (https://github.com/rapid7/recog), BSD-2-Clause.
Trimmed subset of xml/http_servers.xml for rustsploit's recog matcher.
Input is the value of the HTTP "Server:" response header.
-->
<fingerprints matches="http.server">
<fingerprint pattern="^Apache/([\d.]+)\s*\(([^)]+)\)">
<description>Apache httpd with OS detail in parentheses</description>
<example service.version="2.4.52" os.product="Ubuntu">Apache/2.4.52 (Ubuntu)</example>
<param pos="0" name="service.vendor" value="Apache"/>
<param pos="0" name="service.family" value="Apache"/>
<param pos="0" name="service.product" value="HTTP Server"/>
<param pos="1" name="service.version"/>
<param pos="2" name="os.product"/>
<param pos="1" name="service.cpe23" value="cpe:/a:apache:http_server:{service.version}"/>
</fingerprint>
<fingerprint pattern="^Apache/([\d.]+)$">
<description>Apache httpd with no OS detail</description>
<example service.version="2.4.41">Apache/2.4.41</example>
<param pos="0" name="service.vendor" value="Apache"/>
<param pos="0" name="service.family" value="Apache"/>
<param pos="0" name="service.product" value="HTTP Server"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:apache:http_server:{service.version}"/>
</fingerprint>
<fingerprint pattern="^nginx/([\d.]+)$">
<description>nginx web server</description>
<example service.version="1.18.0">nginx/1.18.0</example>
<param pos="0" name="service.vendor" value="Nginx"/>
<param pos="0" name="service.family" value="Nginx"/>
<param pos="0" name="service.product" value="nginx"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:nginx:nginx:{service.version}"/>
</fingerprint>
<fingerprint pattern="^nginx$">
<description>nginx web server with version suppressed</description>
<example>nginx</example>
<param pos="0" name="service.vendor" value="Nginx"/>
<param pos="0" name="service.family" value="Nginx"/>
<param pos="0" name="service.product" value="nginx"/>
</fingerprint>
<fingerprint pattern="^Microsoft-IIS/([\d.]+)$">
<description>Microsoft Internet Information Services</description>
<example service.version="10.0">Microsoft-IIS/10.0</example>
<param pos="0" name="service.vendor" value="Microsoft"/>
<param pos="0" name="service.family" value="IIS"/>
<param pos="0" name="service.product" value="IIS"/>
<param pos="1" name="service.version"/>
<param pos="0" name="os.vendor" value="Microsoft"/>
<param pos="0" name="os.family" value="Windows"/>
<param pos="0" name="os.product" value="Windows"/>
<param pos="1" name="service.cpe23" value="cpe:/a:microsoft:iis:{service.version}"/>
</fingerprint>
<fingerprint pattern="^lighttpd/([\d.]+)$">
<description>lighttpd web server</description>
<example service.version="1.4.59">lighttpd/1.4.59</example>
<param pos="0" name="service.vendor" value="lighttpd"/>
<param pos="0" name="service.family" value="lighttpd"/>
<param pos="0" name="service.product" value="lighttpd"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:lighttpd:lighttpd:{service.version}"/>
</fingerprint>
<fingerprint pattern="^Jetty\(([\d.]+\.v\d+)\)$">
<description>Eclipse Jetty servlet engine</description>
<example service.version="9.4.43.v20210629">Jetty(9.4.43.v20210629)</example>
<param pos="0" name="service.vendor" value="Eclipse"/>
<param pos="0" name="service.family" value="Jetty"/>
<param pos="0" name="service.product" value="Jetty"/>
<param pos="1" name="service.version"/>
</fingerprint>
</fingerprints>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Vendored from Rapid7 Recog (https://github.com/rapid7/recog), BSD-2-Clause.
Trimmed subset of xml/mysql_banners.xml for rustsploit's recog matcher.
Input is the version string from the MySQL/MariaDB handshake greeting packet.
-->
<fingerprints matches="mysql.banner">
<fingerprint pattern="^([\d.]+)-MariaDB(?:-([\w.+~:-]+))?$">
<description>MariaDB database server, optionally with distro suffix</description>
<example service.version="10.5.12" service.product="MariaDB">10.5.12-MariaDB-1:10.5.12+maria~focal</example>
<param pos="0" name="service.vendor" value="MariaDB"/>
<param pos="0" name="service.family" value="MariaDB"/>
<param pos="0" name="service.product" value="MariaDB"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:mariadb:mariadb:{service.version}"/>
</fingerprint>
<fingerprint pattern="^([\d.]+)-(\d+(?:\.\d+)*)?-?Ubuntu">
<description>MySQL server on Ubuntu</description>
<example service.version="8.0.29" os.product="Ubuntu">8.0.29-0ubuntu0.20.04.3</example>
<param pos="0" name="service.vendor" value="Oracle"/>
<param pos="0" name="service.family" value="MySQL"/>
<param pos="0" name="service.product" value="MySQL"/>
<param pos="1" name="service.version"/>
<param pos="0" name="os.vendor" value="Canonical"/>
<param pos="0" name="os.family" value="Linux"/>
<param pos="0" name="os.product" value="Ubuntu"/>
<param pos="1" name="service.cpe23" value="cpe:/a:oracle:mysql:{service.version}"/>
</fingerprint>
<fingerprint pattern="^([\d.]+)-log$">
<description>MySQL server with binary logging enabled</description>
<example service.version="5.7.38">5.7.38-log</example>
<param pos="0" name="service.vendor" value="Oracle"/>
<param pos="0" name="service.family" value="MySQL"/>
<param pos="0" name="service.product" value="MySQL"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:oracle:mysql:{service.version}"/>
</fingerprint>
<fingerprint pattern="^([\d.]+)$">
<description>Generic MySQL server version string</description>
<example service.version="5.7.38">5.7.38</example>
<param pos="0" name="service.vendor" value="Oracle"/>
<param pos="0" name="service.family" value="MySQL"/>
<param pos="0" name="service.product" value="MySQL"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:oracle:mysql:{service.version}"/>
</fingerprint>
</fingerprints>
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Vendored from Rapid7 Recog (https://github.com/rapid7/recog), BSD-2-Clause.
Trimmed subset of xml/smtp_banners.xml for rustsploit's recog matcher.
-->
<fingerprints matches="smtp.banner">
<fingerprint pattern="^220[- ].*ESMTP Exim\s+([\d.]+)">
<description>Exim mail transfer agent</description>
<example service.version="4.94.2" service.product="Exim">220 mail.example.com ESMTP Exim 4.94.2 Mon, 01 Jan 2024 00:00:00 +0000</example>
<param pos="0" name="service.vendor" value="Exim"/>
<param pos="0" name="service.family" value="Exim"/>
<param pos="0" name="service.product" value="Exim"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:exim:exim:{service.version}"/>
</fingerprint>
<fingerprint pattern="^220[- ].*ESMTP Postfix">
<description>Postfix mail transfer agent (version usually hidden)</description>
<example service.product="Postfix">220 mail.example.com ESMTP Postfix (Ubuntu)</example>
<param pos="0" name="service.vendor" value="Postfix"/>
<param pos="0" name="service.family" value="Postfix"/>
<param pos="0" name="service.product" value="Postfix"/>
</fingerprint>
<fingerprint pattern="^220[- ].*ESMTP Sendmail\s+([\d.]+(?:/[\d.]+)?)">
<description>Sendmail mail transfer agent</description>
<example service.version="8.15.2" service.product="Sendmail">220 mail.example.com ESMTP Sendmail 8.15.2/8.15.2; Mon, 1 Jan 2024 00:00:00 GMT</example>
<param pos="0" name="service.vendor" value="Sendmail"/>
<param pos="0" name="service.family" value="Sendmail"/>
<param pos="0" name="service.product" value="Sendmail"/>
<param pos="1" name="service.version"/>
<param pos="1" name="service.cpe23" value="cpe:/a:sendmail:sendmail:{service.version}"/>
</fingerprint>
<fingerprint pattern="^220[- ].*Microsoft ESMTP MAIL Service.*Version:\s*([\d.]+)">
<description>Microsoft Exchange / IIS SMTP service</description>
<example service.version="6.0.3790.4675">220 mail.example.com Microsoft ESMTP MAIL Service, Version: 6.0.3790.4675 ready</example>
<param pos="0" name="service.vendor" value="Microsoft"/>
<param pos="0" name="service.family" value="Exchange"/>
<param pos="0" name="service.product" value="Exchange"/>
<param pos="1" name="service.version"/>
<param pos="0" name="os.vendor" value="Microsoft"/>
<param pos="0" name="os.family" value="Windows"/>
<param pos="0" name="os.product" value="Windows"/>
</fingerprint>
<fingerprint pattern="^220[- ].*\bSMTP\b.*\bhMailServer\b">
<description>hMailServer for Windows</description>
<example service.product="hMailServer">220 mail ESMTP hMailServer</example>
<param pos="0" name="service.vendor" value="hMailServer"/>
<param pos="0" name="service.family" value="hMailServer"/>
<param pos="0" name="service.product" value="hMailServer"/>
<param pos="0" name="os.vendor" value="Microsoft"/>
<param pos="0" name="os.family" value="Windows"/>
<param pos="0" name="os.product" value="Windows"/>
</fingerprint>
</fingerprints>
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Vendored from Rapid7 Recog (https://github.com/rapid7/recog), BSD-2-Clause.
Trimmed subset of xml/ssh_banners.xml for rustsploit's recog matcher.
-->
<fingerprints matches="ssh.banner">
<fingerprint pattern="^SSH-[\d.]+-OpenSSH[_-]([\d.]+)(?:p(\d+))?\s+(.*)$">
<description>OpenSSH running on a Linux/Unix host with an OS comment</description>
<example service.version="8.9" service.product="OpenSSH">SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6</example>
<param pos="0" name="service.vendor" value="OpenBSD"/>
<param pos="0" name="service.family" value="OpenSSH"/>
<param pos="0" name="service.product" value="OpenSSH"/>
<param pos="1" name="service.version"/>
<param pos="3" name="service.cpe23" value="cpe:/a:openbsd:openssh:{service.version}"/>
</fingerprint>
<fingerprint pattern="^SSH-[\d.]+-OpenSSH[_-]([\d.]+)(?:p\d+)?$">
<description>OpenSSH with no trailing OS comment</description>
<example service.version="7.4" service.product="OpenSSH">SSH-2.0-OpenSSH_7.4</example>
<param pos="0" name="service.vendor" value="OpenBSD"/>
<param pos="0" name="service.family" value="OpenSSH"/>
<param pos="0" name="service.product" value="OpenSSH"/>
<param pos="1" name="service.version"/>
</fingerprint>
<fingerprint pattern="^SSH-[\d.]+-dropbear[_-]([\d.]+)$">
<description>Dropbear SSH server, common on embedded devices</description>
<example service.version="2020.81" service.product="Dropbear SSH">SSH-2.0-dropbear_2020.81</example>
<param pos="0" name="service.vendor" value="Matt Johnston"/>
<param pos="0" name="service.family" value="Dropbear SSH"/>
<param pos="0" name="service.product" value="Dropbear SSH"/>
<param pos="1" name="service.version"/>
</fingerprint>
<fingerprint pattern="^SSH-[\d.]+-ROSSSH$">
<description>MikroTik RouterOS SSH service</description>
<example>SSH-2.0-ROSSSH</example>
<param pos="0" name="service.vendor" value="MikroTik"/>
<param pos="0" name="service.family" value="RouterOS"/>
<param pos="0" name="service.product" value="RouterOS"/>
<param pos="0" name="os.vendor" value="MikroTik"/>
<param pos="0" name="os.family" value="RouterOS"/>
<param pos="0" name="os.product" value="RouterOS"/>
</fingerprint>
<fingerprint pattern="^SSH-[\d.]+-libssh[_-]([\d.]+)$">
<description>libssh server library banner</description>
<example service.version="0.9.6">SSH-2.0-libssh_0.9.6</example>
<param pos="0" name="service.vendor" value="libssh"/>
<param pos="0" name="service.family" value="libssh"/>
<param pos="0" name="service.product" value="libssh"/>
<param pos="1" name="service.version"/>
</fingerprint>
<fingerprint pattern="^SSH-[\d.]+-Cisco-([\d.]+)$">
<description>Cisco network device SSH service</description>
<example service.version="1.25">SSH-2.0-Cisco-1.25</example>
<param pos="0" name="service.vendor" value="Cisco"/>
<param pos="0" name="service.family" value="IOS"/>
<param pos="0" name="service.product" value="IOS"/>
<param pos="0" name="os.vendor" value="Cisco"/>
<param pos="0" name="os.family" value="IOS"/>
<param pos="1" name="service.version"/>
</fingerprint>
</fingerprints>
File diff suppressed because it is too large Load Diff
+98 -2
View File
@@ -51,9 +51,51 @@ struct WordlistSpec {
//
// then paste the digest here. Until a list has a real checksum, leave it out
// — there is no "TODO" placeholder, by design.
// Curated subset of SecLists (https://github.com/danielmiessler/SecLists, MIT).
// Each SHA-256 was computed over the exact raw bytes served from the pinned
// `master` raw URL (the same bytes `verify_sha256` hashes after download).
// Sizes are noted for reference; they are not enforced.
const KNOWN_LISTS: &[WordlistSpec] = &[
// Catalogue entries are added by the maintainer after fetching + hashing
// each upstream artefact. Empty by design until verified.
// --- Passwords ---
WordlistSpec {
name: "passwords-top-1k",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/Pwdb_top-1000.txt",
sha256: "9a648a4f30a399af3fed0ff097d7c4f98a73e2e043ba9748d84a1c49f23c0725",
local_name: "seclists-pwdb-top-1000.txt",
},
WordlistSpec {
name: "passwords-top-10k",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/Pwdb_top-10000.txt",
sha256: "d9a018818f2357ac34c0534bdfd67826811859ae858bfd6398559085c7f4e925",
local_name: "seclists-pwdb-top-10000.txt",
},
// --- Usernames ---
WordlistSpec {
name: "usernames-short",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Usernames/top-usernames-shortlist.txt",
sha256: "dc44775d12dcdb4027017623ffaa935a018944839ce4b204ccb0c6ef566db5dd",
local_name: "seclists-usernames-shortlist.txt",
},
// --- Web content discovery ---
WordlistSpec {
name: "web-common",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/common.txt",
sha256: "fc320bacd30d93f5080029912b93667cd739401f81634579a7125fc0c027e6d5",
local_name: "seclists-web-common.txt",
},
WordlistSpec {
name: "web-raft-small-dirs",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-directories.txt",
sha256: "06e1ac7b390c17eb9e0da416d0599c785a1541813daa95b01c676bc92d55185f",
local_name: "seclists-raft-small-directories.txt",
},
// --- Subdomains (DNS) ---
WordlistSpec {
name: "subdomains-top5k",
url: "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt",
sha256: "e331367c140298cb179114fdeefa78f58f696219f0dec017a28bb79487cfcf19",
local_name: "seclists-subdomains-top5000.txt",
},
];
/// Resolve a logical wordlist name to a path on disk, downloading from the
@@ -608,3 +650,57 @@ pub fn load_lines_uncapped<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
.filter(|line| !line.is_empty())
.collect())
}
#[cfg(test)]
mod tests {
use super::{catalogue, KNOWN_LISTS};
/// Every catalogue entry must be structurally sound: a non-empty logical
/// name + local filename, an HTTPS raw URL, and a syntactically valid
/// SHA-256 (64 lowercase hex chars). This does NOT hit the network — it
/// guards against typos/placeholders that would make `resolve()` reject
/// every download for that entry.
#[test]
fn known_lists_are_well_formed() {
for spec in KNOWN_LISTS {
assert!(!spec.name.is_empty(), "entry has empty name");
assert!(
!spec.local_name.is_empty(),
"entry '{}' has empty local_name",
spec.name
);
assert!(
spec.url.starts_with("https://"),
"entry '{}' url is not https: {}",
spec.name,
spec.url
);
assert_eq!(
spec.sha256.len(),
64,
"entry '{}' sha256 is not 64 chars: {}",
spec.name,
spec.sha256
);
assert!(
spec.sha256
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')),
"entry '{}' sha256 has non lowercase-hex chars: {}",
spec.name,
spec.sha256
);
}
}
/// Logical names are the public lookup key, so they must be unique.
#[test]
fn known_list_names_are_unique() {
let names = catalogue();
for (i, a) in names.iter().enumerate() {
for b in names.iter().skip(i + 1) {
assert_ne!(a, b, "duplicate wordlist name in catalogue: {a}");
}
}
}
}